Merge upstream main (samwhosung/benilla c2fc3cf3) into the graphics line

Conflicts: capture fixtures/scenarios (both sides appended: kept both), options_tests
row counts (upstream 80->81 rows: ours 100->101 and 99->100), docs/MAP.md regenerated
with scripts/genmap.sh.
This commit is contained in:
pkuzic
2026-10-09 12:50:37 +01:00
139 changed files with 7449 additions and 1480 deletions
+28 -11
View File
@@ -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 @@
<NormalFont inherits="GameFontHighlightSmall"/>
<HighlightFont inherits="GameFontHighlightSmall"/>
<DisabledFont inherits="GameFontHighlightSmall"/>
<Scripts>
<OnShow>BenillaOptionsTab_FitWidth(this)</OnShow>
</Scripts>
</Button>
<Button name="$parentAddOnsTab">
<Size><AbsDimension x="87" y="37"/></Size>
@@ -2098,7 +2101,6 @@
<!-- No addon list behind it: disabled, in the era's unselected gold. -->
<Scripts>
<OnLoad>this:Disable()</OnLoad>
<OnShow>BenillaOptionsTab_FitWidth(this)</OnShow>
</Scripts>
</Button>
@@ -3982,11 +3984,24 @@
this.parentRow = "RowPlayerNames"
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowNpcNames" inherits="BenillaOptionsCheckboxRowTemplate">
<!-- A child of Player Names on its own CVar, CheckButton23 of
21 (`UIOptionsFrame.lua:24`), greyed with it (`:703-709`). -->
<Frame name="$parentRowPlayerTitles" inherits="BenillaOptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowGuildNames" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowGuildNames" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
BenillaOptionsRow_OnLoad(this, "UnitNamePlayerPVPTitle", "Player Titles",
"OPTION_TOOLTIP_SHOW_PLAYER_TITLES", 1)
this.parentRow = "RowPlayerNames"
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowNpcNames" inherits="BenillaOptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowPlayerTitles" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowPlayerTitles" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>BenillaOptionsRow_OnLoad(this, "UnitNameNPC", "NPC Names",
"OPTION_TOOLTIP_SHOW_NPC_NAMES")</OnLoad></Scripts>
</Frame>
@@ -4402,6 +4417,8 @@
<!-- Controls by default; selection persists and the scale re-fits, as the era's. -->
<OnShow>
BenillaOptionsFrame_UpdateScale()
BenillaOptionsTab_FitWidth(BenillaOptionsFrameGameTab)
BenillaOptionsTab_FitWidth(BenillaOptionsFrameAddOnsTab)
BenillaOptionsFrame_SelectCategory(this.selectedCategory or "Controls")
if UpdateMicroButtons then UpdateMicroButtons() end
</OnShow>
@@ -232,8 +232,9 @@
</Scripts>
</Button>
<Frame name="BenillaScriptLogFrame" hidden="true">
<!-- The standard left-slot panel size. No <Anchors>: ShowUIPanel seats it. -->
<Frame name="BenillaScriptLogFrame" parent="UIParent" hidden="true">
<!-- The standard left-slot panel size. No <Anchors>: ShowUIPanel seats it. Under UIParent,
as the stock panels are, so it takes the UI scale with them. -->
<Size><AbsDimension x="384" y="512"/></Size>
<Layers>
<Layer level="BACKGROUND">
@@ -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:?}");
});
}
}
+180 -22
View File
@@ -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<Res<AreaTableRes>>,
factions: Option<Res<Factions>>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
realm_pvp: Option<Res<RealmPvp>>,
mut cache: Local<crate::ui_script::VmMemo<ZoneCache>>,
) {
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<RealmPvp>,
roster: Option<Res<crate::char_select::Roster>>,
realms: Option<Res<crate::realm_select::Realms>>,
) {
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>) -> 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::<RealmPvp>()
.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<u32> = (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() {
+207 -7
View File
@@ -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<crate::ui_quest::QuestGiver>,
mut quest_log: ResMut<crate::ui_quest_log::QuestLog>,
mut loot: ResMut<crate::ui_loot::LootState>,
mut trainer: ResMut<crate::ui_trainer::TrainerOpen>,
// One param under Bevy's 16-param cap: item objects are entities in the index, spawned as
// the wire does.
store: (ResMut<crate::items::Items>, ResMut<crate::net::GuidIndex>),
// the wire does; the hearthstone bind (`SMSG_BINDPOINTUPDATE`) rides along for the `$z` token.
store: (
ResMut<crate::items::Items>,
ResMut<crate::net::GuidIndex>,
ResMut<crate::net::HomeBind>,
),
mut names: ResMut<crate::names::NameCache>,
icons: Option<Res<crate::entities::ItemDisplays>>,
mut script: Option<NonSendMut<benilla_ui::script::UiScript>>,
@@ -110,7 +176,7 @@ pub(super) fn seed_ui_fixture(
ResMut<crate::loading_screen::LoadingScreen>,
),
) {
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<TrainerSpell> = 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 } => {
+3 -1
View File
@@ -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`:
+3 -3
View File
@@ -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::<f32>(&format!(
"local f = getglobal(\"{name}\") return f and f:Get{edge}()"
"local f = getglobal(\"{name}\") return f and f:Get{edge}() * f:GetEffectiveScale()"
))
.ok()
};
@@ -18,6 +18,20 @@ pub(super) struct Scenario {
pub(super) ui: Option<UiFixture>,
}
/// 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).
+76 -1
View File
@@ -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::<crate::glue::GlueClicks>()
.init_resource::<ButtonInput<KeyCode>>()
.init_resource::<Roster>()
.init_resource::<Realms>()
.init_resource::<DeleteDialog>()
.init_resource::<super::super::addons::AddonsPanel>()
.init_resource::<crate::login::LoginIntent>()
.init_resource::<crate::glue::dialog::GlueDialog>()
.insert_resource(CharPick(pick_tx))
.insert_resource(RealmChoice(realm_tx))
.add_message::<GlueSound>()
.add_message::<bevy::input::mouse::MouseWheel>()
.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<Realms>| {
crate::realm_select::open_over_char_select(
&mut realms,
world.resource::<RealmChoice>(),
);
});
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Escape);
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.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::<State<ClientState>>().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"
);
}
}
}
@@ -18,6 +18,7 @@ use super::{class_name, Roster};
pub(super) fn refresh_list(
roster: Res<Roster>,
areas: Option<Res<AreaTableRes>>,
realms: Option<Res<crate::realm_select::Realms>>,
strings: Option<Res<GlueStrings>>,
status: Res<NetStatus>,
mut rows: Query<(&SelectAction, &mut Visibility), (With<Button>, Without<Hilight>)>,
@@ -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)"),
+1 -1
View File
@@ -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],
@@ -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.
+9 -2
View File
@@ -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::<NameConfig>(&app).npc);
apply(&mut app, "unitnameown", "1");
assert!(res::<NameConfig>(&app).own);
apply(&mut app, "unitnameplayerpvptitle", "0");
assert!(!res::<NameConfig>(&app).player_pvp_title);
apply(&mut app, "ChatBubbles", "0");
assert!(!res::<BubbleConfig>(&app).all);
apply(&mut app, "chatbubblesparty", "0");
+4 -2
View File
@@ -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`,
+4 -2
View File
@@ -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<bool>`), 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<FedDuel>` (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,
+9
View File
@@ -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<std::path::PathBuf> {
local_state::home()
}
/// What a crate on top of benilla adds to the built app ([`run_with`]).
type Extension<'a> = Box<dyn FnOnce(&mut App) + 'a>;
+70 -2
View File
@@ -664,8 +664,9 @@ fn login_input(
time: Res<Time>,
) {
// 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::<crate::glue::GlueClicks>()
.init_resource::<ButtonInput<KeyCode>>()
.init_resource::<crate::bindings::LayoutNames>()
.insert_non_send_resource(HostClipboard::default())
.init_resource::<LoginForm>()
.init_resource::<LoginIntent>()
.init_resource::<GlueDialog>()
.init_resource::<crate::realm_select::Realms>()
.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::<KeyboardInput>()
.add_message::<GlueSound>()
.add_message::<bevy::input::mouse::MouseWheel>()
.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::<crate::realm_select::Realms>()
.shown = true;
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Escape);
app.update();
let order = if list_first {
"list first"
} else {
"screen first"
};
assert!(
!app.world().resource::<crate::realm_select::Realms>().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::<QuitArm>(),
"{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]
+4 -5
View File
@@ -160,7 +160,6 @@ pub(super) type BlipInputs<'w, 's> = (
Query<'w, 's, &'static GlobalTransform, With<NetEntity>>,
Query<'w, 's, &'static Window, With<PrimaryWindow>>,
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<SelfPlayer>>,
@@ -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<Vec2>,
/// 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<Vec2>,
/// 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,
}
+4 -5
View File
@@ -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,
}
});
+10 -9
View File
@@ -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");
+391 -129
View File
@@ -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 `<Subname>`; a player's `<AFK>`/`<DND>`/`<GM>` prefixes,
//! name and `<Guild>`. 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 `<Subname>`; a player's `<AFK>`/`<DND>`/`<GM>`
//! prefixes, the a4 rank prefix (`UnitNamePlayerPVPTitle`, bit `0x20`) and name, and `<Guild>`.
//! 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_<rank>_<team>` 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<n>` 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<u64>,
/// Moves when a read changes the strings, so a line built from the old ones rebuilds.
generation: u64,
strings: HashMap<String, String>,
}
impl PvpNameStrings {
/// The VM session the strings were read from; `None` before the first VM.
pub(crate) fn session(&self) -> Option<u64> {
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<String, String>) {
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<String> {
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<TitleKey> {
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<TitleKey>, 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<String>,
paint: NamePaint,
/// `0x608f50`'s line inputs at build time.
flags: u32,
name: String,
bracketed: Option<String>,
key: Option<TitleKey>,
}
/// 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<NamePaint, Handle<StandardMaterial>>,
meshes: HashMap<Vec<String>, Handle<Mesh>>,
/// unit → (plate entity, the (lines, color) it was built with).
live: bevy::ecs::entity::EntityHashMap<(Entity, Vec<String>, NamePaint)>,
/// unit → its live plate and the inputs the stack was built from.
live: bevy::ecs::entity::EntityHashMap<LiveName>,
/// The atlas generation the meshes' UVs were built from; `None` before the first build.
baked_from: Option<u64>,
}
@@ -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<usize> {
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<crate::ui_party::GroupState>,
// The UnitName* CVar mask.
Res<NameConfig>,
// The rank strings, read off the current VM ([`PvpNameStrings`]).
Res<PvpNameStrings>,
),
names: Res<NameCache>,
// 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<crate::entities::mount::MountChild>>,
),
) {
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 `<AFK>` 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<crate::cvars::CvarChanged>, mut names: ResMut<NameConfig>) {
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::<Nameplates>()
.init_resource::<NameConfig>()
.init_resource::<PvpNameStrings>()
// 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 `<AFK>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<String, String> {
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, "<AFK>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("<Legacy>")),
(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"), "<AFK>Sergeant Bob");
let off = NameConfig {
player_pvp_title: false,
..cfg
};
assert_eq!(
line(&off, &sergeant, AFK, "Bob"),
"<AFK>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
}
+30 -12
View File
@@ -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::<CharActionResultMessage>()
.add_message::<CharacterLoginFailedMessage>()
.add_message::<EnteredWorldMessage>()
.add_message::<WorldEnterCascadeMessage>()
.add_message::<CinematicTriggeredMessage>()
.add_message::<ServerSaidMessage>()
.add_message::<LoggedOutMessage>()
@@ -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<EnteredWorldMessage>,
/// 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<WorldEnterCascadeMessage>,
commands: Res<NetCommands>,
clock: Res<ServerWallClock>,
status: Res<NetStatus>,
mut asked_at: Local<Option<Instant>>,
mut seated: Local<bool>,
) {
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<Vec<u8>>,
}
/// 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.
+31 -3
View File
@@ -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<SelfPlayer>>,
net: Res<NetCommands>,
mut cascades: MessageWriter<WorldEnterCascadeMessage>,
) {
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);
}
}
@@ -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<ClientCommand>) {
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::<ServerWallClock>()
.init_resource::<crate::ui_mail::MailPending>()
.init_resource::<crate::ui_battlefield::Battlefield>()
.init_resource::<crate::ui_dialog_verbs::MeetingStone>()
.add_message::<EnteredWorldMessage>()
.add_message::<WorldEnterCascadeMessage>()
.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<ClientCommand>) -> Vec<String> {
rx.try_iter()
.map(|c| match c {
ClientCommand::SetActiveMover { guid } => format!("SetActiveMover {guid:#x}"),
other => format!("{other:?}"),
})
.collect()
}
fn the_five() -> Vec<String> {
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::<String>::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::<String>::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::<String>::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
));
}
+17 -21
View File
@@ -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::<CharRequest>();
@@ -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);
},
}
}
+3 -3
View File
@@ -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<n>` rows,
// built on demand and hidden when empty, so the claim is the lowest shown row's bottom.
+1 -1
View File
@@ -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<ButtonInput<KeyCode>>,
(mut wheel, mut wheel_carry): (MessageReader<MouseWheel>, Local<WheelNotches>),
mut realms: ResMut<Realms>,
+63 -27
View File
@@ -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<super::Realms>,
assets: Option<Res<benilla_assets::WorldAssets>>,
) {
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`.
+53 -10
View File
@@ -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<Vec<RealmCategory>>,
/// `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<usize>,
@@ -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<usize> {
@@ -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<bool> {
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<Realms>) {
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::<Realms>()
// `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>| realms.owns_input()),
(tick_refresh, screen::refresh_list)
.chain()
.run_if(|realms: Res<Realms>| realms.shown),
)
.chain()
.run_if(|realms: Res<Realms>| realms.shown),
.chain(),
)
.chain()
.after(benilla_world::schedule::WorldStage::Net)
@@ -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),
+9
View File
@@ -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 {
@@ -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::<crate::net::GuidIndex>()
.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::<SpellTargeting>().active());
let (mut world, rx, ally) = unit_world(10.0);
world
.resource_mut::<crate::net::GuidIndex>()
.0
.insert(ALLY, ally);
world.init_resource::<InteractNpc>();
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::<SpellTargeting>().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.
+194 -52
View File
@@ -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<Selection>,
mut seam: crate::creature_anim::AttackSeam,
engaged: Query<(), (With<Engaged>, With<SelfPlayer>)>,
@@ -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>() = 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::<Messages<WorldRightClick>>()
.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::<crate::player::Approach>().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>() = PressPick {
hovered,
cursor: WorldCursor {
kind: cursor_mode::CursorKind::Pickup,
unable: false,
},
..PressPick::default()
};
world
.resource_mut::<Messages<WorldRightClick>>()
.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<ClientCommand>,
) {
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::<crate::net::GuidIndex>()
.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>() = PressPick {
hovered,
cursor: WorldCursor {
kind: cursor_mode::CursorKind::Pickup,
unable: false,
},
..PressPick::default()
};
world
.resource_mut::<Messages<WorldRightClick>>()
.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::<crate::ui_loot::LootLatch>().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::<crate::player::Approach>().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::<crate::player::Approach>().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::<crate::player::Approach>().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<ClientCommand> = 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::<crate::player::Approach>()
.arrived
.is_none());
}
}
}
+3 -1
View File
@@ -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;
+327 -113
View File
@@ -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<u8> {
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
+120
View File
@@ -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::<crate::net::GuidIndex>().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::<UiScript>()
.eval::<()>(lua)
.expect("the binding runs");
world
.run_system_once(
|mut script: NonSendMut<UiScript>, mut select: ScriptSelect| {
for request in script.take_selection_requests() {
select.select(request);
}
},
)
.expect("the applier runs as a one-shot system");
world.resource::<super::super::Selection>().guid
};
let reset = |world: &mut World| {
let mut sel = world.resource_mut::<super::super::Selection>();
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::<crate::net::GuidIndex>()
.0
.remove(&VENDOR);
assert_eq!(run(&mut world, r#"TargetUnit("npc")"#), Some(GUARD));
// The window closed: nobody, and the selection stays.
world
.resource_mut::<crate::net::GuidIndex>()
.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::<crate::net::GuidIndex>()
.0
.insert(VENDOR, vendor);
world.insert_resource(InteractNpc(Some(vendor), Some(VENDOR)));
let mut script = world.non_send_resource_mut::<UiScript>();
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<UiScript>, 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::<super::super::Selection>().guid,
Some(VENDOR)
);
}
}
+15
View File
@@ -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<Spells> {
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.
+8 -6
View File
@@ -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<EnteredWorldMessage>,
/// 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<WorldEnterCascadeMessage>,
mut state: ResMut<Battlefield>,
script: Option<NonSendMut<UiScript>>,
commands: Res<NetCommands>,
) {
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)
+2 -2
View File
@@ -444,8 +444,8 @@ pub(super) fn auto_join_zone_channels(
walk.at = Some(zone_id);
}
/// Feed the VM the zone-less catalog before any interface file runs: world entry runs FrameXML,
/// the addons and `PLAYER_LOGIN` in one call, ahead of the walk's first tick.
/// Feed the VM the zone-less catalog before any interface file runs: world entry runs FrameXML
/// and the addons in one call, ahead of the walk's first tick.
pub(crate) fn seed_zone_channel_catalog(
world: &mut World,
script: &mut benilla_ui::script::UiScript,
+3 -2
View File
@@ -623,8 +623,9 @@ pub(crate) fn fold_dying_vm_chat_cache(world: &mut World) {
pub(super) fn plugin(app: &mut App) {
app.init_resource::<ChatWindowFile>()
// The restore runs from the world-entry UI load, not here: its events must precede the
// first chat line and `PLAYER_LOGIN`, which no `Update` ordering guarantees.
// The restore runs from the world-entry UI load, not here: its events must follow
// `VARIABLES_LOADED` inside the load, where the reference registers its reader
// (`0x4900d6`), and precede the first chat line and `PLAYER_LOGIN`.
.add_systems(
Update,
// After the tick: every write it watches is Lua's.
+41 -38
View File
@@ -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<String> {
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<benilla_ui::script::ScriptValue> = [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<String> = (1..=4)
let n: usize = s.eval("return table.getn(DingLines)").unwrap();
(1..=n)
.map(|i| s.eval::<String>(&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::<benilla_ui::script::UiScript>()
.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 ",
+1
View File
@@ -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,
+158 -56
View File
@@ -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<NonSendMut<UiScript>>,
/// 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<WorldEnterCascadeMessage>,
mut stone: ResMut<MeetingStone>,
commands: Res<NetCommands>,
mut asked: Local<crate::ui_script::VmMemo<bool>>,
) {
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<MeetingStone>) {
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<WorldLeaveSweepMessage>,
mut stone: ResMut<MeetingStone>,
) {
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::<BattlefieldQueue>()
.init_resource::<MeetingStone>()
.add_message::<MeetingStoneUse>()
.add_message::<WorldEnterCascadeMessage>()
.add_message::<WorldLeaveSweepMessage>()
.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<VmMemo<bool>>` 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<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.init_resource::<MeetingStone>()
.insert_resource(NetCommands(tx))
.add_systems(Update, meeting_stone_enter_world);
.init_resource::<crate::ui_script::LeavingWorldArmed>()
.add_message::<crate::net::WorldportMessage>()
.add_message::<WorldEnterCascadeMessage>()
.add_message::<WorldLeaveSweepMessage>()
.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::<crate::ui_script::LeavingWorldArmed>()
.arm();
(app, rx)
}
let queries = |rx: &crossbeam_channel::Receiver<ClientCommand>| {
rx.try_iter()
.filter(|c| matches!(c, ClientCommand::MeetingStoneStatusQuery))
.count()
};
fn stone_queries(rx: &crossbeam_channel::Receiver<ClientCommand>) -> 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::<MeetingStone>();
stone.area = area;
stone.text = StoneText::Built("Meeting Stone: Wailing Caverns".into());
stone.dirty = false;
}
fn stone_text(app: &App) -> StoneText {
app.world().resource::<MeetingStone>().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::<MeetingStone>();
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::<MeetingStone>().area = 1519;
app.world_mut().resource_mut::<MeetingStone>().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::<MeetingStone>();
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::<MeetingStone>();
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::<MeetingStone>().area, 1519);
}
#[test]
+104 -1
View File
@@ -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::<InspectHonor>()
.init_resource::<HonorFeedState>()
.add_systems(Update, feed_honor.in_set(UiFeed));
.init_resource::<crate::nameplates::PvpNameStrings>()
.add_systems(Update, feed_honor.in_set(UiFeed))
// A VM holder, so after the tick; before the driver, so a new VM's strings reach the
// lines the frame it arrives.
.add_systems(
Update,
feed_pvp_name_strings
.after(crate::ui_script::UiInput)
.before(crate::nameplates::drive_nameplates),
);
}
}
/// The overhead name's rank strings ([`crate::nameplates::PvpNameStrings`]), read off a VM the
/// first frame it is in the world. Every VM is installed whole, outside `Update` (the boot VM with
/// `GlobalStrings.lua` run, a world VM after its FrameXML and addon load), so the read sees what
/// the load defined; any other frame pays one compare.
fn feed_pvp_name_strings(
script: Option<NonSend<UiScript>>,
mut strings: ResMut<crate::nameplates::PvpNameStrings>,
) {
let Some(script) = script else {
return;
};
if strings.session() != Some(script.session()) {
strings.set(script.session(), pvp_name_strings(&script));
}
}
/// The [`benilla_ui::script::pvp_name_global_keys`] that `script` defines, with their values.
fn pvp_name_strings(script: &UiScript) -> HashMap<String, String> {
benilla_ui::script::pvp_name_global_keys()
.into_iter()
.filter_map(|key| benilla_ui::strings::global(script.lua(), &key).map(|value| (key, value)))
.collect()
}
/// Read the honor block off our own descriptor, `None` until it streams. That decides only when
/// the first push and its events happen: the reference reads a field it was never sent as 0
/// (`0x51a4b0`-`0x51a7c0`), and so do our bindings.
@@ -264,4 +299,72 @@ mod tests {
fn the_first_push_fires_both() {
assert_eq!(events_for(None, &state()), (true, true));
}
fn vm(src: &str) -> UiScript {
let script = UiScript::new().expect("a VM");
script.run(src).expect("globals set");
script
}
/// The read takes the overhead name's keys a VM defines, `_FEMALE` twins included, and
/// nothing else.
#[test]
fn the_rank_strings_read_takes_only_the_title_keys() {
let strings = pvp_name_strings(&vm(r#"UNIT_PVP_NAME = "%s %s"
PVP_RANK_7_1 = "Sergeant"
PVP_RANK_7_1_FEMALE = "Sergeant (f)"
GARBAGE_KEY = "x""#));
assert_eq!(
strings.get("UNIT_PVP_NAME").map(String::as_str),
Some("%s %s")
);
assert_eq!(
strings.get("PVP_RANK_7_1_FEMALE").map(String::as_str),
Some("Sergeant (f)")
);
assert!(!strings.contains_key("GARBAGE_KEY"), "only the title keys");
}
/// Each VM is read once, the first frame it is in the world, so a world VM whose addon renames
/// a title reaches the overhead line.
#[test]
fn each_new_vm_is_read_once() {
use bevy::ecs::system::RunSystemOnce;
let mut world = World::new();
world.init_resource::<crate::nameplates::PvpNameStrings>();
world.run_system_once(feed_pvp_name_strings).unwrap();
assert_eq!(
world
.resource::<crate::nameplates::PvpNameStrings>()
.session(),
None,
"no VM, no read"
);
let stock = r#"UNIT_PVP_NAME = "%s %s"
PVP_RANK_7_1 = "Sergeant""#;
let boot = vm(stock);
let boot_session = boot.session();
world.insert_non_send_resource(boot);
world.run_system_once(feed_pvp_name_strings).unwrap();
let held = world.resource::<crate::nameplates::PvpNameStrings>();
assert_eq!(held.session(), Some(boot_session));
assert_eq!(held.get("PVP_RANK_7_1"), Some("Sergeant"));
// The same VM is not read again: a global it sets later stays out.
world
.non_send_resource::<UiScript>()
.run(r#"PVP_RANK_7_1 = "Later""#)
.expect("global set");
world.run_system_once(feed_pvp_name_strings).unwrap();
let held = world.resource::<crate::nameplates::PvpNameStrings>();
assert_eq!(held.get("PVP_RANK_7_1"), Some("Sergeant"));
let world_vm = vm(&format!("{stock}\nPVP_RANK_7_1 = \"Feldwebel\""));
let world_session = world_vm.session();
world.insert_non_send_resource(world_vm);
world.run_system_once(feed_pvp_name_strings).unwrap();
let held = world.resource::<crate::nameplates::PvpNameStrings>();
assert_eq!(held.session(), Some(world_session));
assert_eq!(held.get("PVP_RANK_7_1"), Some("Feldwebel"));
}
}
+80 -31
View File
@@ -60,6 +60,8 @@ fn spell_desc_text(
// The player's skill in a spell's line, the `$`-tokens' level (`TokenContext::skill`).
skill: &dyn Fn(u32) -> u32,
home_area: Option<&str>,
// The active player's `UNIT_FIELD_BYTES_0` byte 2, the `$g`/`$G` branch's input (`0x508214`).
gender: &dyn Fn() -> u8,
mods: Option<&crate::spell::SpellModifiers>,
// The VM's strings for the keyed `$d`/`$s` tokens.
global: &dyn Fn(&str) -> Option<String>,
@@ -77,6 +79,7 @@ fn spell_desc_text(
lookup: &|i| sp.catalog.get(i),
mods: mods.map(|m| m as &dyn benilla_formats::SpellMods),
unmodified_points: false,
gender,
home_area: &|| home_area,
global,
printf: &crate::ui_script::token_printf,
@@ -87,6 +90,15 @@ fn spell_desc_text(
}
}
/// What the `$`-tokens read of the player beside the modifiers: the skills the per-level terms
/// scale by, and the gender byte `$g`/`$G` branch on (`0x508214`).
type TokenReads = (crate::spell::SkillSnapshot, u8);
fn token_reads(me: Option<&ObjectStore>) -> TokenReads {
let gender = me.and_then(|s| s.0.unit_gender()).unwrap_or(0);
(crate::spell::skill_snapshot(me), gender)
}
/// Only descriptions with substitution tokens can change when the caster's spell mods change.
fn spell_has_mod_tokens(spells: Option<&crate::ui_action::Spells>, id: u32) -> bool {
spells
@@ -137,6 +149,8 @@ fn template_view(
skill: &dyn Fn(u32) -> u32,
skill_lines: Option<&benilla_formats::SkillLineCatalog>,
home_area: Option<&str>,
// The active player's gender byte, the trigger text's `$g`/`$G` branch (`0x508214`).
gender: &dyn Fn() -> u8,
factions: Option<&benilla_formats::FactionCatalog>,
sub_classes: Option<&benilla_formats::ItemSubClassCatalog>,
classes: Option<&benilla_formats::ItemClassCatalog>,
@@ -150,7 +164,7 @@ fn template_view(
.and_then(|s| s.catalog.get(id))
.map(|sd| sd.name.clone())
};
let spell_text = |id: u32| spell_desc_text(spells, id, skill, home_area, mods, get);
let spell_text = |id: u32| spell_desc_text(spells, id, skill, home_area, gender, mods, get);
benilla_ui::script::ItemTemplateView {
name: t.name.clone(),
quality: t.quality,
@@ -254,7 +268,7 @@ pub(super) fn feed_item_sets(
crate::ui_script::VmMemo<std::collections::HashMap<u32, benilla_ui::script::ItemSetView>>,
>,
mut mod_sensitive: Local<crate::ui_script::VmMemo<HashSet<u32>>>,
mut last_skills: Local<crate::ui_script::VmMemo<Option<crate::spell::SkillSnapshot>>>,
mut last_reads: Local<crate::ui_script::VmMemo<Option<TokenReads>>>,
) {
let Some(mut script) = script else {
return;
@@ -265,11 +279,12 @@ pub(super) fn feed_item_sets(
for id in script.take_item_set_asks() {
pending.entry(id).or_default();
}
// The `$`-tokens' per-level terms follow the player's skills.
let skills = Some(crate::spell::skill_snapshot(me));
let skills_changed = *last_skills.get(&script) != skills;
*last_skills.get(&script) = skills;
if spell_mods.is_changed() || skills_changed {
// The `$`-tokens follow the player's skills and gender.
let reads = token_reads(me);
let gender = reads.1;
let reads_changed = last_reads.get(&script).as_ref() != Some(&reads);
*last_reads.get(&script) = Some(reads);
if spell_mods.is_changed() || reads_changed {
for &id in mod_sensitive.iter() {
pending.entry(id).or_default();
}
@@ -308,8 +323,16 @@ pub(super) fn feed_item_sets(
.bonuses
.iter()
.filter_map(|&(n, spell)| {
spell_desc_text(spell_res, spell, &skill, None, Some(&spell_mods), &global)
.map(|desc| (n, desc))
spell_desc_text(
spell_res,
spell,
&skill,
None,
&|| gender,
Some(&spell_mods),
&global,
)
.map(|desc| (n, desc))
})
.collect(),
required_skill: row.required_skill,
@@ -392,7 +415,7 @@ pub(super) fn feed_item_stats(
mut pending: Local<crate::ui_script::VmMemo<std::collections::HashSet<u32>>>,
mut mod_sensitive: Local<crate::ui_script::VmMemo<HashSet<u32>>>,
mut last_home: Local<crate::ui_script::VmMemo<Option<String>>>,
mut last_skills: Local<crate::ui_script::VmMemo<Option<crate::spell::SkillSnapshot>>>,
mut last_reads: Local<crate::ui_script::VmMemo<Option<TokenReads>>>,
) {
let Some(mut script) = script else {
return;
@@ -401,10 +424,11 @@ pub(super) fn feed_item_stats(
let mod_sensitive = mod_sensitive.get(&script);
let last_home = last_home.get(&script);
let me = self_q.single().ok();
// The `$`-tokens' per-level terms follow the player's skills.
let skills = Some(crate::spell::skill_snapshot(me));
let skills_changed = *last_skills.get(&script) != skills;
*last_skills.get(&script) = skills;
// The `$`-tokens follow the player's skills and gender.
let reads = token_reads(me);
let gender = reads.1;
let reads_changed = last_reads.get(&script).as_ref() != Some(&reads);
*last_reads.get(&script) = Some(reads);
// `GetBindLocation()`'s push, here so it and the `$z` token share one name, and ahead of the
// pending gate below: the bind point can arrive while the feed idles.
let home_area: Option<&str> = home_bind
@@ -421,7 +445,7 @@ pub(super) fn feed_item_stats(
pending.extend(items.take_fresh());
pending.extend(script.take_item_stat_asks());
if spell_mods.is_changed() || skills_changed {
if spell_mods.is_changed() || reads_changed {
pending.extend(mod_sensitive.iter().copied());
}
if pending.is_empty() {
@@ -464,6 +488,7 @@ pub(super) fn feed_item_stats(
&skill,
skill_catalog,
home_area,
&|| gender,
factions.as_deref().map(|f| f.catalog()),
sub_classes.as_deref().map(|s| &s.0),
classes.as_deref().map(|c| &c.0),
@@ -1692,6 +1717,7 @@ mod tests {
this return"
);
}
use crate::ui_action::real_spells;
use benilla_protocol::messages::ItemSpellEntry;
use benilla_ui::script::{ContainerState, UiScript};
use std::collections::HashMap;
@@ -1911,19 +1937,7 @@ mod tests {
/// line (`0x52da29`-`0x52da31`), and a described one does.
#[test]
fn an_undescribed_spell_prints_no_trigger_line_on_real_data() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = crate::ui_action::Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
forms: benilla_formats::load_shapeshift_forms(&mut chain)
.expect("SpellShapeshiftForm.dbc"),
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("SpellRange.dbc"),
cast_times: benilla_formats::load_spell_cast_times(&mut chain)
.expect("SpellCastTimes.dbc"),
durations: benilla_formats::load_spell_durations(&mut chain)
.expect("SpellDuration.dbc"),
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
};
let Some(spells) = real_spells() else { return };
// No string table: Fireball's description reaches no keyed token.
let no_strings = |_: &str| None;
@@ -1936,7 +1950,7 @@ mod tests {
"spell {id} has a name — which is exactly what must NOT leak into the tooltip"
);
assert_eq!(
super::spell_desc_text(Some(&spells), id, &|_| 0, None, None, &no_strings),
super::spell_desc_text(Some(&spells), id, &|_| 0, None, &|| 0, None, &no_strings),
None,
"spell {id} ({:?}) has no description, so the reference prints no trigger line",
d.name
@@ -1944,11 +1958,46 @@ mod tests {
}
// The control: Fireball (133), described, still yields its line.
let fireball = super::spell_desc_text(Some(&spells), 133, &|_| 0, None, None, &no_strings)
.expect("a described spell still yields its line");
let fireball =
super::spell_desc_text(Some(&spells), 133, &|_| 0, None, &|| 0, None, &no_strings)
.expect("a described spell still yields its line");
assert!(
fireball.contains("damage"),
"expected the substituted Fireball description, got {fireball:?}"
);
}
/// The trigger text's `$g` branch reads the gender handed in (`0x508214`): Conjure Food 587's
/// "providing the mage and `$ghis:her;` allies" takes "her" on 1, "his" on 0.
#[test]
fn a_trigger_texts_gender_branch_follows_the_player() {
let Some(spells) = real_spells() else { return };
let no_strings = |_: &str| None;
let line = |gender: u8| {
super::spell_desc_text(
Some(&spells),
587,
&|_| 0,
None,
&move || gender,
None,
&no_strings,
)
.expect("a described spell")
};
assert!(line(1).contains("the mage and her allies"), "{}", line(1));
assert!(line(0).contains("the mage and his allies"), "{}", line(0));
}
/// The item feeds re-substitute a held `$`-text when what the tokens read moves: the gender
/// byte `$g`/`$G` branch on (`0x508214`), and no other byte of `UNIT_FIELD_BYTES_0`.
#[test]
fn the_token_reads_move_with_the_gender_byte_alone() {
let store = |bytes: u32| {
crate::net::ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(36, bytes)]))
};
let male = super::token_reads(Some(&store(4 | 1 << 8)));
assert_ne!(super::token_reads(Some(&store(4 | 1 << 8 | 1 << 16))), male);
assert_eq!(super::token_reads(Some(&store(1 | 2 << 8 | 1 << 24))), male);
}
}
+39 -1
View File
@@ -363,7 +363,8 @@ impl LootLatch {
/// A movement start that closes the loot: the reference's guard `0x60e990`, called by every
/// movement-start emitter, turning included, but not by mouse-look facing. No loot target reaches
/// the range gate `0x493230`, so there is no distance leash. vmangos also releases on movement
/// (`MovementHandler.cpp:1104`), but the close is the client's.
/// (`MovementHandler.cpp:1104`), but the close is the client's. A loot response that lands while
/// we move sets it too (`0x5ebc3a`).
#[derive(Resource, Default)]
pub(crate) struct LootMoveStart(pub(crate) bool);
@@ -674,6 +675,7 @@ fn feed_loot(
group: Res<GroupState>,
names: Res<NameCache>,
objects: LootSourceObjects,
mut moving: OpenWhileMoving,
) {
let Some(mut script) = script else {
return;
@@ -755,6 +757,19 @@ fn feed_loot(
}
}
}
// The response's tail (`0x5ebc3a`-`0x5ebc51`) closes a window that opened while we
// move, after the copier's open and sweep and in the same handler: `LOOT_CLOSED` fires
// in this pass, so the window never paints.
if std::mem::take(&mut moving.move_start.0) {
let closed = close_on_move_start(&mut loot, &mut moving.latch, &commands);
if closed.is_some() {
moving.dead.after_close(closed);
script.set_loot(None);
script.fire_event("LOOT_CLOSED", vec![]);
*last = None;
return;
}
}
}
// One `LOOT_SLOT_CLEARED` per row that went, with its 1-based row: the stock handler hides
// that button and pages down when the page empties (`LootFrame.lua:22-50`).
@@ -862,6 +877,14 @@ fn close_on_move_start(
close_interaction(loot, latch, Some(commands))
}
/// What [`feed_loot`] needs to take the move-start close on a window it has just opened.
#[derive(bevy::ecs::system::SystemParam)]
struct OpenWhileMoving<'w, 's> {
move_start: ResMut<'w, LootMoveStart>,
latch: ResMut<'w, LootLatch>,
dead: DeadUnitDeselect<'w, 's>,
}
/// Sends what the Lua asked for, as the take dispatcher `0x4c2790(slot, flag)`: a row click
/// (a `LootButton`'s own, flag 0) and the `LOOT_BIND` confirm (`LootSlot`, flag 1) stay apart,
/// so a second click on a bind-on-pickup row asks again.
@@ -1170,6 +1193,11 @@ mod tests {
let (tx, _rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_message::<crate::sound::LootPickupSound>()
// What the feed's move-start close reads (`OpenWhileMoving`).
.add_message::<crate::target::DeselectGuid>()
.init_resource::<LootMoveStart>()
.init_resource::<LootLatch>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<LootState>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<GroupState>()
@@ -1227,6 +1255,11 @@ mod tests {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_message::<crate::sound::LootPickupSound>()
// What the feed's move-start close reads (`OpenWhileMoving`).
.add_message::<crate::target::DeselectGuid>()
.init_resource::<LootMoveStart>()
.init_resource::<LootLatch>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<LootState>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<GroupState>()
@@ -1460,6 +1493,11 @@ mod tests {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_message::<crate::sound::LootPickupSound>()
// What the feed's move-start close reads (`OpenWhileMoving`).
.add_message::<crate::target::DeselectGuid>()
.init_resource::<LootMoveStart>()
.init_resource::<LootLatch>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<LootState>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<GroupState>()
+287 -36
View File
@@ -5,7 +5,7 @@ use benilla_protocol::messages::ItemPushResult;
use benilla_protocol::{SessionEvent, SessionEventKind};
use bevy::prelude::*;
use super::{LootLatch, LootState};
use super::{LootLatch, LootMoveStart, LootState};
use crate::net::{ClientCommand, NetCommands, NetHandlerApp, SelfGuid};
use crate::pending_item_ops::{LockTransitions, PendingItemOps};
use crate::ui_action::{UiError, UiErrorKeys};
@@ -26,8 +26,15 @@ pub(super) fn register(app: &mut App) {
}
/// An admitted response selects a looted unit (`0x5ebc35` → `0x48f3a0`) before the window opens
/// (`0x4c1cb0`), so the outgoing target's teardown closes a window still open on it.
fn on_response(In(ev): In<SessionEvent>, mut select: crate::target::SelectCommit) {
/// (`0x4c1cb0`), so the outgoing target's teardown closes a window still open on it. One that lands
/// while our own body moves takes the move-start close (`0x5ebc3a`-`0x5ebc51`), which the feed runs
/// after it fires `LOOT_OPENED`, or the drain when the window waits on an item template.
fn on_response(
In(ev): In<SessionEvent>,
mut select: crate::target::SelectCommit,
player: Option<Res<crate::player::Player>>,
mut move_start: ResMut<LootMoveStart>,
) {
let SessionEvent::LootResponse {
guid,
loot_type,
@@ -47,6 +54,12 @@ fn on_response(In(ev): In<SessionEvent>, mut select: crate::target::SelectCommit
);
select.select_unit(guid);
select.seam.loot.open(guid, loot_type, gold, items);
if player.is_some_and(|p| {
p.foreign_mover.is_none()
&& p.move_flags() & crate::creature_anim::move_flags::STEERING != 0
}) {
move_start.0 = true;
}
}
fn on_error(
@@ -763,6 +776,8 @@ mod tests {
world.init_resource::<LockTransitions>();
world.init_resource::<bevy::ecs::message::Messages<crate::target::DeselectGuid>>();
world.init_resource::<bevy::ecs::message::Messages<crate::player::StandStateRequest>>();
world.init_resource::<crate::player::Player>();
world.init_resource::<super::super::LootMoveStart>();
world.spawn((
SelfPlayer,
Guid(1),
@@ -988,34 +1003,33 @@ mod tests {
);
}
/// The issue's symptom on the stock frames: the Tab target is on `TargetFrame` when the body's
/// window opens, and the unit feed then names the body there (`TargetFrame.lua:63`,
/// `UnitFrame.lua:25`).
#[test]
fn the_stock_target_frame_names_the_looted_body() {
/// The stock unit frames over `TargetFrame`, for [`stock_app`].
const TARGET_UI: &[&str] = &[
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\UIParent.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\MoneyFrame.lua",
"Interface\\FrameXML\\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\CombatFeedback.xml",
"Interface\\FrameXML\\UnitPopup.xml",
"Interface\\FrameXML\\UnitFrame.xml",
"Interface\\FrameXML\\PlayerFrame.xml",
"Interface\\FrameXML\\PartyFrame.xml",
"Interface\\FrameXML\\TargetFrame.xml",
];
/// [`tabbed_world`] under the stock `ui` files, with the unit feed and both creatures named.
fn stock_app(ui: &[&[&str]]) -> App {
use benilla_ui::script::UiScript;
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().expect("VM");
s.set_screen_size(1024.0, 768.0);
for f in crate::ui_script::test_ui::production_order(&[&[
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\UIParent.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\MoneyFrame.lua",
"Interface\\FrameXML\\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\CombatFeedback.xml",
"Interface\\FrameXML\\UnitPopup.xml",
"Interface\\FrameXML\\UnitFrame.xml",
"Interface\\FrameXML\\PlayerFrame.xml",
"Interface\\FrameXML\\PartyFrame.xml",
"Interface\\FrameXML\\TargetFrame.xml",
]]) {
for f in crate::ui_script::test_ui::production_order(ui) {
crate::ui_script::test_ui::load_ui(&s, f);
}
@@ -1048,20 +1062,257 @@ mod tests {
.init_resource::<crate::ui_guild::GuildState>()
.insert_non_send_resource(s);
crate::ui_unit::add_unit_feed(&mut app);
let shown = |app: &mut App| -> String {
let s = app.world().non_send_resource::<UiScript>();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.eval("return TargetFrame:IsShown() and TargetFrame.name:GetText() or ''")
.expect("the target frame's name")
};
app
}
/// The name on the stock `TargetFrame`, empty while it is hidden.
fn target_frame_name(app: &App) -> String {
let s = app
.world()
.non_send_resource::<benilla_ui::script::UiScript>();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.eval("return TargetFrame:IsShown() and TargetFrame.name:GetText() or ''")
.expect("the target frame's name")
}
/// The issue's symptom on the stock frames: the Tab target is on `TargetFrame` when the body's
/// window opens, and the unit feed then names the body there (`TargetFrame.lua:63`,
/// `UnitFrame.lua:25`).
#[test]
fn the_stock_target_frame_names_the_looted_body() {
benilla_formats::wow_data_or_skip!();
let mut app = stock_app(&[TARGET_UI]);
app.update();
assert_eq!(shown(&mut app), "Kobold Vermin", "the Tab target");
assert_eq!(target_frame_name(&app), "Kobold Vermin", "the Tab target");
app.world_mut().resource_mut::<LootLatch>().0 = Some(CORPSE);
respond(app.world_mut(), CORPSE, 1);
app.update();
assert_eq!(shown(&mut app), "Kobold Worker", "the looted body");
assert_eq!(target_frame_name(&app), "Kobold Worker", "the looted body");
}
/// What [`super::super::drain_loot`] reads beside [`seat_tabbed`]'s, with our body's movement
/// word at `player`'s.
fn seat_drain(world: &mut World, player: crate::player::Player) {
world.insert_resource(player);
world.init_resource::<crate::ui_party::GroupState>();
world.init_resource::<crate::items::Items>();
world.init_resource::<bevy::ecs::message::Messages<crate::sound::LootPickupSound>>();
}
/// The deselects asked for since the last call.
fn deselects(world: &mut World) -> Vec<u64> {
world
.resource_mut::<bevy::ecs::message::Messages<crate::target::DeselectGuid>>()
.drain()
.map(|d| d.0)
.collect()
}
/// A response on the corpse with `player` seated, then one drain.
fn open_while(
player: crate::player::Player,
) -> (World, crossbeam_channel::Receiver<ClientCommand>) {
let (mut world, rx) = tabbed_world();
seat_drain(&mut world, player);
world.resource_mut::<LootLatch>().0 = Some(CORPSE);
respond(&mut world, CORPSE, 1);
world
.run_system_once(super::super::drain_loot)
.expect("the drain runs as a one-shot system");
(world, rx)
}
/// The response's tail (`0x5ebc3a`-`0x5ebc51`): a bit of our movement word's low byte, forward,
/// back, strafe, turn or pitch, takes the move-start close on the window it just opened, the
/// release after the selection and the dead body's deselect last (`0x48f369`).
#[test]
fn a_window_that_opens_while_moving_closes_as_a_movement_start_does() {
for bit in (0..8).map(|b| 1u32 << b) {
let (mut world, rx) = open_while(crate::player::Player::with_move_flags(bit));
assert_eq!(world.resource::<LootState>().source(), None, "{bit:#x}");
assert_eq!(world.resource::<LootLatch>().0, None, "{bit:#x}");
let sent: Vec<_> = rx.try_iter().collect();
assert!(
matches!(
sent[..],
[ClientCommand::SetSelection { guid: a }, ClientCommand::LootRelease { guid: b }]
if a == CORPSE && b == CORPSE
),
"{bit:#x}: {sent:?}"
);
assert_eq!(deselects(&mut world), [CORPSE], "{bit:#x}");
}
}
/// Only that byte, and only our body's: walking, falling or swimming alone leaves the window
/// open, as does a step of the creature we possess.
#[test]
fn a_window_opened_falling_swimming_or_possessing_stays_open() {
use crate::creature_anim::move_flags as f;
use crate::player::Player;
let mut possessing = Player::with_move_flags(f::FORWARD);
possessing.foreign_mover = Some(KOBOLD);
for (what, player) in [
("still", Player::with_move_flags(0)),
("walk mode", Player::with_move_flags(f::WALK_MODE)),
("falling", Player::with_move_flags(f::FALLING)),
("swimming", Player::with_move_flags(f::SWIMMING)),
("possessing", possessing),
] {
let (mut world, rx) = open_while(player);
assert_eq!(
world.resource::<LootState>().source(),
Some(CORPSE),
"{what}"
);
let sent: Vec<_> = rx.try_iter().collect();
assert!(
matches!(sent[..], [ClientCommand::SetSelection { guid }] if guid == CORPSE),
"{what}: {sent:?}"
);
assert!(deselects(&mut world).is_empty(), "{what}");
}
}
/// The copier `0x4c1cb0` sweeps an auto-loot window (`0x4c1fa0`) before the response's
/// movement close (`0x5ebc51`), so a body auto-looted on the run is taken, then released.
#[test]
fn auto_loot_on_the_run_takes_the_rows_before_the_release() {
const TOUGH_JERKY: u32 = 117;
let mut app = App::new();
let rx = seat_tabbed(app.world_mut());
seat_drain(
app.world_mut(),
crate::player::Player::with_move_flags(crate::creature_anim::move_flags::FORWARD),
);
app.world_mut()
.resource_mut::<crate::items::Items>()
.insert_template(
TOUGH_JERKY,
Some(crate::items::test_template("Tough Jerky")),
);
app.insert_resource(super::super::LootConfig {
auto_loot: true,
..Default::default()
})
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<crate::names::NameCache>()
.init_resource::<ButtonInput<KeyCode>>()
.insert_non_send_resource(benilla_ui::script::UiScript::new().expect("VM"))
.add_systems(
Update,
(super::super::feed_loot, super::super::drain_loot).chain(),
);
app.world_mut().resource_mut::<LootLatch>().0 = Some(CORPSE);
app.world_mut()
.run_system_once_with(
on_response,
SessionEvent::LootResponse {
guid: CORPSE,
loot_type: 1,
gold: 0,
items: vec![benilla_protocol::messages::LootItem {
slot: 0,
item_id: TOUGH_JERKY,
count: 1,
display_info_id: 1117,
random_property_id: 0,
slot_type: 0,
}],
},
)
.expect("the handler runs as a one-shot system");
app.update();
let sent: Vec<_> = rx.try_iter().collect();
assert!(
matches!(
sent[..],
[
ClientCommand::SetSelection { .. },
ClientCommand::AutostoreLootItem { slot: 0 },
ClientCommand::LootRelease { guid },
] if guid == CORPSE
),
"{sent:?}"
);
}
/// The issue's spot on the stock frames: a body's window that opens on the run is shown by
/// `LOOT_OPENED` and hidden by `LOOT_CLOSED` in the same frame (`LootFrame.lua:13-16`,
/// `:53-55`), its one release
/// sent, and `TargetFrame` lets the body go; standing, the window and the target stay.
#[test]
fn the_stock_loot_frame_closes_on_a_body_looted_on_the_run() {
use crate::creature_anim::move_flags as f;
use benilla_ui::script::UiScript;
benilla_formats::wow_data_or_skip!();
for (flags, stays) in [(f::FORWARD, false), (0, true)] {
let mut app = stock_app(&[
crate::ui_script::test_ui::LOOT_UI,
TARGET_UI,
&["Interface\\FrameXML\\LootFrame.xml"],
]);
let (tx, rx) = crossbeam_channel::unbounded();
app.insert_resource(crate::net::NetCommands(tx));
seat_drain(
app.world_mut(),
crate::player::Player::with_move_flags(flags),
);
app.init_resource::<super::super::LootConfig>()
.init_resource::<ButtonInput<KeyCode>>()
.add_systems(
Update,
(
super::super::feed_loot,
super::super::drain_loot,
crate::target::click::clear_target_requests,
)
.chain(),
);
app.world()
.non_send_resource::<UiScript>()
.run(
"LOOT_EVENTS = {} local f = CreateFrame('Frame') \
f:RegisterEvent('LOOT_OPENED') f:RegisterEvent('LOOT_CLOSED') \
f:SetScript('OnEvent', function() tinsert(LOOT_EVENTS, event) end)",
)
.unwrap();
app.update();
app.world_mut().resource_mut::<LootLatch>().0 = Some(CORPSE);
respond(app.world_mut(), CORPSE, 1);
// One frame: the reference opens and closes in the response's handler.
app.update();
let (events, shown): (String, bool) = app
.world()
.non_send_resource::<UiScript>()
.eval("return table.concat(LOOT_EVENTS, ','), LootFrame:IsShown() == 1")
.unwrap();
for _ in 0..2 {
app.update();
}
let releases = rx
.try_iter()
.filter(|c| matches!(c, ClientCommand::LootRelease { guid } if *guid == CORPSE))
.count();
if stays {
assert_eq!(events, "LOOT_OPENED");
assert!(shown, "standing, the window stays");
assert_eq!(releases, 0);
assert_eq!(target_frame_name(&app), "Kobold Worker");
} else {
assert_eq!(events, "LOOT_OPENED,LOOT_CLOSED");
assert!(
!shown,
"on the run, the window closes in the frame it opens"
);
assert_eq!(releases, 1, "released once");
assert_eq!(target_frame_name(&app), "", "and the body is let go");
}
}
}
/// Only a releasing arm reaches `UnlockItem 0x495420`, through the tail `0x5ebac2`.
+9 -8
View File
@@ -19,7 +19,7 @@ use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{
ClientCommand, EnteredWorldMessage, NetCommands, ObjectStore, Objects, SelfPlayer,
ClientCommand, NetCommands, ObjectStore, Objects, SelfPlayer, WorldEnterCascadeMessage,
};
use crate::query_cache::QueryCache;
use crate::ui_script::{UiFeed, UiInput};
@@ -175,21 +175,22 @@ impl Plugin for UiMailPlugin {
close_npc_session_out_of_range::<MailOpen>.before(feed_mail),
feed_mail.after(crate::ui_unit::UnitFeed).in_set(UiFeed),
drain_mail.after(UiInput),
send_query_next_mail_time_on_enter,
// The cascade's order: the time query, this, then the battlefield status.
send_query_next_mail_time_on_enter.after(crate::net::send_query_time),
),
);
}
}
/// `MSG_QUERY_NEXT_MAIL_TIME` at every world enter, from the mail module's init in the world-enter
/// cascade (`0x4908c0`). The init stamps "no mail" first, so the icon goes dark across a loading
/// screen until the reply.
fn send_query_next_mail_time_on_enter(
mut entered: MessageReader<EnteredWorldMessage>,
/// `MSG_QUERY_NEXT_MAIL_TIME` at each world-enter cascade, from the mail init (`0x4acb10`, called
/// at `0x4909f6`), after the time query and before the battlefield status. The init stamps "no
/// mail" first (`0x4acb87`).
pub(crate) fn send_query_next_mail_time_on_enter(
mut cascades: MessageReader<WorldEnterCascadeMessage>,
commands: Res<NetCommands>,
mut pending: ResMut<MailPending>,
) {
if entered.read().next().is_some() {
if cascades.read().next().is_some() {
pending.on_query_sent();
let _ = commands.0.send(ClientCommand::QueryNextMailTime);
}
+10 -3
View File
@@ -1692,9 +1692,10 @@ mod tests {
);
}
/// `UI_Init 0x48fbf0` loads the addons at `0x4900a3` (each `ADDON_LOADED` at `0x51f5ad`) and
/// fires `VARIABLES_LOADED` at `0x4900b2`; `PLAYER_LOGIN` comes after, on a fresh login from
/// the player's create (`0x5deb60` → `0x4908c0`), on a `/reload` from `0x490168`.
/// `UI_Init 0x48fbf0` loads the addons at `0x4900a3` (each `ADDON_LOADED` at `0x51f5ad`),
/// fires `VARIABLES_LOADED` at `0x4900b2` and arms `PLAYER_LOGIN` (`0x49011d`), which the
/// world-enter cascade fires: on a fresh login from the player's create (`0x5deb60` →
/// `0x4908c0`), on a `/reload` from `0x490168`.
#[test]
fn the_ui_init_events_fire_in_the_reference_order() {
let _l = crate::local_state::test_env::ENV_LOCK
@@ -1728,6 +1729,12 @@ mod tests {
let failures = load_third_party(&mut script, None, &[], true);
assert!(failures.is_empty(), "load errors: {failures:?}");
crate::ui_script::finish_ui_load(&mut script);
assert_eq!(
event_log(&script),
vec!["ADDON_LOADED:EventProbe", "VARIABLES_LOADED:nil"],
"the load only arms PLAYER_LOGIN"
);
script.fire_world_enter();
assert_eq!(
event_log(&script),
@@ -4,7 +4,7 @@
use benilla_ui::script::{
BattlefieldFlagView, BattlefieldPositionView, QuadContent, UiScript, UnitState,
WorldMapContinentView, WorldMapLandmarkView, WorldMapOverlayView, WorldMapZoneView,
WorldMapContinentView, WorldMapLandmarkSource, WorldMapOverlayView, WorldMapZoneView,
WorldStateUiView, ARROW_MODEL,
};
@@ -411,14 +411,17 @@ fn the_poi_pool_grows_from_the_landmarks_and_parks_its_tail() {
open(&mut s);
assert_eq!(s.eval::<i64>("return NUM_BATTLEFIELDMAP_POIS").unwrap(), 0);
let landmark = |name: &str, icon: u32, uv: (f32, f32)| WorldMapLandmarkView {
// A world position here is already map UV, so a landmark source sits where it says.
s.set_world_loc_projector(Box::new(|_, _, x, y| Some((x, y))));
let landmark = |name: &str, icon: u32, uv: (f32, f32)| WorldMapLandmarkSource {
name: name.into(),
description: String::new(),
texture_index: icon,
uv,
map: 0,
pos: uv,
icons: [Some(icon); 3],
};
// Icon 6 is `ICON_POI_REDFLAG`, icon 9 the second row's first cell of the 8×8 `POIIcons` atlas.
s.set_world_map_landmarks(vec![
s.set_world_map_landmark_sources(vec![
landmark("Silverwing Flag", 6, (0.25, 0.5)),
landmark("Warsong Flag", 9, (0.75, 0.25)),
]);
@@ -466,7 +469,7 @@ fn the_poi_pool_grows_from_the_landmarks_and_parks_its_tail() {
"DEFAULT_POI_ICON_SIZE × GetBattlefieldMapIconScale()"
);
s.set_world_map_landmarks(vec![landmark("Warsong Flag", 9, (0.75, 0.25))]);
s.set_world_map_landmark_sources(vec![landmark("Warsong Flag", 9, (0.75, 0.25))]);
s.tick(0.0);
assert_eq!(
s.eval::<i64>("return NUM_BATTLEFIELDMAP_POIS").unwrap(),
@@ -6,7 +6,7 @@ use benilla_ui::script::{ExtractedQuad, QuadContent, UiScript};
use super::test_ui::load_ui as load_xml;
/// The chat stack, fonts first so `inherits="ChatFontNormal"` resolves.
fn chat_frame() -> UiScript {
pub(crate) fn chat_frame() -> UiScript {
let mut s = UiScript::new().unwrap();
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
+23 -11
View File
@@ -425,7 +425,11 @@ pub(super) fn tick_script(
mut last_seam: Local<f32>,
// The `scale_factor` measures were last answered under; 0 until the first frame.
mut last_dpi: Local<f32>,
ui_scale: Res<super::UiScaleCvar>,
// The dial, `UIParent`'s scale in the VM, and the automatic scale's last display.
(ui_scale, mut auto_cache): (
Res<super::UiScaleCvar>,
Option<ResMut<super::UiAutoScaleCache>>,
),
mut ui_cost: ResMut<super::UiFrameCost>,
mut pass: ResMut<super::UiPassState>,
) {
@@ -442,9 +446,9 @@ pub(super) fn tick_script(
};
let (w, h) = (window.width(), window.height());
// The client's UI space is 768 units tall at every aspect (`f(screenH) = 768`, `0x41ad10`; the
// caret's `H_px/192`, `0x77b8c0`), `768/uiScale` with the dial. The VM lives in it: quads
// scale ×s out, and the mouse and the measures ÷s in.
let s = super::seam_scale(h, ui_scale.0);
// caret's `H_px/192`, `0x77b8c0`), the dial being `UIParent`'s scale inside it. The VM lives in
// it: quads scale ×s out, and the mouse and the measures ÷s in.
let s = super::seam_scale(h);
// A moved seam scale or DPI stales every text metric the engine caches: integer-stepped
// advances do not rescale, and the size snap moves a string's width by several percent,
// enough for the ellipsis to eat fitting text. A monitor hop moves the DPI alone.
@@ -486,8 +490,17 @@ pub(super) fn tick_script(
{
let _span = bevy::log::info_span!("ui_script: tick").entered();
let resized = script.set_screen_size(w / s, if h > 0.0 { h / s } else { 768.0 });
// A resize re-runs what the interface computed from the old screen size.
// A display change re-applies the UI scale (`0x492e90`), whose narrow-aspect cap follows
// the window; a moved dial applies once, as its CVar callback does (`0x490770`).
if resized {
let display = (w.round() as u32, h.round() as u32);
let mut none = None;
let cache = auto_cache.as_deref_mut().map_or(&mut none, |c| &mut c.0);
script.apply_ui_scale(display, cache);
}
let rescaled = script.set_ui_scale(ui_scale.0);
// Either re-runs what the interface computed from the old `GetScreenHeight()`.
if resized || rescaled {
super::manifest::on_screen_resized(&script);
}
script.tick(time.delta_secs());
@@ -1713,9 +1726,10 @@ mod clip_plumb_tests {
);
}
/// At dial 0.5 the VM sees a 2048×1536 screen, and every window-px rect halves.
/// The dial is `UIParent`'s scale, so a ScrollFrame with no parent keeps its px rect and clip
/// at dial 0.5: the root stays 768 units tall.
#[test]
fn the_uiscale_dial_scales_the_extracted_rects() {
fn the_uiscale_dial_leaves_a_parentless_frame_alone() {
let mut app = app_with_scrolled_marker();
app.insert_resource(crate::ui_script::UiScaleCvar(0.5));
app.update();
@@ -1726,12 +1740,10 @@ mod clip_plumb_tests {
.find(|q| q.color[0] == 1.0 && q.color[1] == 0.0 && q.color[2] == 0.0)
.expect("the colored marker quad extracted");
// WoW space: top 1536 − 100 = 1436, bottom 1236, right 300; ×0.5 is y-up px 618..718,
// and y-down through the 768 window the top is 50 and the bottom 150.
assert_eq!(
marker.clip,
Some(Rect::new(0.0, 50.0, 150.0, 150.0)),
"dial 0.5 halves the px rect and re-hangs the frame from the taller virtual top"
Some(Rect::new(0.0, 100.0, 300.0, 300.0)),
"dial 0.5 moves nothing outside UIParent"
);
}
@@ -40,10 +40,11 @@ pub(super) struct TextHost<'a> {
/// The focused editbox's text UI, unfiltered: [`emit`] matches it to this quad by `target`.
pub ebox: Option<&'a EditBoxTextUi>,
pub screen_h: f32,
/// The seam scale `windowH/768 × uiScale`, for the unit-space inputs; rects arrive in px.
/// The seam scale `windowH/768`, for the root-unit inputs; rects arrive in px.
pub scale: f32,
/// The frame's effective scale, already in the rect: glyph size and shadow offset ride it, as
/// the 1.12 client's text rides `SetScale`; the editbox x-offsets arrive in screen UI units.
/// the 1.12 client's text rides `SetScale` and the UI scale; the editbox x-offsets arrive in
/// root units.
pub font_scale: f32,
/// Captures pin the caret on for deterministic pixels; live, the engine's blink decides.
pub caret_pinned: bool,
@@ -387,7 +388,7 @@ mod tests {
assert_eq!(band_clip_slack(Some(2.0)), 3.0);
}
/// `1.0546875` is the seam scale of a 1600×900 window at uiScale 0.9.
/// `1.0546875` is a 1600×900 window's seam times the 0.9 UI scale.
#[test]
fn a_shadow_offset_is_a_whole_pixel_and_never_vanishes() {
assert_eq!(shadow_offset_px(1.0), 1.0);
+2 -6
View File
@@ -94,8 +94,6 @@ pub(super) fn feed_ui_input(
),
// The characters the active layout makes, which name a key for a keyboard frame.
layout: Res<crate::bindings::LayoutNames>,
// The uiScale dial folded into the seam scale.
ui_scale: Res<super::UiScaleCvar>,
) {
let (keyboard, keys, capture, clipboard) = (&mut kbd.0, &kbd.1, &mut kbd.2, &mut kbd.3);
let world_pick = pointer.world_pick();
@@ -139,10 +137,8 @@ pub(super) fn feed_ui_input(
.or_else(crate::target::hover_probe_point)
.filter(|_| !synthetic)
{
// The window cursor is logical px, y-down from the top left; the UI is y-up in 768-high
// units under uiScale: flip through the window height, then undo the extract seam's scale.
let s = super::seam_scale(window.height(), ui_scale.0);
let (x, y) = (cursor.x / s, (window.height() - cursor.y) / s);
// The window cursor in the root's y-up 768-high units, as `GetCursorPosition` answers it.
let Vec2 { x, y } = super::window_to_ui(window.height(), cursor);
// `WOW_HIT_COST=1` meters this call's per-frame hit-test rebuild, one line a second.
static HIT_COST: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
let metering =
@@ -492,16 +492,19 @@ fn toggle_inspect_reopens_after_a_close_and_raises_like_the_reference() {
s.eval::<bool>("return InspectFrame:IsVisible()").unwrap(),
"the window still shows — Show() runs before OnShow raises"
);
// Two raises: `InspectFrame` and `InspectPaperDollFrame` each run their own `OnShow` under
// their own pcall, the first dying at the portrait's gate, the second at `UnitLevel`'s.
// Two raises: `InspectPaperDollFrame` and `InspectFrame` each run their own `OnShow` under
// their own pcall, the page first, since the show cascade notifies children before their
// parent (`0x76aed5`, then `0x76aef5`): the page dies at `UnitLevel`'s gate, the window at
// the portrait's.
let errors = s.errors();
assert_eq!(errors.len(), 2, "one raise per OnShow handler: {errors:?}");
assert!(
errors[0].contains(r#"Usage: SetPortraitTexture(texture, "unit")"#),
"the portrait's gate, before the name line is ever reached: {errors:?}"
errors[0].contains(r#"Usage: UnitLevel("unit")"#),
"the paper doll's own handler, dying at its first getter: {errors:?}"
);
assert!(
errors[1].contains(r#"Usage: UnitLevel("unit")"#),
"and the paper doll's own handler, dying at its first getter: {errors:?}"
errors[1].contains(r#"Usage: SetPortraitTexture(texture, "unit")"#),
"then the window's, at the portrait's gate, before the name line is ever reached: \
{errors:?}"
);
}
+187 -44
View File
@@ -230,26 +230,22 @@ pub(crate) fn run_pending_entry_load(world: &mut World) {
);
}
/// Seat the VM's screen size and text measurer for the load, under the current window and
/// `uiScale`: the load-edge half of [`super::extract::tick_script`]'s per-frame pair. A fresh VM
/// answers 1024×768 until then, and a value computed once at load keeps it: stock
/// `WorldMapFrame_OnLoad` sizes the full-screen `BlackoutWorld` from `GetScreenWidth()` and
/// `GetScreenHeight()` and never again (`WorldMapFrame.lua:19-28`). With no atlas yet (it bakes on
/// the first `Update`) the per-frame pass seats the measurer.
/// Seat the VM's screen size and text measurer for the load, under the current window: the
/// load-edge half of [`super::extract::tick_script`]'s per-frame pair. A fresh VM answers 1024×768
/// until then, and a value computed once at load keeps it: stock `WorldMapFrame_OnLoad` sizes the
/// full-screen `BlackoutWorld` from `GetScreenWidth()` and `GetScreenHeight()` and never again
/// (`WorldMapFrame.lua:19-28`), which answer the root while `UIParent` is still at scale 1. With no
/// atlas yet (it bakes on the first `Update`) the per-frame pass seats the measurer.
fn seat_raster_seam_for_load(world: &mut World, script: &mut UiScript) {
let ui_scale = world
.get_resource::<super::UiScaleCvar>()
.map_or(1.0, |c| c.0);
let (w, h) = {
let mut q = world.query_filtered::<&Window, With<bevy::window::PrimaryWindow>>();
q.single(world)
.map_or((0.0, 0.0), |win| (win.width(), win.height()))
};
let s = super::seam_scale(h, ui_scale);
let s = super::seam_scale(h);
if w > 0.0 && h > 0.0 {
// The VM lives in 768-tall virtual space, so logical size over the seam scale is its
// screen. No `UIParent_ManageFramePositions` here, unlike `tick_script`: nothing is laid
// out yet.
// The VM lives in a 768-tall root, so logical size over the seam scale is its screen. No
// `UIParent_ManageFramePositions` here, unlike `tick_script`: nothing is laid out yet.
script.set_screen_size(w / s, h / s);
}
let Some(atlas) = world.get_resource::<crate::ui_text::UiFontAtlas>() else {
@@ -258,12 +254,29 @@ fn seat_raster_seam_for_load(world: &mut World, script: &mut UiScript) {
super::extract::seat_text_measurer(script, atlas, s);
}
/// Which `"player"` a UI load runs its files under, by the edge that runs it.
#[derive(Clone, Copy, PartialEq, Eq)]
enum LoadSeat {
/// World entry: `UI_Init` runs before `CMSG_PLAYER_LOGIN` goes out (the load at `0x46c236`,
/// the send in a later step at `0x46c272`), so no player object exists and the roster's
/// record stands in ([`seat_from_roster`]).
Roster,
/// `/reload`: the player object stays in the world through the teardown and the load, so the
/// live one is seated ([`crate::ui_unit::live_player_seat`]), the roster's if none exists.
Live,
}
/// Load the in-game UI for this session onto a VM built for it, once per world entry. The
/// reference builds the UI in `UI_Init` (`0x48fbf0`, re-making the Lua state at `0x48fe97`) and
/// destroys it at `0x490bd0`, so every login runs every addon's file scope afresh. Safe on the
/// state edge because the initial transition runs after `PostStartup`: a capture booting straight
/// into `InWorld` still loads after [`benilla_assets::AssetSet::Open`].
pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
load_ingame_ui_seated(world, LoadSeat::Roster);
}
/// [`load_ingame_ui_on_world_entry`] under the `"player"` its edge has.
fn load_ingame_ui_seated(world: &mut World, seat: LoadSeat) {
// The parked VM goes back into the world, where the CVar fold reads it; a reload has none
// parked and uses the live one.
unpark_boot_vm(world);
@@ -322,19 +335,31 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
}
// The player too, before the addon walk. The record first: the reference's
// `UnitName`/`UnitRace`/`UnitClass`/`UnitSex` read only a copy of the char-enum row made at the
// Enter World commit (`0x5abd9e`) and never cleared, and each rebuilt VM is told once. The
// snapshot stands in for the descriptor until that streams in.
// Enter World commit (`0x5abd9e`) and never cleared, and each rebuilt VM is told once. Then
// the unit: the live player on a `/reload`, else the roster's stand-in until the descriptor
// streams in.
if let Some(record) = world
.get_resource::<crate::char_select::Roster>()
.and_then(record_from_roster)
{
script.set_player_record(record);
}
if let Some(seat) = world
.get_resource::<crate::char_select::Roster>()
.and_then(seat_from_roster)
match (seat == LoadSeat::Live)
.then(|| live_player_seat(world))
.flatten()
{
script.set_unit("player", Some(seat));
Some(live) => {
script.set_unit("player", Some(live.state));
script.set_player_xp(live.xp.0, live.xp.1);
}
None => {
if let Some(seat) = world
.get_resource::<crate::char_select::Roster>()
.and_then(seat_from_roster)
{
script.set_unit("player", Some(seat));
}
}
}
// The addon version gate, read from the persisted base the fold above just made current, so a
// character-screen "Load out of date AddOns" click counts. A bare test world is check on.
@@ -358,6 +383,9 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
// text width, `UIPanelTemplates.xml:370-373`), which answers 0 with no measurer.
seat_raster_seam_for_load(world, &mut script);
// Read out of the world first: the bracket below borrows `world` for the chat-cache restore.
let ui_scale = world
.get_resource::<super::UiScaleCvar>()
.map_or(1.0, |c| c.0);
let zoom = {
let z = world.resource::<crate::minimap::MinimapZoom>();
(z.outdoor, z.inside)
@@ -392,6 +420,21 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|script| crate::vplates::seat_legacy_settings(script, &legacy_plates),
|script| {
crate::ui_chat::restore_chat_looks(world, script);
// The UI scale lands on `UIParent` once the files, the addons and
// `VARIABLES_LOADED` are done, after the chat reader (`0x4900d6`) and before the
// `PLAYER_LOGIN` arm (`0x49011d`), through the display handler (`0x49010e` →
// `0x492e90`). So every `OnLoad` sees `UIParent` at scale 1 and the root's size.
let display = {
let mut q =
world.query_filtered::<&Window, With<bevy::window::PrimaryWindow>>();
q.single(world).map_or((0, 0), |w| {
(w.width().round() as u32, w.height().round() as u32)
})
};
let mut none = None;
let mut cache = world.get_resource_mut::<super::UiAutoScaleCache>();
let cache = cache.as_deref_mut().map_or(&mut none, |c| &mut c.0);
script.seat_ui_scale(ui_scale, display, cache);
},
);
});
@@ -418,6 +461,17 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
}
}
/// The live player's seat off the world, through the unit feed's own builder; `None` before our
/// own create. A world without the feed's resources (a bare test world) has none to read.
fn live_player_seat(world: &mut World) -> Option<crate::ui_unit::LivePlayerSeat> {
world
.run_system_cached(crate::ui_unit::live_player_seat)
.unwrap_or_else(|e| {
warn!("ui_script: no live player to seat for the load ({e}); the roster stands in");
None
})
}
/// The wire's gender byte (0 male, 1 female) on `UnitSex`'s 2/3 scale, as `ui_unit::snapshot`
/// maps the descriptor's; the record and the seat share it.
fn roster_sex(gender: u8) -> u8 {
@@ -486,14 +540,53 @@ pub(crate) fn seat_from_roster(
pub(crate) struct AddOnIdentity(pub(crate) Option<(String, String)>);
/// The world latch, the reference's `[0xb4b424]`: set at `0x4908ce` in the world-enter cascade
/// `0x4908c0` and cleared at `0x490a8d` as `PLAYER_LEAVING_WORLD` fires (`0x490b4d`, its one
/// site), so the first departure in a world fires and the rest are no-ops until the next entry.
/// Ours has two producers, [`shutdown_ui_state`]'s tail and `ui_unit`'s worldport fire. A
/// `0x4908c0` and cleared at `0x490a8d` by the leave-world sweep `0x490a80`, which then fires
/// `PLAYER_LEAVING_WORLD` (`0x490b4d`, its one site), so the first departure in a world sweeps
/// and the rest are no-ops until the next entry. Ours is spent through [`LeaveWorldSweep`] or
/// [`run_leave_world_sweep`], by [`shutdown_ui_state`]'s roots and `ui_unit`'s worldport fire. A
/// cross-map worldport spends it without leaving `InWorld`, so a quit on its loading screen
/// fires nothing; a `/reload` fires, and its rebuild re-arms.
#[derive(Resource, Default)]
pub(crate) struct LeavingWorldArmed(bool);
/// The leave-world sweep `0x490a80` ran past its latch: a cross-map worldport (the local
/// player's destructor, `0x5dd543`), a `/reload` (`0x490c20`) or leaving the world. Its legs
/// past the clear read this, as the meeting stone's text drop (`0x490b2f` → `0x4c9f80`) does; a
/// same-map teleport destroys nothing and never sweeps.
#[derive(Message)]
pub(crate) struct WorldLeaveSweepMessage;
/// The latch and the sweep it gates, for a system: spending one writes the other.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct LeaveWorldSweep<'w> {
armed: ResMut<'w, LeavingWorldArmed>,
sweeps: MessageWriter<'w, WorldLeaveSweepMessage>,
}
impl LeaveWorldSweep<'_> {
/// Run the sweep: `true`, and [`WorldLeaveSweepMessage`] written, at most once per world.
pub(crate) fn run(&mut self) -> bool {
let swept = self.armed.spend();
if swept {
self.sweeps.write(WorldLeaveSweepMessage);
}
swept
}
}
/// [`LeaveWorldSweep::run`] for an exclusive edge; no latch is no world to leave.
pub(crate) fn run_leave_world_sweep(world: &mut World) -> bool {
let swept = world
.get_resource_mut::<LeavingWorldArmed>()
.is_some_and(|mut l| l.spend());
if swept {
if let Some(mut sweeps) = world.get_resource_mut::<Messages<WorldLeaveSweepMessage>>() {
sweeps.write(WorldLeaveSweepMessage);
}
}
swept
}
impl LeavingWorldArmed {
/// A world began. Idempotent, like the reference's `mov byte [0xb4b424],1`.
pub(crate) fn arm(&mut self) {
@@ -501,7 +594,7 @@ impl LeavingWorldArmed {
}
/// Take the one-shot: `true` at most once per world, `0x490a8d`'s clear folded in.
pub(crate) fn spend(&mut self) -> bool {
fn spend(&mut self) -> bool {
std::mem::take(&mut self.0)
}
@@ -566,9 +659,7 @@ pub(crate) fn end_ui_session(world: &mut World) {
.get_resource::<AddOnIdentity>()
.and_then(|id| id.0.clone());
// Spent at the root: of this edge and the worldport's, the first to a departure owns it.
let leaving_world = world
.get_resource_mut::<LeavingWorldArmed>()
.is_some_and(|mut l| l.spend());
let leaving_world = run_leave_world_sweep(world);
if !ui_never_loaded {
if let Some(mut script) = world.get_non_send_resource_mut::<UiScript>() {
shutdown_ui_state(&mut script, identity.as_ref(), leaving_world);
@@ -611,11 +702,13 @@ pub(crate) fn end_ui_session(world: &mut World) {
pub(crate) struct ReloadUiPending(pub(crate) bool);
/// Run a pending `ReloadUI()`: the reference's teardown and rebuild (`0x495664 call 0x490bd0`,
/// `0x495669 call 0x48fbf0`), for us [`end_ui_session`] then [`load_ingame_ui_on_world_entry`]
/// without leaving the world, so a `DisableAddOn` staged in the dying VM reaches `AddOns.txt`
/// before the rebuild reads it. `PLAYER_ENTERING_WORLD` then refires from [`crate::ui_unit`]'s
/// feed on the new VM. In-world only, as the reference's gate (`0x494a50(0xa)`) is; elsewhere the
/// request is dropped.
/// `0x495669 call 0x48fbf0`), for us [`end_ui_session`] then the load without leaving the world,
/// so a `DisableAddOn` staged in the dying VM reaches `AddOns.txt` before the rebuild reads it.
/// The load runs under the live player, which never left. `UI_Init` then enters the world-enter
/// cascade itself (`0x490168`), firing the `PLAYER_LOGIN` it armed and `PLAYER_ENTERING_WORLD`;
/// ours fire from [`crate::ui_unit`]'s feed on the new VM's first `Update`, once it has re-seated
/// the player. In-world only, as the reference's gate (`0x494a50(0xa)`) is; elsewhere the request
/// is dropped.
pub(crate) fn run_pending_reload(world: &mut World) {
if !std::mem::take(&mut world.resource_mut::<ReloadUiPending>().0) {
return;
@@ -630,7 +723,18 @@ pub(crate) fn run_pending_reload(world: &mut World) {
}
info!("ui_script: ReloadUI — ending the UI session and building a new one");
end_ui_session(world);
load_ingame_ui_on_world_entry(world);
load_ingame_ui_seated(world, LoadSeat::Live);
// `UI_Init` ends by re-entering the world-enter cascade (`0x490168`), its sends included, but
// only with the active player set (`0x490166 je`): our own player's entity exists. Not
// `SetActiveMover`, which only the player's create runs.
let seated = world
.query_filtered::<(), With<crate::net::SelfPlayer>>()
.iter(world)
.next()
.is_some();
if seated {
world.write_message(crate::net::WorldEnterCascadeMessage);
}
}
/// `AppExit`, the quit roots, read as a message because a quit from in-world never leaves
@@ -640,7 +744,7 @@ pub(crate) fn shutdown_on_exit(
script: Option<NonSendMut<UiScript>>,
id: Res<AddOnIdentity>,
pending_entry: Option<Res<PendingEntryUiLoad>>,
mut armed: ResMut<LeavingWorldArmed>,
mut sweep: LeaveWorldSweep,
mut exits: MessageReader<AppExit>,
) {
if exits.read().next().is_none() {
@@ -650,7 +754,7 @@ pub(crate) fn shutdown_on_exit(
if pending_entry.is_some() {
return;
}
let leaving_world = armed.spend();
let leaving_world = sweep.run();
if let Some(mut script) = script {
shutdown_ui_state(&mut script, id.0.as_ref(), leaving_world);
}
@@ -662,13 +766,13 @@ pub(crate) fn shutdown_on_exit(
/// saved file and fires `VARIABLES_LOADED` (`0x4900b2` → `0x4913b0`), and arms `PLAYER_LOGIN`
/// (`[0xb4e260]`, set at `0x49011d`). The world-enter cascade `0x4908c0` fires `PLAYER_LOGIN` if
/// armed (read at `0x49094b`, fired at `0x490959`, disarmed at `0x49095e`), then
/// `PLAYER_ENTERING_WORLD` (`0x49096a`).
/// `PLAYER_ENTERING_WORLD` (`0x49096a`): [`UiScript::fire_world_enter`].
///
/// On a `/reload`, `UI_Init` enters the cascade itself (`0x490168`). On a fresh login that call is
/// skipped (`0x490166 je`, the active-player GUID still 0) and the cascade runs from the local
/// player's own create (`0x5deb60 call 0x4908c0`). This fires `PLAYER_LOGIN` at the end of the
/// load on both, the `/reload` shape; `PLAYER_ENTERING_WORLD` fires from [`crate::ui_unit`] once
/// the self descriptor lands.
/// player's own create (`0x5deb60 call 0x4908c0`). Either way the player object exists when it
/// runs, so this only arms, and [`crate::ui_unit`]'s feed fires both once it has seated the
/// player.
#[cfg(test)]
pub(crate) fn finish_ui_load(script: &mut UiScript) {
finish_ui_load_with(script, |_| {}, |_| {});
@@ -676,10 +780,10 @@ pub(crate) fn finish_ui_load(script: &mut UiScript) {
/// [`finish_ui_load`] with its two host seams. `host_settings` runs between the saved-variables
/// chunk and `VARIABLES_LOADED`, carrying over a setting `config.toml` held before it moved into
/// the saved variables. `between`
/// is the chat-cache restore's `UPDATE_CHAT_WINDOWS` + `UPDATE_CHAT_COLOR` burst, after
/// `VARIABLES_LOADED` and before `PLAYER_LOGIN`, where the reference registers its reader
/// (`0x4900d6` → `0x498a20`). On a fresh login the reference's cache (`0x5afe50`) defers that
/// the saved variables. `between` runs after `VARIABLES_LOADED` and before the `PLAYER_LOGIN` arm
/// (so before the event too): the chat-cache restore's `UPDATE_CHAT_WINDOWS` +
/// `UPDATE_CHAT_COLOR` burst, where the reference registers its reader (`0x4900d6` → `0x498a20`),
/// then the UI scale (`0x49010e`). On a fresh login the reference's cache (`0x5afe50`) defers that
/// reader until the server's account data reconciles (`0x5afa42`/`0x5afd01`), after `UI_Init` has
/// returned. Either way a `VARIABLES_LOADED` handler sees the boot chat colours, not the file's.
pub(crate) fn finish_ui_load_with(
@@ -688,11 +792,12 @@ pub(crate) fn finish_ui_load_with(
between: impl FnOnce(&mut UiScript),
) {
// Still the load edge, so still bounded: re-armed so the saved variables and every
// `PLAYER_LOGIN` handler get the full allowance; the entry edge disarms it after.
// `VARIABLES_LOADED` handler get the full allowance; the entry edge disarms it after. The
// world-enter events fire later, unbounded, as every event the feeds fire is.
script.set_instruction_budget(addons::LOAD_INSTRUCTION_BUDGET);
crate::ui_saved::load_saved_variables(script, host_settings);
between(script);
script.fire_event("PLAYER_LOGIN", vec![]);
script.arm_player_login();
}
/// Run the patch chain's `Interface\FrameXML\GlobalStrings.lua`, the first file in the reference's
@@ -842,6 +947,44 @@ mod tests {
assert_eq!(*world.resource::<VPlateMode>(), on, "a reload keeps them");
}
/// A `ReloadUI()` runs the leave-world sweep (`0x490c20`, past the armed latch) and then
/// re-enters the world-enter cascade (`0x490168`), whose readers re-send the time, mail,
/// battlefield and meeting-stone queries, only with the active player set (`0x490166 je`).
#[test]
fn a_reload_sweeps_and_reenters_the_world_enter_cascade_once_the_player_exists() {
use crate::net::WorldEnterCascadeMessage;
let reload = |seated: bool| {
let mut world = World::new();
world.insert_resource(State::new(crate::char_select::ClientState::InWorld));
world.insert_resource(crate::run_mode::CaptureMode);
world.init_resource::<Messages<WorldEnterCascadeMessage>>();
world.init_resource::<Messages<WorldLeaveSweepMessage>>();
world.init_resource::<LeavingWorldArmed>();
world.resource_mut::<LeavingWorldArmed>().arm();
if seated {
world.spawn(crate::net::SelfPlayer);
}
world.insert_resource(ReloadUiPending(true));
run_pending_reload(&mut world);
assert!(!world.resource::<ReloadUiPending>().0, "the reload ran");
let sweeps = world
.resource_mut::<Messages<WorldLeaveSweepMessage>>()
.drain()
.count();
let cascades = world
.resource_mut::<Messages<WorldEnterCascadeMessage>>()
.drain()
.count();
(sweeps, cascades)
};
assert_eq!(reload(true), (1, 1), "in the world");
assert_eq!(
reload(false).1,
0,
"no cascade before our own player's create"
);
}
/// The reference re-makes its Lua state inside `UI_Init` (`0x48fe97`).
#[test]
fn the_world_entry_builds_its_own_vm_rather_than_adopting_the_glue_phases() {
+14 -11
View File
@@ -68,13 +68,13 @@ pub(super) fn apply_buff_durations(script: &UiScript) -> Result<(), String> {
.map_err(|e| e.to_string())
}
/// What a screen-size change re-runs, from `extract::tick_script`'s resize arm: anchors follow the
/// screen, but not a size or seat computed from the old one. The managed pass re-wraps the open
/// bags, whose columns `updateContainerFrameAnchors` lays out by `GetScreenHeight()`.
/// What a screen-size or UI-scale change re-runs, from `extract::tick_script`: anchors follow the
/// screen, but not a size or seat computed from the old `GetScreenHeight()`. The managed pass
/// re-wraps the open bags, whose columns `updateContainerFrameAnchors` lays out by it.
/// Deviation: [`FULLSCREEN_QUADS_RESEAT`] re-sizes the full-screen quads, which the stock files
/// size only in their OnLoads, because a resize or `uiScale` change here keeps the loaded UI and
/// the blackout would stop short of the new screen. Host-side, not in the layer: it adapts the
/// engine's resizable window, and changes nothing the stock UI does at a fixed size.
/// size only in their OnLoads, because a resize here keeps the loaded UI and the blackout would
/// stop short of the new screen. Host-side, not in the layer: it adapts the engine's resizable
/// window, and changes nothing the stock UI does at a fixed size.
pub(super) fn on_screen_resized(script: &UiScript) {
let _ = script.run("if UIParent_ManageFramePositions then UIParent_ManageFramePositions() end");
if let Err(e) = script.run(FULLSCREEN_QUADS_RESEAT) {
@@ -85,11 +85,14 @@ pub(super) fn on_screen_resized(script: &UiScript) {
/// The sizing arithmetic of `WorldMapFrame_OnLoad` (`WorldMapFrame.lua:19-28`) and
/// `CinematicFrame_OnLoad` (`CinematicFrame.lua:6-21`), each existence-guarded: the glue screens
/// have neither. Below 4:3 the bars go back to `CinematicFrame.xml`'s declared 1024 x 128, where
/// the stock OnLoad leaves them.
/// the stock OnLoad leaves them. The OnLoads read the screen root's size, `UIParent` being still at
/// scale 1 then, and their frames hang off the root, so this re-reads the root:
/// `GetScreenWidth()` times `UIParent`'s scale.
const FULLSCREEN_QUADS_RESEAT: &str = r#"
local rootScale = UIParent and UIParent:GetEffectiveScale() or 1
if BlackoutWorld then
local width = GetScreenWidth()
local height = GetScreenHeight()
local width = GetScreenWidth() * rootScale
local height = GetScreenHeight() * rootScale
if ( width / height < 4 / 3 ) then
width = width * 1.25
height = height * 1.25
@@ -98,8 +101,8 @@ if BlackoutWorld then
BlackoutWorld:SetHeight( height )
end
if UpperBlackBar and LowerBlackBar then
local width = GetScreenWidth()
local height = GetScreenHeight()
local width = GetScreenWidth() * rootScale
local height = GetScreenHeight() * rootScale
local barWidth, blackBarHeight = 1024, 128
if ( width / height > 4 / 3 ) then
local desiredHeight = width / 2
+54 -22
View File
@@ -1,8 +1,9 @@
//! The UI-engine bridge: hosts [`benilla_ui::script::UiScript`] and feeds its
//! [`extract`](benilla_ui::script::UiScript::extract) output into the quad pass
//! ([`crate::ui_pass::UiQuads`]) every frame. The script side is WoW UI space (y-up, origin
//! bottom-left, a screen `768/uiScale` units tall); [`seam_scale`] carries quads ×s out and the
//! mouse ÷s in, and extraction flips to y-down window px.
//! bottom-left, a screen root 768 units tall at scale 1); [`seam_scale`] carries quads ×s out and
//! the mouse ÷s in, and extraction flips to y-down window px. The UI scale is `UIParent`'s own
//! frame scale inside the VM ([`UiScaleCvar`]), never part of the seam.
use bevy::prelude::*;
@@ -199,8 +200,10 @@ pub(crate) fn run_or_warn(script: &benilla_ui::script::UiScript, chunk: &str) {
}
}
/// The reference's `uiScale` CVar: the VM's screen is `768/uiScale` units tall, so
/// `uiScale = 768/screenH` is pixel-perfect. Tests pin the `Default` 1.0.
/// The reference's `uiScale` CVar, which the VM makes `UIParent`'s own scale
/// ([`UiScript::set_ui_scale`]): the screen root stays 768 units tall, so `uiScale = 768/screenH`
/// is pixel-perfect inside `UIParent`, and a frame outside it is not scaled. Tests pin the
/// `Default` 1.0.
#[derive(Resource)]
pub(crate) struct UiScaleCvar(pub(crate) f32);
@@ -213,8 +216,17 @@ impl Default for UiScaleCvar {
/// The shipped `uiScale`. Deviation: a flat 0.9, because 1.0 reads oversized. With `useUiScale`
/// off, its default (`0x8430c0`), the reference sets `max(768/H, 0.9)` above 768 px tall and 1.0
/// at or below (`0x492f70`, on a mode set and from `0x4908ad`), so the two agree from ~853 px up.
/// benilla never reads `useUiScale`: this CVar always applies to `UIParent` alone, as with it on.
/// A change goes through the CVar callback (`0x490770`: the narrow-aspect cap, the 0.64 floor);
/// the load and a display change through the display handler's ON leg (`0x492e90`: the cap, and
/// below 0.64 the automatic scale `0x492f70` instead of the floor).
pub(crate) const DEFAULT_UI_SCALE: f32 = 0.9;
/// The display the reference's automatic UI scale last ran for (`0x492f70`'s `[0xb4e304]` and
/// `[0xb4e308]`), which outlives every UI: the display handler skips a display it already ran for.
#[derive(Resource, Default)]
pub(crate) struct UiAutoScaleCache(pub(crate) Option<(u32, u32)>);
/// `WOW_UI_SCALE=` if set, clamped to the dial's range, else [`DEFAULT_UI_SCALE`].
fn default_ui_scale() -> f32 {
std::env::var("WOW_UI_SCALE")
@@ -224,16 +236,24 @@ fn default_ui_scale() -> f32 {
.unwrap_or(DEFAULT_UI_SCALE)
}
/// The seam scale `s`, window px per UI unit (`windowH/768 × uiScale`), used at every crossing of
/// the VM boundary; identity for a degenerate window (h ≤ 0, before winit).
pub(crate) fn seam_scale(window_h: f32, ui_scale: f32) -> f32 {
/// The seam scale `s`, window px per screen-root unit (`windowH/768`: the root is 768 units tall at
/// every aspect, `0x41ad10`), used at every crossing of the VM boundary; identity for a degenerate
/// window (h ≤ 0, before winit). The UI scale is not in it: it is `UIParent`'s, inside the VM.
pub(crate) fn seam_scale(window_h: f32) -> f32 {
if window_h > 0.0 {
window_h / 768.0 * ui_scale
window_h / 768.0
} else {
1.0
}
}
/// A window cursor (logical px, y-down from the top left) in the VM's root units, y up: what the
/// pointer feed hands the engine and `GetCursorPosition` answers (`0x48b820`, no scale divided out).
pub(crate) fn window_to_ui(window_h: f32, cursor: Vec2) -> Vec2 {
let s = seam_scale(window_h);
Vec2::new(cursor.x / s, (window_h - cursor.y) / s)
}
/// The Lua UI host (a `NonSend` resource: an mlua VM is `!Send`) and its per-frame passes.
pub(crate) struct UiScriptPlugin;
@@ -263,6 +283,7 @@ impl Plugin for UiScriptPlugin {
// The quit root runs in `Last`: the close button's `AppExit` is written in `PostUpdate`.
crate::shutdown::on_app_exit(app, shutdown_on_exit.into_configs());
app.insert_resource(UiScaleCvar(default_ui_scale()))
.init_resource::<UiAutoScaleCache>()
.init_resource::<UiFrameCost>()
.init_resource::<crate::bindings::WheelNotches>()
.init_resource::<UiCostWanted>()
@@ -295,6 +316,7 @@ impl Plugin for UiScriptPlugin {
)
// `init_` here and in `UiUnitPlugin`: either plugin may be built alone in a test.
.init_resource::<LeavingWorldArmed>()
.add_message::<WorldLeaveSweepMessage>()
.add_systems(Update, lifecycle::arm_leaving_world_on_self_create)
// A queued `ReloadUI()` runs in `PreUpdate`, a frame after its drain (the reference's
// deferral, `0x495590`) and before every `Update` seed or feed.
@@ -381,8 +403,8 @@ fn arbitrate_pointer_over_ui(
/// The session lifecycle: the VM's birth, identity, death and reload.
mod lifecycle;
pub(crate) use lifecycle::{
end_ui_session, ingame_ui_up, run_pending_reload, setup_script, AddOnIdentity,
LeavingWorldArmed, PendingEntryUiLoad, ReloadUiPending,
end_ui_session, ingame_ui_up, run_pending_reload, setup_script, AddOnIdentity, LeaveWorldSweep,
LeavingWorldArmed, PendingEntryUiLoad, ReloadUiPending, WorldLeaveSweepMessage,
};
// Test-only: other modules' tests consume these, and a plain re-export would warn unused.
#[cfg(test)]
@@ -599,9 +621,9 @@ fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<VmMemo<
},
]);
script.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
// 70% XP, set before `PLAYER_ENTERING_WORLD` so the bar's first update reads it.
// 70% XP, set before the world-enter events so the bar's first update reads it.
script.set_player_xp(4200, 6000);
script.fire_event("PLAYER_ENTERING_WORLD", vec![]);
script.fire_world_enter();
// The bottom multibars ship off and a capture has no toggle byte, so raise them as the
// Options rows do; `WOW_DEMO_BOTTOM_BARS=0` leaves them down for the stance shelf art,
// which `ShapeshiftBar_UpdatePosition` hides under the bottom-left bar.
@@ -705,6 +727,9 @@ mod chat_resize_tests;
#[cfg(test)]
mod dropdown_tests;
#[cfg(test)]
mod ui_scale_tests;
#[cfg(test)]
mod action_bar_tests;
@@ -794,7 +819,7 @@ mod loot_tests;
mod group_loot_tests;
#[cfg(test)]
mod chat_tests;
pub(crate) mod chat_tests;
/// The chat bubble's `UIMenu` kit driven as a menu: the rows' label and shortcut anchoring.
#[cfg(test)]
@@ -985,19 +1010,26 @@ mod world_map_tests;
#[cfg(test)]
mod seam_scale_tests {
use super::seam_scale;
use super::{seam_scale, window_to_ui};
use bevy::math::Vec2;
#[test]
fn seam_scale_is_the_768_base_times_the_dial() {
fn seam_scale_is_the_768_tall_root() {
// Identity at the design height, proportional elsewhere.
assert_eq!(seam_scale(768.0, 1.0), 1.0);
assert_eq!(seam_scale(1536.0, 1.0), 2.0);
// The dial multiplies it.
assert_eq!(seam_scale(768.0, 0.9), 0.9);
// The reference's pixel-perfect setting: uiScale = 768/screenH → 1 px per UI unit.
assert!((seam_scale(1080.0, 768.0 / 1080.0) - 1.0).abs() < 1e-6);
assert_eq!(seam_scale(768.0), 1.0);
assert_eq!(seam_scale(1536.0), 2.0);
// A degenerate (pre-winit) window is identity, never a division blow-up.
assert_eq!(seam_scale(0.0, 0.9), 1.0);
assert_eq!(seam_scale(0.0), 1.0);
}
/// `GetCursorPosition` answers root units whatever the dial: the bottom of a 1080-tall window
/// is 0 and its top 768, its middle column `768·a/2`.
#[test]
fn the_cursor_reaches_the_vm_in_root_units() {
let at = window_to_ui(1080.0, Vec2::new(960.0, 108.0));
assert!((at.x - 960.0 * 768.0 / 1080.0).abs() < 1e-3, "{at}");
assert!((at.y - 972.0 * 768.0 / 1080.0).abs() < 1e-3, "{at}");
assert_eq!(window_to_ui(1080.0, Vec2::new(0.0, 1080.0)), Vec2::ZERO);
}
}
@@ -1346,14 +1346,17 @@ fn the_nameplates_page_toggles_the_unit_name_cvars() {
"{row} defaults unchecked"
);
}
// The name rows at the reference's registered defaults: player "1", NPC and own "0".
assert!(
s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyNameplatesRowPlayerNamesCheck:GetChecked()"
)
.unwrap(),
"Player Names defaults checked"
);
// The name rows at the reference's registered defaults: player, guild and titles "1", NPC and
// own "0".
for row in ["RowPlayerNames", "RowGuildNames", "RowPlayerTitles"] {
assert!(
s.eval::<bool>(&format!(
"return BenillaOptionsFrameContainerBodyNameplates{row}Check:GetChecked()"
))
.unwrap(),
"{row} defaults checked"
);
}
for row in ["RowNpcNames", "RowOwnName"] {
assert!(
!s.eval::<bool>(&format!(
@@ -2915,11 +2918,11 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
);
checked += 1;
}
// 83 rows less the three untipped below; four of the 80 carry a `BENILLA_` key, and a dropdown
// 84 rows less the three untipped below; four of the 81 carry a `BENILLA_` key, and a dropdown
// row is checked on the key it wears at rest.
// MONKEY (graphics programme): the Advanced Graphics page's tipped rows (lighting, water,
// lava, fog, daylight, Graphics Preset, Render Distance, ...) add 20. Upstream 80 -> 100.
assert_eq!(checked, 100, "every tipped row carries a live key");
// lava, fog, daylight, Graphics Preset, Render Distance, ...) add 20. Upstream 81 -> 101.
assert_eq!(checked, 101, "every tipped row carries a live key");
assert_eq!(
untipped,
vec![
@@ -3018,9 +3021,9 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
s.errors()
);
}
// Every tipped row: the same 80 the key census counts.
// MONKEY (graphics programme): the Advanced Graphics page's described rows add 19. 80 -> 99.
assert_eq!(raised, 99, "every row but Auto Loot raises a description");
// Every tipped row: the same 81 the key census counts.
// MONKEY (graphics programme): the Advanced Graphics page's described rows add 19. 81 -> 100.
assert_eq!(raised, 100, "every row but Auto Loot raises a description");
}
/// 1.12's AdvancedOptionsCombatText box as saved-global rows: a click writes the global, never a
@@ -4680,13 +4683,6 @@ fn without_a_seated_measurer_the_same_fit_reads_zero() {
// nothing and reads back nil, so its box would offer a setting benilla does not have. A CVar is
// registered only once something reads it.
const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
(
"UnitNamePlayerPVPTitle",
"the PvP rank prefix on the overhead name line — slot a4 of `0x608f50`, bit `0x20` of the \
same mask, resolved by `0x609370` through the `PVP_RANK_%d_%d` GlobalStrings key. Blocked \
one step further back than its guild twin: the rank byte streams, but the key's second \
index is a FACTION SIDE that `ui_unit` does not resolve for an arbitrary player yet",
),
// No slider CVar may land here: `UIOptionsFrame_Load` hands `GetCVar` to `Slider:SetValue`
// (`UIOptionsFrame.lua:271-275`), which raises on nil (`0x790980`) and stops the stock
// window's load. A check button's `GetCVar(x) == "1"` is nil-safe.
@@ -4990,7 +4986,48 @@ fn show_target_damage_greys_its_own_pair_and_the_floating_text_master_leaves_it_
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The reference's rules: Player Guild Names is dead while player names are off
/// The titles box is the guild row's twin: checked and live at the registered defaults (`"1"`),
/// and a click survives closing and reopening the panel because the CVar is its store.
#[test]
fn the_titles_box_sticks_across_a_panel_reopen() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowNameplates:Click()")
.unwrap();
let checked = |s: &mut UiScript| -> bool {
s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyNameplatesRowPlayerTitlesCheck:GetChecked()",
)
.unwrap()
};
let cvar = |s: &mut UiScript| -> Option<String> {
s.eval::<Option<String>>(r#"return GetCVar("UnitNamePlayerPVPTitle")"#)
.unwrap()
};
assert!(checked(&mut s), "the registered default is \"1\"");
assert_eq!(cvar(&mut s).as_deref(), Some("1"));
// A click writes the CVar; the box and the mirror agree.
s.run("BenillaOptionsFrameContainerBodyNameplatesRowPlayerTitlesCheck:Click()")
.unwrap();
assert!(!checked(&mut s));
assert_eq!(cvar(&mut s).as_deref(), Some("0"));
// Closed and reopened, the row re-reads the CVar instead of a staged value.
s.run("HideUIPanel(BenillaOptionsFrame) ShowUIPanel(BenillaOptionsFrame)")
.unwrap();
s.run("BenillaOptionsFrameCategoryListRowNameplates:Click()")
.unwrap();
assert!(!checked(&mut s), "the click stuck");
s.run("BenillaOptionsFrameContainerBodyNameplatesRowPlayerTitlesCheck:Click()")
.unwrap();
assert!(checked(&mut s));
assert_eq!(cvar(&mut s).as_deref(), Some("1"));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The reference's rules: Player Guild Names and Player Titles are dead while player names are off
/// (`UIOptionsFrame.lua:703-709`), Auto-Follow Speed while the style is Never (`:717-721`).
#[test]
fn the_guild_line_greys_with_player_names_and_the_follow_speed_with_the_style() {
@@ -4998,22 +5035,28 @@ fn the_guild_line_greys_with_player_names_and_the_follow_speed_with_the_style()
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
// Nameplates: `UnitNamePlayer` ships "1", so the child arrives live.
// Nameplates: `UnitNamePlayer` ships "1", so both children arrive live.
s.run("BenillaOptionsFrameCategoryListRowNameplates:Click()")
.unwrap();
let guild_on = |s: &mut UiScript| -> bool {
s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyNameplatesRowGuildNamesCheck:IsEnabled() ~= 0",
)
let child_on = |s: &mut UiScript, row: &str| -> bool {
s.eval::<bool>(&format!(
"return BenillaOptionsFrameContainerBodyNameplates{row}Check:IsEnabled() ~= 0"
))
.unwrap()
};
assert!(guild_on(&mut s));
for row in ["RowGuildNames", "RowPlayerTitles"] {
assert!(child_on(&mut s, row), "{row} arrives live");
}
s.run("BenillaOptionsFrameContainerBodyNameplatesRowPlayerNamesCheck:Click()")
.unwrap();
assert!(!guild_on(&mut s), "no names, no guild line to gate");
for row in ["RowGuildNames", "RowPlayerTitles"] {
assert!(!child_on(&mut s, row), "no names, no {row} to gate");
}
s.run("BenillaOptionsFrameContainerBodyNameplatesRowPlayerNamesCheck:Click()")
.unwrap();
assert!(guild_on(&mut s));
for row in ["RowGuildNames", "RowPlayerTitles"] {
assert!(child_on(&mut s, row));
}
// Controls: the style ships "1" (Smart), so the slider arrives live; "0" is Never.
s.run("BenillaOptionsFrameCategoryListRowControls:Click()")
@@ -1693,3 +1693,115 @@ fn the_stock_title_width_cap_bites_and_is_sticky_per_row_button() {
"the capped box survives the row going untagged (the ref's own 275 reset never fires)"
);
}
// ── The group-mates count ──
/// A party member held with `ids` in their quest-log window, named `name`.
fn mate_on(guid: u64, name: &str, ids: &[u32]) -> benilla_ui::script::UnitState {
let mut quest_log = [[0; 3]; 20];
for (slot, &id) in quest_log.iter_mut().zip(ids) {
*slot = [id, 0, 0];
}
benilla_ui::script::UnitState {
exists: true,
has_object: true,
is_player: true,
guid,
name: Some(name.into()),
quest_log,
..Default::default()
}
}
/// Each quest row counts the party members on it as `[n]` (`QuestLogFrame.lua:168-183`), through
/// `IsUnitOnQuest(questIndex, "partyN")`; the row tooltip names them (`:515-541`), and a mate's
/// `UNIT_QUEST_LOG_CHANGED` recounts (`:69-72`).
#[test]
fn quest_rows_count_the_party_members_on_each_quest() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kits
s.set_quest_log(eight_entries());
s.set_party(party(2));
s.set_unit("party1", Some(mate_on(0x300, "Mate0", &[1, 3])));
s.set_unit("party2", Some(mate_on(0x301, "Mate1", &[1])));
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let mates = |s: &mut UiScript, row: u32| {
s.eval::<String>(&format!(
"return QuestLogTitle{row}GroupMates:GetText() or ''"
))
.unwrap()
};
assert_eq!(mates(&mut s, 1), "[2]", "both mates are on quest 1");
assert_eq!(mates(&mut s, 2), "", "nobody is on quest 2");
assert_eq!(mates(&mut s, 3), "[1]", "party1 is on quest 3");
// The row tooltip, as `QuestLogTitleButton_OnEnter` builds it for row 1, then row 2.
let line = |s: &mut UiScript, n: u32| {
s.eval::<String>(&format!("return GameTooltipTextLeft{n}:GetText() or ''"))
.unwrap()
};
s.run("this = QuestLogTitle1 QuestLog_UpdatePartyInfoTooltip()")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(line(&mut s, 1), "Quest 1");
assert!(s
.eval::<bool>(
"return GameTooltipTextLeft2:GetText() == \
HIGHLIGHT_FONT_COLOR_CODE..PARTY_QUEST_STATUS_ON..FONT_COLOR_CODE_CLOSE"
)
.unwrap());
assert!(line(&mut s, 3).contains("Mate0"), "{}", line(&mut s, 3));
assert!(line(&mut s, 4).contains("Mate1"), "{}", line(&mut s, 4));
s.run("this = QuestLogTitle2 QuestLog_UpdatePartyInfoTooltip()")
.unwrap();
assert!(s
.eval::<bool>(
"return GameTooltipTextLeft2:GetText() == \
HIGHLIGHT_FONT_COLOR_CODE..PARTY_QUEST_STATUS_NONE..FONT_COLOR_CODE_CLOSE"
)
.unwrap());
// party2 takes quest 3: their quest-log event recounts the open log.
s.set_unit("party2", Some(mate_on(0x301, "Mate1", &[1, 3])));
s.fire_event(
"UNIT_QUEST_LOG_CHANGED",
vec![benilla_ui::script::ScriptValue::Str("party2".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(mates(&mut s, 3), "[2]");
// Out of range, a mate counts for nothing, whatever their last window held (`0x468460` finds
// no object).
s.set_unit(
"party1",
Some(benilla_ui::script::UnitState {
has_object: false,
..mate_on(0x300, "Mate0", &[1, 3])
}),
);
s.run("QuestLog_Update()").unwrap();
assert_eq!(mates(&mut s, 1), "[1]");
assert_eq!(mates(&mut s, 3), "[1]");
}
@@ -1937,12 +1937,6 @@ mod tests {
),
("SYSMSG", "UIErrorsFrame.lua"),
("UNIT_DEFENSE", "PetPaperDollFrame.lua"),
(
"UNIT_QUEST_LOG_CHANGED",
"QuestLogFrame.lua — a party member's quest-log fields changing (the reference \
fires it off the unit's PLAYER_QUEST_LOG_* descriptor updates); benilla's unit \
feed does not derive it yet",
),
(
"UNIT_MODEL_CHANGED",
"four files — the paperdoll model refresh",
+6 -2
View File
@@ -501,7 +501,7 @@ pub(crate) const GOSSIP_UI: &[&str] = &[
/// lacks what the app loads at setup (the master-loot menu reads `GROUP` and `GIVE_LOOT`),
/// `ItemButtonTemplate.xml` because a missing template only warns, and `PartyFrame.xml` because
/// `LootFrame.lua:217` reads `MAX_PARTY_MEMBERS` at load. Needs client data.
pub(super) const LOOT_UI: &[&str] = &[
pub(crate) const LOOT_UI: &[&str] = &[
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\BasicControls.xml", // `TEXT`, which UnitPopup.lua reads at file scope
@@ -660,6 +660,8 @@ pub(super) const BAG_UI: &[&str] = &[
"Interface\\FrameXML\\UIDropDownMenu.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
// `CharacterFrame.xml`'s tab template, for the hidden parent `PaperDollFrame` needs below.
r"Interface\FrameXML\CharacterFrameTemplates.xml",
// The dialog engine, after the `UIPanelCloseButton` it inherits.
r"Interface\FrameXML\StaticPopup.xml",
// `ContainerFrameItemButton_OnClick` hides `StackSplitFrame` on every plain click
@@ -683,7 +685,9 @@ pub(super) const BAG_UI: &[&str] = &[
"Interface\\FrameXML\\ItemButtonTemplate.xml",
// `BagSlotButtonTemplate` inherits `PaperDollItemSlotButtonTemplate`, and its OnLoad
// (`PaperDollItemSlotButton_OnLoad`) gives each bag button its inventory-slot id, 20..23.
// `CharacterFrame.xml` stays out: a missing `parent=` only warns.
// Its hidden parent `CharacterFrame` comes first: a parentless `PaperDollFrame` would be
// visible at the end of its load and run its OnShow, which reaches for the Honor tab.
"Interface\\FrameXML\\CharacterFrame.xml",
"Interface\\FrameXML\\PaperDollFrame.xml",
// The stock bag bar, with `BagSlotButtonTemplate` and `KEYRING_CONTAINER`.
"Interface\\FrameXML\\MainMenuBarBagButtons.xml",
@@ -0,0 +1,272 @@
//! The UI scale through the real UI pass: `uiScale` is `UIParent`'s own frame scale (`0x494550`
//! calls `0x76ac10` on the frame named `"UIParent"`), and the screen root under it keeps scale 1
//! and is 768 units tall, so a frame outside `UIParent` is not scaled by it.
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use benilla_ui::script::UiScript;
use super::extract::{paint_script, tick_script};
use crate::portrait::PortraitImages;
use crate::ui_pass::{UiQuad, UiQuads};
/// A `UIParent` filling the root with a red 100-unit square inside it, 10 units in from its top
/// left, and a green 100-unit square at the root's bottom left with no parent.
const TREE: &str = r#"
local ui = CreateFrame("Frame", "UIParent")
ui:SetPoint("BOTTOMLEFT", 0, 0)
ui:SetPoint("TOPRIGHT", 0, 0)
local inside = CreateFrame("Frame", "Inside", UIParent)
inside:SetPoint("TOPLEFT", 10, -10)
inside:SetWidth(100); inside:SetHeight(100)
local red = inside:CreateTexture(nil, "ARTWORK")
red:SetTexture(1, 0, 0)
red:SetAllPoints()
local outside = CreateFrame("Frame", "Outside")
outside:SetPoint("BOTTOMLEFT", 0, 0)
outside:SetWidth(100); outside:SetHeight(100)
local green = outside:CreateTexture(nil, "ARTWORK")
green:SetTexture(0, 1, 0)
green:SetAllPoints()
"#;
/// Both halves of the UI pass over [`TREE`] on a `w`×`h` window, the dial at `ui_scale`.
fn app(w: u32, h: u32, ui_scale: f32) -> App {
let script = UiScript::new().unwrap();
script.run(TREE).unwrap();
let mut app = App::new();
app.insert_non_send_resource(script);
app.init_resource::<UiQuads>();
app.init_resource::<super::UiPassState>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<super::UiFrameCost>();
app.init_resource::<super::UiCostWanted>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
app.init_resource::<super::UiClock>();
app.insert_resource(super::UiScaleCvar(ui_scale));
app.world_mut().spawn((
Window {
resolution: UVec2::new(w, h).into(),
..default()
},
PrimaryWindow,
));
app.add_systems(Update, (tick_script, paint_script).chain());
app.update();
app
}
fn eval(app: &App, chunk: &str) -> f64 {
app.world()
.non_send_resource::<UiScript>()
.eval::<f64>(chunk)
.unwrap()
}
/// The window-px rect of the quad tinted `rgb`, y-down.
fn quad(app: &App, rgb: [f32; 3]) -> Rect {
let quads = &app.world().resource::<UiQuads>().quads;
let q: &UiQuad = quads
.iter()
.find(|q| q.color[..3] == rgb)
.expect("the marker quad extracted");
q.rect
}
fn close(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-3
}
fn rect_close(got: Rect, want: Rect) -> bool {
close(got.min.x, want.min.x)
&& close(got.min.y, want.min.y)
&& close(got.max.x, want.max.x)
&& close(got.max.y, want.max.y)
}
/// `UIParent:GetScale()` and `GetEffectiveScale()` answer the UI scale: it is `UIParent`'s own
/// scale, set through the worker Lua's `SetScale` reaches (`0x494550` → `0x76ac10`).
#[test]
fn uiparent_answers_the_ui_scale() {
let app = app(1024, 768, 0.9);
assert!(close(eval(&app, "return UIParent:GetScale()") as f32, 0.9));
assert!(close(
eval(&app, "return UIParent:GetEffectiveScale()") as f32,
0.9
));
assert!(close(
eval(&app, "return Inside:GetEffectiveScale()") as f32,
0.9
));
assert_eq!(eval(&app, "return Outside:GetEffectiveScale()"), 1.0);
}
/// `GetScreenWidth`/`GetScreenHeight` (`0x48b480`/`0x48b4d0`) divide the 768-tall root by
/// `UIParent`'s scale (`0x494590`), so they answer in `UIParent`'s units.
#[test]
fn the_screen_size_answers_in_uiparent_units() {
let app = app(1024, 768, 0.9);
assert!(close(
eval(&app, "return GetScreenHeight()") as f32,
768.0 / 0.9
));
assert!(close(
eval(&app, "return GetScreenWidth()") as f32,
1024.0 / 0.9
));
}
/// A frame with no parent hangs off the root, which keeps scale 1: one unit is `windowH/768` px
/// whatever the dial says.
#[test]
fn a_parentless_frame_is_not_scaled_by_the_ui_scale() {
let app = app(1280, 1024, 0.9);
let s = 1024.0 / 768.0;
let got = quad(&app, [0.0, 1.0, 0.0]);
assert!(
rect_close(got, Rect::new(0.0, 1024.0 - 100.0 * s, 100.0 * s, 1024.0)),
"got {got:?}"
);
}
/// Inside `UIParent` nothing moves: the dial scales the subtree exactly as it scaled the seam.
#[test]
fn a_frame_inside_uiparent_lands_where_the_dial_put_it() {
let app = app(1280, 1024, 0.9);
let s = 1024.0 / 768.0 * 0.9;
let got = quad(&app, [1.0, 0.0, 0.0]);
assert!(
rect_close(got, Rect::new(10.0 * s, 10.0 * s, 110.0 * s, 110.0 * s)),
"got {got:?}"
);
}
/// Whether `frame` is `UIParent` or hangs below it.
fn under_ui_parent(s: &UiScript, frame: benilla_ui::widget::FrameHandle) -> bool {
let mut at = Some(frame);
while let Some(h) = at {
if s.frame_name(h).as_deref() == Some("UIParent") {
return true;
}
at = s.frame_parent(h);
}
false
}
/// The whole stock interface, every frame shown: scaling `UIParent` by `S` in the 768-tall root
/// must place every quad of its subtree, and every hit, exactly where enlarging the root by `1/S`
/// with `UIParent` at scale 1 placed them, the seam having been the old home of the UI scale. A
/// frame-local length the engine forgets to scale (a border, an inset, a thumb) shows up here.
#[test]
fn the_stock_interface_inside_uiparent_is_unmoved_by_where_the_scale_lives() {
let _data = benilla_formats::wow_data_or_skip!();
const S: f32 = 0.9;
let (w, h) = (768.0 * 16.0 / 9.0, 768.0);
let mut script = UiScript::new().expect("VM");
script.set_screen_size(w, h);
let failures = super::load_default_ui(&script);
assert!(
failures.is_empty(),
"default UI failed to load: {failures:?}"
);
// Every frame shown, its parents first, so the most geometry the stock files declare draws.
script
.run(
r#"
local f = EnumerateFrames()
while f do
pcall(f.Show, f)
f = EnumerateFrames(f)
end
"#,
)
.unwrap();
let _ = script.take_errors();
// The old home: a root `1/S` larger, `UIParent` at scale 1.
script.set_screen_size(w / S, h / S);
script.run("UIParent:SetScale(1)").unwrap();
script.resolve();
let old = script.extract();
let grid: Vec<(f32, f32)> = (0..=40)
.flat_map(|i| (0..=24).map(move |j| (w * i as f32 / 40.0, h * j as f32 / 24.0)))
.collect();
let old_hits: Vec<_> = grid
.iter()
.map(|&(x, y)| script.hit_test_frame(x / S, y / S))
.collect();
// The reference's: a 768-tall root, `UIParent` at scale `S`.
script.set_screen_size(w, h);
assert!(script.set_ui_scale(S));
script.resolve();
let new = script.extract();
let new_hits: Vec<_> = grid
.iter()
.map(|&(x, y)| script.hit_test_frame(x, y))
.collect();
assert_eq!(old.len(), new.len(), "the same quads draw");
let close = |a: f32, b: f32| (a - b).abs() < 0.02;
let mut moved = Vec::new();
for (a, b) in old.iter().zip(&new) {
assert_eq!(a.target, b.target, "the same paint order");
let Some(frame) = script.target_frame(b.target) else {
continue;
};
if !under_ui_parent(&script, frame) {
continue;
}
let same_rect = match (a.rect, b.rect) {
(Some(ra), Some(rb)) => {
close(ra.left * S, rb.left)
&& close(ra.right * S, rb.right)
&& close(ra.top * S, rb.top)
&& close(ra.bottom * S, rb.bottom)
}
(None, None) => true,
_ => false,
};
if !same_rect || !close(a.scale * S, b.scale) {
moved.push(format!(
"{:?} {:?}: {:?}×{S} vs {:?}",
script.target_owner_name(b.target),
std::mem::discriminant(&b.content),
a.rect,
b.rect
));
}
}
assert!(
moved.is_empty(),
"{} quads inside UIParent moved, first: {:#?}",
moved.len(),
&moved[..moved.len().min(20)]
);
let inside = |hit: Option<benilla_ui::widget::FrameHandle>| {
hit.is_none_or(|h| under_ui_parent(&script, h))
};
let mut rehit = Vec::new();
for ((&at, a), b) in grid.iter().zip(&old_hits).zip(&new_hits) {
if inside(*a) && inside(*b) && a != b {
rehit.push(format!(
"{at:?}: {:?} vs {:?}",
a.and_then(|h| script.frame_name(h)),
b.and_then(|h| script.frame_name(h))
));
}
}
assert!(
rehit.is_empty(),
"{} hits inside UIParent changed, first: {:#?}",
rehit.len(),
&rehit[..rehit.len().min(20)]
);
}
@@ -773,6 +773,28 @@ fn leaving_inside_the_deferral_window_drops_the_load_and_writes_nothing() {
// ───────────── The login one-shots wait for the in-game UI ─────────────
/// An app running the unit feed after `Startup`, with what the feed reads; the receiver is the
/// wire, kept alive beside it.
fn unit_feed_app() -> (App, crossbeam_channel::Receiver<crate::net::ClientCommand>) {
let mut app = App::new();
app.add_plugins(crate::ui_unit::UiUnitPlugin);
app.init_resource::<super::AddOnIdentity>();
app.init_resource::<crate::minimap::MinimapZoom>();
app.init_resource::<super::ReloadUiPending>();
app.init_resource::<crate::target::Selection>();
app.init_resource::<crate::names::NameCache>();
let (tx, rx) = crossbeam_channel::unbounded();
app.insert_resource(crate::net::NetCommands(tx));
app.init_resource::<crate::net::Reputations>();
app.init_resource::<crate::ui_party::GroupState>();
app.init_resource::<crate::ui_chat::ChatLog>();
app.init_resource::<crate::ui_guild::GuildState>();
app.add_message::<crate::creature_anim::SwingImpact>();
app.add_message::<crate::net::WorldEnterCascadeMessage>();
super::setup_script(app.world_mut());
(app, rx)
}
/// The unit feed's login one-shots (`PLAYER_ENTERING_WORLD`, the first `PLAYER_XP_UPDATE` and
/// `UPDATE_EXHAUSTION`) wait for the in-game UI instead of reaching a VM with no frames.
#[test]
@@ -781,22 +803,7 @@ fn the_login_one_shots_wait_for_the_in_game_ui() {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("oneshot");
let mut app = App::new();
app.add_plugins(crate::ui_unit::UiUnitPlugin);
app.init_resource::<super::AddOnIdentity>();
app.init_resource::<crate::minimap::MinimapZoom>();
app.init_resource::<super::ReloadUiPending>();
app.init_resource::<crate::target::Selection>();
app.init_resource::<crate::names::NameCache>();
let (tx, _rx) = crossbeam_channel::unbounded();
app.insert_resource(crate::net::NetCommands(tx));
app.init_resource::<crate::net::Reputations>();
app.init_resource::<crate::ui_party::GroupState>();
app.init_resource::<crate::ui_chat::ChatLog>();
app.init_resource::<crate::ui_guild::GuildState>();
app.add_message::<crate::creature_anim::SwingImpact>();
super::setup_script(app.world_mut());
let (mut app, _rx) = unit_feed_app();
// A frame registered as FrameXML registers, in the VM that exists before the entry load.
app.world()
@@ -1576,3 +1583,143 @@ fn a_login_and_a_reload_load_without_the_lost_target_sound() {
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
// ───────────── PLAYER_LOGIN fires from the world-enter cascade ─────────────
/// Records what file scope and the two cascade events see of `"player"`, in order.
const LOGIN_PROBE_LUA: &str = "\
LoginProbe = { order = \"\", fileExists = tostring(UnitExists(\"player\")),
fileLevel = UnitLevel(\"player\") }
local f = CreateFrame(\"Frame\")
f:RegisterEvent(\"PLAYER_LOGIN\")
f:RegisterEvent(\"PLAYER_ENTERING_WORLD\")
f:SetScript(\"OnEvent\", function()
if event == \"PLAYER_LOGIN\" then
LoginProbe.order = LoginProbe.order .. \"LOGIN:\" .. tostring(UnitExists(\"player\"))
.. \":\" .. UnitLevel(\"player\") .. \":\" .. UnitXP(\"player\") .. \" \"
else
LoginProbe.order = LoginProbe.order .. \"PEW \"
end
end)
";
const LOGIN_PROBE_TOC: &str = "## Interface: 11200\nLoginProbe.lua\n";
/// Our own player as its create lands: level 17, 1234 of 5000 XP.
fn spawn_self_player(world: &mut World) -> Entity {
use benilla_protocol::messages::{ObjectFields, ObjectType};
// `PLAYER_XP` and `PLAYER_NEXT_LEVEL_XP`, 716 and 717 in the 1.12 descriptor.
let fields = ObjectFields::from_pairs(&[
(benilla_protocol::field::FIELD_UNIT_LEVEL, 17),
(716, 1234),
(717, 5000),
])
.into_created(ObjectType::Player);
world
.spawn((
crate::net::SelfPlayer,
crate::net::Guid(1),
crate::net::ObjectStore(fields),
))
.id()
}
/// A probe global off the live VM, as a string.
fn login_probe(app: &App, field: &str) -> String {
app.world()
.non_send_resource::<benilla_ui::script::UiScript>()
.eval::<Option<String>>(&format!("return tostring(LoginProbe.{field})"))
.ok()
.flatten()
.unwrap_or_default()
}
/// `PLAYER_LOGIN` has one fire site, `0x490959` in the world-enter cascade `0x4908c0`, which the
/// UI load only arms (`0x49011d`). A fresh login skips the load's own entry (`0x490166 je`) and
/// runs the cascade from the player's create (`0x5deb60`), so its handlers see the player, and
/// `PLAYER_ENTERING_WORLD` follows at once (`0x49096a`). A worldport loads no UI, so its cascade
/// finds the arm spent and fires 272 alone.
#[test]
fn player_login_fires_from_the_world_entry_with_the_player_seated() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_addon("login", "LoginProbe", LOGIN_PROBE_TOC, LOGIN_PROBE_LUA);
let (mut app, _rx) = unit_feed_app();
app.insert_resource(State::new(crate::char_select::ClientState::InWorld));
app.insert_resource(roster_named("Probelogin", 1));
super::load_ingame_ui_on_world_entry(app.world_mut());
assert_eq!(
login_probe(&app, "fileExists"),
"nil",
"a fresh login's file scope runs before the player object exists"
);
assert_eq!(
login_probe(&app, "order"),
"",
"the load arms PLAYER_LOGIN; nothing fires it before the player is in the world"
);
let me = spawn_self_player(app.world_mut());
app.update();
assert_eq!(
login_probe(&app, "order"),
"LOGIN:1:17:1234 PEW ",
"PLAYER_LOGIN sees the player's own unit, then PLAYER_ENTERING_WORLD fires at once"
);
app.update();
assert_eq!(login_probe(&app, "order"), "LOGIN:1:17:1234 PEW ");
// A cross-map worldport: the player object is destroyed and created again.
app.world_mut().despawn(me);
app.update();
spawn_self_player(app.world_mut());
app.update();
assert_eq!(
login_probe(&app, "order"),
"LOGIN:1:17:1234 PEW PEW ",
"a worldport re-fires PLAYER_ENTERING_WORLD and not PLAYER_LOGIN"
);
drop(app);
let _ = std::fs::remove_dir_all(&tmp);
}
/// On a `/reload` the player object stays in the world: `UI_Init` runs its file scope against it
/// and enters the cascade itself (`0x490168`), so `PLAYER_LOGIN` then `PLAYER_ENTERING_WORLD` fire
/// once each on the new VM, both seeing the player.
#[test]
fn a_reload_fires_player_login_once_against_the_live_player() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_addon("relogin", "LoginProbe", LOGIN_PROBE_TOC, LOGIN_PROBE_LUA);
let (mut app, _rx) = unit_feed_app();
app.insert_resource(State::new(crate::char_select::ClientState::InWorld));
app.insert_resource(roster_named("Probelogin", 1));
super::load_ingame_ui_on_world_entry(app.world_mut());
spawn_self_player(app.world_mut());
app.update();
assert_eq!(login_probe(&app, "order"), "LOGIN:1:17:1234 PEW ");
reload(app.world_mut(), crate::char_select::ClientState::InWorld);
assert_eq!(
(
login_probe(&app, "fileExists"),
login_probe(&app, "fileLevel")
),
("1".to_string(), "17".to_string()),
"the reloaded file scope runs against the live player"
);
app.update();
app.update();
assert_eq!(
login_probe(&app, "order"),
"LOGIN:1:17:1234 PEW ",
"the new VM gets PLAYER_LOGIN then PLAYER_ENTERING_WORLD, once each"
);
drop(app);
let _ = std::fs::remove_dir_all(&tmp);
}
@@ -3,7 +3,7 @@
//! marker, [`crate::poi_marker`]), the unit blips, the player arrow and the full-screen quads.
use benilla_ui::script::{
BattlefieldFlagView, BattlefieldPositionView, QuadContent, UiScript, WorldMapLandmarkView,
BattlefieldFlagView, BattlefieldPositionView, QuadContent, UiScript, WorldMapLandmarkSource,
ARROW_MODEL,
};
@@ -34,6 +34,8 @@ fn harness() -> UiScript {
super::test_ui::load_ui(&s, file);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// A world position here is already map UV, so a landmark source sits where it says.
s.set_world_loc_projector(Box::new(|_, _, x, y| Some((x, y))));
s
}
@@ -60,12 +62,14 @@ fn update(s: &mut UiScript) {
.unwrap();
}
fn landmark(name: &str, icon: u32, uv: (f32, f32)) -> WorldMapLandmarkView {
WorldMapLandmarkView {
/// A landmark at map UV `uv` on every level, through [`harness`]'s identity projection.
fn landmark(name: &str, icon: u32, uv: (f32, f32)) -> WorldMapLandmarkSource {
WorldMapLandmarkSource {
name: name.into(),
description: String::new(),
texture_index: icon,
uv,
map: 0,
pos: uv,
icons: [Some(icon); 3],
}
}
@@ -75,7 +79,7 @@ fn landmark(name: &str, icon: u32, uv: (f32, f32)) -> WorldMapLandmarkView {
fn a_landmark_draws_its_poi_icon_at_its_map_position() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.set_world_map_landmarks(vec![landmark("Stormwind Warrior Trainer", 6, (0.25, 0.5))]);
s.set_world_map_landmark_sources(vec![landmark("Stormwind Warrior Trainer", 6, (0.25, 0.5))]);
s.run("WorldMapFrame_Update()").unwrap();
assert_eq!(
@@ -115,7 +119,7 @@ fn a_landmark_draws_its_poi_icon_at_its_map_position() {
fn the_poi_pool_grows_and_parks_its_tail() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.set_world_map_landmarks(vec![
s.set_world_map_landmark_sources(vec![
landmark("The Bank", 6, (0.1, 0.1)),
landmark("The Inn", 6, (0.2, 0.2)),
landmark("The Auction House", 6, (0.3, 0.3)),
@@ -126,7 +130,7 @@ fn the_poi_pool_grows_and_parks_its_tail() {
.eval::<bool>("return WorldMapFramePOI3:IsShown()")
.unwrap());
s.set_world_map_landmarks(vec![landmark("The Inn", 6, (0.2, 0.2))]);
s.set_world_map_landmark_sources(vec![landmark("The Inn", 6, (0.2, 0.2))]);
s.run("WorldMapFrame_Update()").unwrap();
assert_eq!(
s.eval::<i64>("return NUM_WORLDMAP_POIS").unwrap(),
@@ -158,7 +162,7 @@ fn the_poi_pool_grows_and_parks_its_tail() {
fn hovering_a_poi_names_it_and_adds_a_status_line_only_when_there_is_one() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.set_world_map_landmarks(vec![landmark("Lion's Pride Inn", 6, (0.5, 0.5))]);
s.set_world_map_landmark_sources(vec![landmark("Lion's Pride Inn", 6, (0.5, 0.5))]);
s.run("WorldMapFrame_Update()").unwrap();
s.run("this = WorldMapFramePOI1 this:GetScript(\"OnEnter\")() this = nil")
.unwrap();
@@ -178,7 +182,7 @@ fn hovering_a_poi_names_it_and_adds_a_status_line_only_when_there_is_one() {
let mut with_status = landmark("Stables", 6, (0.5, 0.5));
with_status.description = "In Conflict".into();
s.set_world_map_landmarks(vec![with_status]);
s.set_world_map_landmark_sources(vec![with_status]);
s.run("WorldMapFrame_Update()").unwrap();
s.run("this = WorldMapFramePOI1 this:GetScript(\"OnEnter\")() this = nil")
.unwrap();
@@ -197,7 +201,7 @@ fn the_landmark_getter_returns_the_references_five_values() {
let mut s = harness();
let mut with_status = landmark("Stables", 9, (0.25, 0.75));
with_status.description = "In Conflict".into();
s.set_world_map_landmarks(vec![landmark("Woo Ping", 6, (0.5, 0.5)), with_status]);
s.set_world_map_landmark_sources(vec![landmark("Woo Ping", 6, (0.5, 0.5)), with_status]);
let (name, desc_is_nil, icon, x, y): (String, bool, i64, f32, f32) = s
.eval("local n, d, t, x, y = GetMapLandmarkInfo(1) return n, d == nil, t, x, y")
@@ -859,3 +863,66 @@ fn the_fullscreen_quads_follow_a_resize() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Reopening the map after zooming out shows the player's zone from its first paint. The OnShow
/// calls `SetMapToCurrentZone()` (WorldMapFrame.xml:600), whose setter `0x4a67a0` fires
/// `WORLD_MAP_UPDATE` before it returns (`0x4a6ce4`, `SignalEvent` dispatching in place), so the
/// stock `WorldMapFrame_Update` sets the zone's tiles inside the show, before any tick or paint.
#[test]
fn a_reopened_map_shows_the_players_zone_before_its_first_paint() {
use benilla_ui::script::{WorldMapContinentView, WorldMapZoneView};
let _data = benilla_formats::wow_data_or_skip!();
// The whole stock UI: the OnShow's `UpdateMicroButtons` and the full-screen panel slot.
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Probefour".into()),
level: 60,
..Default::default()
}),
);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
s.set_world_map_catalog(vec![WorldMapContinentView {
name: "Eastern Kingdoms".into(),
map_file: "Azeroth".into(),
zones: vec![WorldMapZoneView {
name: "Elwynn Forest".into(),
area_id: 12,
map_file: "Elwynn".into(),
..Default::default()
}],
..Default::default()
}]);
// The player stands in Elwynn Forest.
s.set_world_map_feed(Some((1, 1)), None, 0.0, None, Vec::new(), Vec::new());
s.tick(0.01);
let tile = |s: &UiScript| {
s.eval::<String>("return WorldMapDetailTile1:GetTexture()")
.unwrap()
};
s.run("ToggleWorldMap()").unwrap();
s.tick(0.01);
assert!(s.eval::<bool>("return WorldMapFrame:IsVisible()").unwrap());
assert_eq!(tile(&s), "Interface\\WorldMap\\Elwynn\\Elwynn1");
// A right-click on the map zooms out to the continent (WorldMapFrame.lua:294).
s.run("WorldMapZoomOutButton_OnClick()").unwrap();
s.tick(0.01);
assert_eq!(tile(&s), "Interface\\WorldMap\\Azeroth\\Azeroth1");
s.run("ToggleWorldMap()").unwrap();
s.tick(0.01);
assert!(!s.eval::<bool>("return WorldMapFrame:IsVisible()").unwrap());
s.run("ToggleWorldMap()").unwrap();
assert!(s.eval::<bool>("return WorldMapFrame:IsVisible()").unwrap());
assert_eq!(
tile(&s),
"Interface\\WorldMap\\Elwynn\\Elwynn1",
"the reopened map paints its first frame with the continent it was closed on"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -273,6 +273,27 @@ fn room_to_room_hop_splashes_the_room_name_alone() {
assert_eq!(text_of(&s, "SubZoneTextString"), "Library Wing");
}
/// A nil pvpType, a PvE realm outside a capital: no territory line, and the zone and subzone in
/// the stock else-arm's cream (`ZoneText.xml:32-33`), not a faction colour.
#[test]
fn a_nil_pvp_type_splashes_uncoloured_with_no_territory_line() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
set_area(&mut s, "Elwynn Forest", "Goldshire", "", "");
s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]);
assert!(visible(&s, "ZoneTextFrame"));
assert_eq!(text_of(&s, "PVPInfoTextString"), "");
for region in ["ZoneTextString", "SubZoneTextString"] {
let (r, g, b) = s
.eval::<(f32, f32, f32)>(&format!("return {region}:GetTextColor()"))
.unwrap();
assert!(
(r - 1.0).abs() < 1e-3 && (g - 0.9294).abs() < 1e-3 && (b - 0.7607).abs() < 1e-3,
"{region} is ({r}, {g}, {b})"
);
}
}
/// An FFA pit: `GetZonePVPInfo`'s isArena, the leaf area's flag `0x80`.
#[test]
fn arena_pit_shows_the_ffa_line() {
+6 -5
View File
@@ -52,11 +52,12 @@ pub(crate) fn fontstring_em(height: Option<f32>) -> Option<f32> {
height.map(|h| h.min(FONTSTRING_EM_CAP))
}
/// The drawn pixel height of a UI FontString times the seam scale `s = windowH/768 × uiScale`
/// (`crate::ui_script::seam_scale`), under the reference's two size regimes (getter `0x7727b0`):
/// one-to-one, the default, draws the raster size ([`FONTSTRING_EM_CAP`] applied in units), and a
/// `SetTextHeight` size (`text_height`) draws uncapped, the reference magnifying the raster to it
/// (`0x771600` clears bit `0x200`; the drawn em is `round(size/768·deviceH)`).
/// The drawn pixel height of a UI FontString at `s`, the seam scale `windowH/768`
/// (`crate::ui_script::seam_scale`) times the owner's effective scale, the UI scale included,
/// under the reference's two size regimes (getter `0x7727b0`): one-to-one, the default, draws the
/// raster size ([`FONTSTRING_EM_CAP`] applied in units), and a `SetTextHeight` size
/// (`text_height`) draws uncapped, the reference magnifying the raster to it (`0x771600` clears bit
/// `0x200`; the drawn em is `round(size/768·deviceH)`).
pub(crate) fn drawn_px(font_height: Option<f32>, text_height: Option<f32>, s: f32) -> Option<f32> {
match text_height {
Some(t) => Some(t * s),
@@ -14,23 +14,25 @@ use benilla_ui::script::SpellTooltipView;
use super::spell_deps::{Changes, Deps, RangeSeen, Reagents, Seen, UnitField};
use super::spell_feed::{build_view, reagent_state, PetInputs, ViewCaster, ViewCtx};
use super::tests::{real_spells, TestCtx};
use super::tests::TestCtx;
use crate::items::Items;
use crate::net::{NetCommands, ObjectStore, Objects};
use crate::spell::usable::{
slot_item_cached, worn_slots_read, SlotItem, EQUIPMENT_MASK, EQUIPMENT_SLOTS,
};
use crate::spell::{ModsDiff, SpellModifiers};
use crate::ui_action::Spells;
use crate::ui_action::{real_spells, Spells};
use crate::ui_items::TestObjects;
use benilla_formats::{RangeUnit, UnitMotion};
/// One side of each input group, so that moving an input is an xor: `false` is the empty side (no
/// bind point, form 0, no percentages, default reach, no auto-attack target, both units standing,
/// nothing worn, no skills, zero cells), `true` the populated one.
/// bind point, gender 0, form 0, no percentages, default reach, no auto-attack target, both units
/// standing, nothing worn, no skills, zero cells), `true` the populated one.
#[derive(Clone, Copy, Default, PartialEq, Eq, Hash, Debug)]
struct Inputs {
home: bool,
/// The player's gender byte, `UNIT_FIELD_BYTES_0` byte 2.
gender: bool,
form: bool,
/// A `Chance` bit each: block, dodge, parry, crit.
avoid: u8,
@@ -60,6 +62,7 @@ const POKE: u32 = 0x4020_0000;
impl Inputs {
const ALL: Inputs = Inputs {
home: true,
gender: true,
form: true,
avoid: 0xf,
reach: true,
@@ -76,6 +79,7 @@ impl Inputs {
fn xor(self, o: Inputs) -> Inputs {
Inputs {
home: self.home ^ o.home,
gender: self.gender ^ o.gender,
form: self.form ^ o.form,
avoid: self.avoid ^ o.avoid,
reach: self.reach ^ o.reach,
@@ -103,6 +107,15 @@ impl Inputs {
},
));
}
if self.gender {
parts.push((
"the gender byte".to_string(),
Inputs {
gender: true,
..alone
},
));
}
if self.form {
parts.push((
"the form".to_string(),
@@ -207,6 +220,7 @@ impl Inputs {
fn outside(deps: &Deps, layout: &Layout) -> Inputs {
Inputs {
home: !deps.home,
gender: !deps.gender,
form: !deps.form,
avoid: !deps.avoidance & 0xf,
reach: !deps.range_units,
@@ -324,6 +338,9 @@ fn mat(layout: &Layout, inputs: Inputs, p: Params) -> Mat {
if inputs.disarm {
pairs.push((46, 0x0020_0000));
}
if inputs.gender {
pairs.push((36, 1 << 16));
}
let form = if inputs.form { p.form } else { 0 };
if form != 0 {
pairs.push((138, u32::from(form) << 16));
@@ -666,6 +683,19 @@ fn every_view_that_moves_with_an_input_is_requeued_by_that_input() {
assert_covered("the bind point", &s);
assert!(s.moved >= 1, "no view reads the bind point");
row("home bind ($z)", &s);
let s = sweep(
&mut rig,
empty,
&any,
&own,
&cause(Inputs {
gender: true,
..empty
}),
);
assert_covered("the gender byte", &s);
assert!(s.moved >= 50, "only {} views read the gender byte", s.moved);
row("gender byte ($g)", &s);
let s = sweep(
&mut rig,
empty,
@@ -1337,7 +1367,7 @@ fn the_modifier_diff_names_the_family_flag_bit_of_a_changed_cell() {
}
/// A percentage's own bit, a worn slot's own bit, a skill line by its id, each unit field by its
/// own bit and a player appearing, through the diff the feed runs each frame.
/// own bit, the gender byte and a player appearing, through the diff the feed runs each frame.
#[test]
fn the_seen_diff_names_each_input_it_saw_move() {
let mut objs = TestObjects::new();
@@ -1443,6 +1473,14 @@ fn the_seen_diff_names_each_input_it_saw_move() {
assert!(c.disarm);
let now = player(&[(22, 100), (46, 0x0000_0008)]);
assert!(seen(&now).changes_since(&seen(&base)).is_empty());
// The gender byte, `UNIT_FIELD_BYTES_0` byte 2, and no other byte of the field.
let bytes_0 = |bytes: u32| player(&[(22, 100), (36, bytes)]);
let c = seen(&bytes_0(1 << 16)).changes_since(&seen(&base));
assert!(c.gender && !c.is_empty());
let race_class_power = bytes_0(4 | 1 << 8 | 1 << 24);
assert!(seen(&race_class_power)
.changes_since(&seen(&base))
.is_empty());
// Each unit field on its own bit: level 34, base mana 162, base health 163, ranged attack
// time 128; max health 28 and the max pools 29-33 on none, which no cost reads.
let unit = [
+5 -5
View File
@@ -114,8 +114,6 @@ fn drive_mouseover_tooltip(
go_inputs: crate::target::lock::GoLockInputs,
// The known-spell set the resolver's SKILL arm scans.
player_actions: Res<crate::ui_action::PlayerActions>,
// The cursor seat crosses the VM seam: the anchor below is UI units, not px.
ui_scale: Res<crate::ui_script::UiScaleCvar>,
mut memo: HoverMemo,
// `ChrClasses.dbc` field 16, `UnitHasRelicSlot`'s input, and the form table the creature type
// reads.
@@ -229,15 +227,17 @@ fn drive_mouseover_tooltip(
.templates
.get(guid)
.is_some_and(|t| t.floating_tooltip);
// Window px to the VM's y-up UI units, as the input seam converts.
// Window px to the VM's y-up root units, as the input seam converts.
let cursor_ui = cursor_seated
.then(|| {
window.iter().next().and_then(|w| {
let s = crate::ui_script::seam_scale(w.height(), ui_scale.0);
// The hover probe's aim stands in for a missing cursor; a real pointer wins.
w.cursor_position()
.or_else(crate::target::hover_probe_point)
.map(|c| (c.x / s, (w.height() - c.y) / s))
.map(|c| {
let at = crate::ui_script::window_to_ui(w.height(), c);
(at.x, at.y)
})
})
})
.flatten();
@@ -109,6 +109,8 @@ impl UnitFields {
pub(super) struct Deps {
/// A `$z` expanded: the bind point.
pub(super) home: bool,
/// A `$g`/`$G` expanded: the branch read the active player's gender byte (`0x508214`).
pub(super) gender: bool,
/// The required-form test read the shapeshift form.
pub(super) form: bool,
/// The range cell's melee arm read the caster's and the auto-attack target's combat reach and
@@ -169,6 +171,7 @@ impl Deps {
pub(super) fn hits(&self, changes: &Changes) -> bool {
changes.everything
|| (changes.home && self.home)
|| (changes.gender && self.gender)
|| (changes.form && self.form)
|| (changes.range_units && self.range_units)
|| changes.avoidance & self.avoidance != 0
@@ -193,6 +196,7 @@ pub(super) struct Changes {
/// An input no view can be ruled out of: every view is due.
pub(super) everything: bool,
pub(super) home: bool,
pub(super) gender: bool,
pub(super) form: bool,
pub(super) range_units: bool,
/// A [`Chance`] bit each.
@@ -212,6 +216,7 @@ impl Changes {
pub(super) fn is_empty(&self) -> bool {
!self.everything
&& !self.home
&& !self.gender
&& !self.form
&& !self.range_units
&& self.avoidance == 0
@@ -274,6 +279,8 @@ impl RangeSeen {
pub(super) struct Seen {
/// The bind point `$z` names.
home: Option<String>,
/// The gender byte `$g`/`$G` branch on (`0x508214`), 0 without a player as the views read it.
gender: u8,
/// The form the required-form line's colour follows (`0x52f1e3`).
form: u8,
/// The skills the descriptions' per-level terms scale by ([`crate::spell::skill_snapshot`]).
@@ -301,6 +308,7 @@ impl Seen {
) -> Self {
Seen {
home: home.map(str::to_string),
gender: player.and_then(|s| s.0.unit_gender()).unwrap_or(0),
form: player.map_or(0, |s| s.0.unit_shapeshift_form()),
skills: crate::spell::skill_snapshot(player),
worn: player
@@ -317,6 +325,7 @@ impl Seen {
pub(super) fn changes_since(&self, prev: &Seen) -> Changes {
let mut changes = Changes {
home: self.home != prev.home,
gender: self.gender != prev.gender,
form: self.form != prev.form,
range_units: self.range_units != prev.range_units,
disarm: self.disarmed != prev.disarmed,
@@ -31,17 +31,6 @@ pub(super) enum ViewCaster<'a> {
Pet(Option<&'a benilla_protocol::ObjectFields>),
}
impl ViewCaster<'_> {
/// The pet's `[vtbl+0xa8]`, `CGUnit`'s `0x60cd80`: `UNIT_FIELD_LEVEL × 5`, which
/// [`benilla_formats::SpellDisplay::skill_level`] caps at `maxLevel × 5` and divides by 5 as
/// `0x60cdb2`-`0x60cdb9` and `0x6e3195` do. No unit reads 0 (`0x6e31a4`).
fn pet_skill_value(pet: Option<&benilla_protocol::ObjectFields>) -> u32 {
pet.and_then(|p| p.unit_level())
.unwrap_or(0)
.saturating_mul(5)
}
}
/// The caster-dependent inputs of a spell view: the worn set (`0x5f0c50`), the bags, the form and
/// the bind point `$z` names.
pub(super) struct ViewCtx<'a, 'w, 's> {
@@ -99,7 +88,7 @@ pub(super) fn build_view(
line.map_or(0, |line| crate::spell::skill_line_value(vctx.store, line))
}
// The pet's level is one of the pet's own inputs.
ViewCaster::Pet(pet) => ViewCaster::pet_skill_value(pet),
ViewCaster::Pet(pet) => crate::spell::pet_skill_value(pet),
};
// A cross-referenced spell's own family mask decides its modifiers.
let lookup = |id| {
@@ -112,8 +101,13 @@ pub(super) fn build_view(
deps.borrow_mut().home = true;
home
};
// `$g`/`$G` read the active player's gender byte (`0x508214`), a pet view's too.
let gender = || {
deps.borrow_mut().gender = true;
vctx.store.and_then(|s| s.0.unit_gender()).unwrap_or(0)
};
// `0x6e3130` for this spell: the level the cost's and the cast time's per-level terms read.
let pet_level = |pet| d.skill_level(ViewCaster::pet_skill_value(pet));
let pet_level = |pet| d.skill_level(crate::spell::pet_skill_value(pet));
let ctx = benilla_formats::TokenContext {
durations: &spells.durations,
radii: &spells.radii,
@@ -122,6 +116,7 @@ pub(super) fn build_view(
lookup: &lookup,
mods: Some(vctx.spell_mods),
unmodified_points: false,
gender: &gender,
home_area: &home_area,
global: vctx.get,
printf: &crate::ui_script::token_printf,
+46 -16
View File
@@ -4,7 +4,7 @@ use super::spell_feed::{build_view, feed_spell_tooltips, ViewCaster, ViewCtx};
use super::*;
use crate::items::Items;
use crate::net::{NetCommands, ObjectStore, Objects, SelfPlayer};
use crate::ui_action::{PlayerActions, Spells};
use crate::ui_action::{real_spells, PlayerActions, Spells};
/// The view alone, for the cell tests; the feed keeps what it read beside it.
fn spell_tooltip_view(
@@ -105,20 +105,6 @@ impl TestCtx {
}
}
/// The 5875 spell data the view builder reads; `None` skips where the install is absent.
pub(super) fn real_spells() -> Option<Spells> {
let data = benilla_formats::wow_data_or_skip!(None);
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
Some(Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
forms: benilla_formats::load_shapeshift_forms(&mut chain).expect("forms"),
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("ranges"),
cast_times: benilla_formats::load_spell_cast_times(&mut chain).expect("cast times"),
durations: benilla_formats::load_spell_durations(&mut chain).expect("durations"),
radii: benilla_formats::load_spell_radii(&mut chain).expect("radii"),
})
}
/// A unit's fields: level (34) and base mana (162).
fn unit(level: u32, base_mana: u32) -> benilla_protocol::ObjectFields {
benilla_protocol::ObjectFields::from_pairs(&[(22u16, 100u32), (34, level), (162, base_mana)])
@@ -220,6 +206,51 @@ fn a_pet_view_costs_seduction_from_the_pets_base_mana() {
assert_eq!(v.cost, None);
}
/// The `$g`/`$G` branch (`0x508180`) reads the active player's `UNIT_FIELD_BYTES_0` byte 2
/// (`0x508214`): the first form on 0, the second on anything else. A female player's Conjure Food
/// and Water (587, 5504) tooltips read "her allies" and Hellfire (1949) "to herself".
#[test]
fn a_female_players_spell_tooltips_take_the_second_gender_form() {
let Some(spells) = real_spells() else { return };
let mut t = TestCtx::new();
let mut objs = no_objects();
let objects = objs.get();
// A level 60 player whose `UNIT_FIELD_BYTES_0` (36) holds `gender` in byte 2.
let player = |gender: u32| {
let mut fields = unit(60, 1373);
fields.merge(benilla_protocol::ObjectFields::from_pairs(&[(
36,
gender << 16,
)]));
ObjectStore(fields)
};
let (her, him) = (player(1), player(0));
let mut text = |id: u32, store: &ObjectStore| {
spell_tooltip_view(id, &spells, &mut t.ctx_for(&objects, 0, None, Some(store)))
.unwrap_or_else(|| panic!("spell {id} view"))
.description
};
for id in [587u32, 5504] {
let female = text(id, &her);
assert!(female.contains("the mage and her allies"), "{id}: {female}");
let male = text(id, &him);
assert!(male.contains("the mage and his allies"), "{id}: {male}");
}
let hellfire = text(1949, &her);
assert!(hellfire.contains("Fire damage to herself"), "{hellfire}");
// The branch looks the active player up (`0x508189`-`0x5081a4`): a pet view reads the
// player's gender, never the pet's.
let pet = unit(20, 0);
let pets = spell_tooltip_view(
587,
&spells,
&mut t.pet_ctx(&objects, Some(&her), Some(&pet)),
)
.expect("Conjure Food view")
.description;
assert!(pets.contains("the mage and her allies"), "{pets}");
}
/// An object index with nothing streamed: no worn item, and every reagent count 0.
fn no_objects() -> crate::ui_items::TestObjects {
crate::ui_items::TestObjects::new()
@@ -851,7 +882,6 @@ fn mouseover_app() -> (App, Entity, Entity) {
let mut app = App::new();
let (tx, _rx) = crossbeam_channel::unbounded();
app.insert_resource(NetCommands(tx))
.insert_resource(crate::ui_script::UiScaleCvar(1.0))
.init_resource::<Hovered>()
.init_resource::<HoveredObject>()
.init_resource::<NameCache>()
+75 -3
View File
@@ -4,10 +4,11 @@
//! not unify them.
use benilla_formats::{
SkillLineCatalog, SpellCatalog, SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM,
SPELL_EFFECT_LEARN_PET_SPELL, SPELL_EFFECT_LEARN_SPELL, SPELL_EFFECT_SKILL_STEP,
substitute, SkillLineCatalog, SpellCatalog, TokenContext, SPELL_ATTR_IS_TRADESKILL,
SPELL_EFFECT_CREATE_ITEM, SPELL_EFFECT_LEARN_PET_SPELL, SPELL_EFFECT_LEARN_SPELL,
SPELL_EFFECT_SKILL_STEP,
};
use benilla_protocol::messages::trainer_spell_state;
use benilla_protocol::messages::{trainer_spell_state, TrainerSpell};
use benilla_ui::script::{TrainerServiceCategory, TrainerTooltip, TRAINER_GROUP_KNOWN};
use crate::entities::ItemDisplays;
@@ -92,6 +93,77 @@ pub(super) fn service_tooltip(wire_spell: u32, spells: &SpellCatalog) -> Trainer
wire_only
}
/// The `$`-token contexts a service description expands over, picked per row by the caster the
/// expander's level terms read (`0x6e3130`'s selector): the active player's, or the pet's, which
/// a `LEARN_PET_SPELL` wrapper selects.
pub(super) struct ServiceText<'a> {
pub(super) player: &'a TokenContext<'a>,
pub(super) pet: &'a TokenContext<'a>,
}
/// `GetTrainerServiceDescription 0x4d9b40`: the text the stock window shows under the service's
/// cost — `Spell.dbc`'s Description column with its `$`-tokens expanded (`0x5075f0` at
/// `0x4d9d6f`). Which record is expanded depends on the row: the wire spell's own text when it
/// has one (`0x4d9c32`), else the spell the first learn effect teaches — `EffectTriggerSpell` of
/// the first `LEARN_SPELL`/`LEARN_PET_SPELL` slot, whether or not its trigger resolves
/// (`0x4d9bdd`-`0x4d9c18`) — with that spell's own text, else, for a tradeskill recipe, the
/// description of the item the taught spell creates (`0x4d9cd0`-`0x4d9d22`, returned verbatim,
/// never expanded). A used row at a mount trainer skips its own text (`0x4d9c29`), and every
/// failure falls back to the wire text again (`0x4d9d3b`, probing slot 0 alone for the caster).
/// The expansion itself is the spell tooltip's description call (`0x52f717`): level override 0,
/// the Description column, and the modifiers applied.
pub(super) fn service_description(
wire: &TrainerSpell,
trainer_type: u32,
spells: &SpellCatalog,
items: &Items,
commands: &NetCommands,
text: &ServiceText,
) -> String {
let Some(d) = spells.get(wire.spell) else {
return String::new();
};
let learn = (0..3).find(|&i| {
matches!(
d.effects[i],
SPELL_EFFECT_LEARN_SPELL | SPELL_EFFECT_LEARN_PET_SPELL
)
});
let caster = |slot: usize| {
if d.effects[slot] == SPELL_EFFECT_LEARN_PET_SPELL {
text.pet
} else {
text.player
}
};
let wire_text = d.description.as_deref().filter(|t| !t.is_empty());
if !(trainer_type == TRAINER_TYPE_MOUNT && wire.state == trainer_spell_state::GRAY) {
if let Some(t) = wire_text {
return substitute(t, d, learn.map_or(text.player, caster));
}
}
if let Some(slot) = learn {
if let Some(taught) = spells.get(d.effect_trigger_spell[slot]) {
if let Some(t) = taught.description.as_deref().filter(|t| !t.is_empty()) {
return substitute(t, taught, caster(slot));
}
// The recipe's product carries the only text a `CREATE_ITEM` spell has.
if taught.attributes & SPELL_ATTR_IS_TRADESKILL != 0
&& taught.effects[0] == SPELL_EFFECT_CREATE_ITEM
{
if let Some(t) = items
.template(taught.effect_item_type[0], 0, commands)
.map(|item| item.description.clone())
.filter(|t| !t.is_empty())
{
return t;
}
}
}
}
wire_text.map_or_else(String::new, |t| substitute(t, d, caster(0)))
}
/// The list builder `0x4d7560`'s group key (`0x4d7786`). Type 2 resolves no skill line, so no row
/// drops: 1 when the wire spell steps a skill (`0x4d77b6`), else 2, labelled from the table at
/// `0x807520`. Type 1 puts a known service in the signed `-1` group (`0x4d77e8`). The rest key on
+70 -16
View File
@@ -5,7 +5,7 @@
use std::collections::BTreeSet;
use benilla_formats::{SkillLineCatalog, SpellCatalog};
use benilla_formats::{SkillLineCatalog, SpellCatalog, TokenContext};
use benilla_protocol::messages::TrainerSpell;
use bevy::prelude::*;
@@ -163,6 +163,7 @@ fn resolve_service(
icons: Option<&ItemDisplays>,
items: &Items,
commands: &NetCommands,
text: &ServiceText,
// The VM's own `GlobalStrings.lua`, for the group header labels.
get: &dyn Fn(&str) -> Option<String>,
) -> TrainerService {
@@ -224,9 +225,7 @@ fn resolve_service(
subtext: display.and_then(|d| d.rank.clone()),
// Over the wire spell, like the name and subtext ([`service_icon`]).
texture: service_icon(wire.spell, trainer_type, spells, icons, items, commands),
// Empty: the 1.12 `GetTrainerServiceDescription` returns `Spell.dbc`'s Description with
// its `$s1`/`$o1`/`$d`/`$a1` tokens substituted, and that substitution is not built.
description: String::new(),
description: service_description(wire, trainer_type, spells, items, commands, text),
cost: wire.cost,
prof_first_rank: wire.is_primary_prof_first_rank,
category: cat,
@@ -249,6 +248,7 @@ fn snapshot(
icons: Option<&ItemDisplays>,
items: &Items,
commands: &NetCommands,
text: &ServiceText,
get: &dyn Fn(&str) -> Option<String>,
) -> Option<TrainerState> {
open.trainer?;
@@ -268,6 +268,7 @@ fn snapshot(
icons,
items,
commands,
text,
get,
)
})
@@ -288,6 +289,16 @@ pub(crate) struct ReEvalInputs<'w, 's> {
edges: MessageReader<'w, 's, FieldChanged>,
}
/// The resources a service description's `$`-tokens read beside the caster's descriptor: the
/// spell-modifier tables and the bind point `$z` names, the same two reads the item and spell
/// tooltip feeds make.
#[derive(SystemParam)]
pub(crate) struct DescriptionReads<'w> {
spell_mods: Res<'w, crate::spell::SpellModifiers>,
home_bind: Option<Res<'w, crate::net::HomeBind>>,
area_names: Option<Res<'w, crate::ui_quest_log::QuestHeaderNamesRes>>,
}
/// Push the current trainer into the VM and fire its events on a change. Another trainer while
/// open is a close then an open: `ShowUIPanel` returns early on a visible frame.
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
@@ -299,6 +310,7 @@ pub(crate) fn feed_trainer(
spells: Option<Res<Spells>>,
skill_lines: Option<Res<SkillLines>>,
mut re_eval: ReEvalInputs,
desc: DescriptionReads,
// A tradeskill row shows its created item's icon: the template cache and `ItemDisplayInfo.dbc`.
icons: Option<Res<ItemDisplays>>,
items: Res<Items>,
@@ -416,23 +428,63 @@ pub(crate) fn feed_trainer(
&player,
);
}
let fresh = snapshot(
&open,
&spells.catalog,
Some(&skill_lines.catalog),
&actions.spells,
icons.as_deref(),
&items,
&commands,
&|key: &str| {
// The spell tooltip's description reads (`0x52f717`), and `0x6e3159`'s charm-else-summon for
// a `LEARN_PET_SPELL` row.
let store = self_player.map(|(_, store)| store);
let pet = store
.and_then(|s| s.0.unit_pet_guid())
.and_then(|guid| re_eval.index.0.get(&guid).copied())
.and_then(|entity| re_eval.stores.get(entity).ok());
let fresh = {
let gender = store.and_then(|s| s.0.unit_gender()).unwrap_or(0);
let home_area: Option<&str> = desc
.home_bind
.as_deref()
.and_then(|b| b.0)
.and_then(|id| desc.area_names.as_deref()?.0.resolve(id as i32));
let player_skill =
|id: u32| crate::spell::spell_skill_value(store, Some(&skill_lines.catalog), id);
let pet_skill = |_: u32| crate::spell::pet_skill_value(pet.map(|p| &p.0));
let get = |key: &str| {
script
.lua()
.globals()
.get::<String>(key)
.ok()
.filter(|t| !t.is_empty())
},
);
};
let ctx = TokenContext {
durations: &spells.durations,
radii: &spells.radii,
ranges: Some(&spells.ranges),
skill: &player_skill,
lookup: &|id| spells.catalog.get(id),
mods: Some(&*desc.spell_mods),
unmodified_points: false,
gender: &|| gender,
home_area: &|| home_area,
global: &get,
printf: &crate::ui_script::token_printf,
};
let pet_ctx = TokenContext {
skill: &pet_skill,
..ctx
};
snapshot(
&open,
&spells.catalog,
Some(&skill_lines.catalog),
&actions.spells,
icons.as_deref(),
&items,
&commands,
&ServiceText {
player: &ctx,
pet: &pet_ctx,
},
&get,
)
};
// A spell subject's view must be in the store before the detail icon is hovered.
let mut fresh_tooltip_subjects = TrainerTooltipSubjects::default();
for service in fresh.iter().flat_map(|state| &state.services) {
@@ -528,7 +580,9 @@ fn drain_trainer(
}
mod law;
use law::{category, service_group, service_icon, service_tooltip};
use law::{
category, service_description, service_group, service_icon, service_tooltip, ServiceText,
};
#[cfg(test)]
mod tests;
+436 -21
View File
@@ -27,6 +27,43 @@ fn probe_strings(key: &str) -> Option<String> {
}
}
/// Run `f` with the description contexts over the test catalogs: the casters' skill values
/// (`level × 5` for a pet) and the bind point `$z` names.
fn with_text<R>(
spells: &SpellCatalog,
player_skill: u32,
pet_skill: u32,
home: Option<&str>,
f: impl FnOnce(&ServiceText) -> R,
) -> R {
let durations = benilla_formats::SpellDurationCatalog::default();
let radii = benilla_formats::SpellRadiusCatalog::default();
let lookup = |id: u32| spells.get(id);
let skill_of = |_: u32| player_skill;
let pet_of = |_: u32| pet_skill;
let ctx = TokenContext {
durations: &durations,
radii: &radii,
ranges: None,
skill: &skill_of,
lookup: &lookup,
mods: None,
unmodified_points: false,
gender: &|| 0,
home_area: &|| home,
global: &probe_strings,
printf: &crate::ui_script::token_printf,
};
let pet_ctx = TokenContext {
skill: &pet_of,
..ctx
};
f(&ServiceText {
player: &ctx,
pet: &pet_ctx,
})
}
/// [`resolve_service`] with no icon sources.
fn resolve_service(
wire: &TrainerSpell,
@@ -36,17 +73,20 @@ fn resolve_service(
known: &BTreeSet<u32>,
) -> TrainerService {
let deps = Deps::new();
super::resolve_service(
wire,
trainer_type,
spells,
skill_lines,
known,
None,
&deps.items,
&deps.commands,
&probe_strings,
)
with_text(spells, 0, 0, None, |text| {
super::resolve_service(
wire,
trainer_type,
spells,
skill_lines,
known,
None,
&deps.items,
&deps.commands,
text,
&probe_strings,
)
})
}
fn wire(spell: u32, state: u8, cost: u32, req_level: u8, req_skill: u32) -> TrainerSpell {
@@ -66,16 +106,19 @@ fn wire(spell: u32, state: u8, cost: u32, req_level: u8, req_skill: u32) -> Trai
/// [`snapshot`] with no icon sources.
fn snap(open: &TrainerOpen, spells: &SpellCatalog) -> Option<TrainerState> {
let deps = Deps::new();
snapshot(
open,
spells,
None,
&BTreeSet::new(),
None,
&deps.items,
&deps.commands,
&probe_strings,
)
with_text(spells, 0, 0, None, |text| {
snapshot(
open,
spells,
None,
&BTreeSet::new(),
None,
&deps.items,
&deps.commands,
text,
&probe_strings,
)
})
}
/// Wrapper 100 teaches 200 by a slot-0 `LEARN_SPELL`, 200 creates item 777, and 777's display 5
@@ -461,6 +504,378 @@ fn ability_req_shows_the_required_rank_on_real_data() {
assert_eq!(svc.ability_reqs[0].name, "Heroic Strike (Rank 1)");
}
/// `GetTrainerServiceDescription 0x4d9b40`: a class trainer's plain ability row expands the wire
/// spell's own `Spell.dbc` Description (`0x4d9c32`).
#[test]
fn service_description_expands_the_wire_spells_own_text() {
let d = SpellDisplay {
description: Some("Increases melee damage by $s1.".into()),
// A flat 11: base points are stored one under the total, on one one-sided die.
effect_base_points: [10, 0, 0],
effect_base_dice: [1, 0, 0],
effect_die_sides: [1, 0, 0],
..Default::default()
};
let spells = SpellCatalog::from_displays(HashMap::from([(78, d)]));
let deps = Deps::new();
with_text(&spells, 0, 0, None, |text| {
assert_eq!(
service_description(
&wire(78, trainer_spell_state::GREEN, 10, 1, 0),
0,
&spells,
&deps.items,
&deps.commands,
text,
),
"Increases melee damage by 11."
);
});
}
/// A learn wrapper has no text of its own, so the taught spell's Description is expanded
/// (`0x4d9c6f`); the description is the taught record's, not the wrapper's.
#[test]
fn service_description_hops_to_the_taught_spell() {
let wrapper = SpellDisplay {
effects: [SPELL_EFFECT_LEARN_SPELL, 0, 0],
effect_trigger_spell: [200, 0, 0],
..Default::default()
};
let taught = SpellDisplay {
name: "Growl".into(),
description: Some("Causes $s1 threat.".into()),
effect_base_points: [9, 0, 0],
effect_base_dice: [1, 0, 0],
effect_die_sides: [1, 0, 0],
..Default::default()
};
let spells = SpellCatalog::from_displays(HashMap::from([(100, wrapper), (200, taught)]));
let deps = Deps::new();
with_text(&spells, 0, 0, None, |text| {
assert_eq!(
service_description(
&wire(100, trainer_spell_state::GREEN, 10, 1, 0),
0,
&spells,
&deps.items,
&deps.commands,
text,
),
"Causes 10 threat."
);
});
}
/// The third arm: a tradeskill recipe has no text of its own, so the created item's Description is
/// returned verbatim — never expanded (`0x4d9cd0`-`0x4d9d22`).
#[test]
fn service_description_takes_the_recipes_product_text_verbatim() {
let wrapper = SpellDisplay {
effects: [SPELL_EFFECT_LEARN_SPELL, 0, 0],
effect_trigger_spell: [200, 0, 0],
..Default::default()
};
let recipe = SpellDisplay {
attributes: SPELL_ATTR_IS_TRADESKILL,
effects: [SPELL_EFFECT_CREATE_ITEM, 0, 0],
effect_item_type: [777, 0, 0],
..Default::default()
};
let spells = SpellCatalog::from_displays(HashMap::from([(100, wrapper), (200, recipe)]));
let mut deps = Deps::new();
let mut item = crate::items::test_template("Copper Shortsword");
item.description = "Keeps $s1 for later.".into();
deps.items.insert_template(777, Some(item));
with_text(&spells, 0, 0, None, |text| {
assert_eq!(
service_description(
&wire(100, trainer_spell_state::GREEN, 10, 1, 0),
TRAINER_TYPE_TRADESKILL,
&spells,
&deps.items,
&deps.commands,
text,
),
"Keeps $s1 for later.",
"the product's text is the item's own, tokens and all"
);
});
}
/// A used row at a mount trainer skips its own text (`0x4d9c29`); the same row at a class trainer
/// shows it.
#[test]
fn service_description_skips_the_wire_text_for_a_used_mount_row() {
let d = SpellDisplay {
description: Some("Summons a mount.".into()),
effects: [SPELL_EFFECT_LEARN_SPELL, 0, 0],
effect_trigger_spell: [200, 0, 0],
..Default::default()
};
let taught = SpellDisplay {
description: Some("Riding skill.".into()),
..Default::default()
};
let spells = SpellCatalog::from_displays(HashMap::from([(100, d), (200, taught)]));
let deps = Deps::new();
let description = |trainer_type, state| {
with_text(&spells, 0, 0, None, |text| {
service_description(
&wire(100, state, 10, 1, 0),
trainer_type,
&spells,
&deps.items,
&deps.commands,
text,
)
})
};
assert_eq!(
description(0, trainer_spell_state::GREEN),
"Summons a mount."
);
assert_eq!(
description(TRAINER_TYPE_MOUNT, trainer_spell_state::GRAY),
"Riding skill."
);
}
/// The caster selector: a `LEARN_PET_SPELL` wrapper expands against the pet (`0x6e3130`'s nonzero
/// selector), the same taught spell under a plain `LEARN_SPELL` against the player.
#[test]
fn service_description_expands_against_the_pet_for_a_learn_pet_row() {
// One point per level over `baseLevel` 1: level 0 reads 10, a level-2 pet reads 11.
let taught = SpellDisplay {
description: Some("Causes $s1 threat.".into()),
base_level: 1,
effect_base_points: [9, 0, 0],
effect_base_dice: [1, 0, 0],
effect_die_sides: [1, 0, 0],
effect_dice_per_level: [1, 0, 0],
..Default::default()
};
let pet_wrapper = SpellDisplay {
effects: [SPELL_EFFECT_LEARN_PET_SPELL, 0, 0],
effect_trigger_spell: [200, 0, 0],
..Default::default()
};
let plain_wrapper = SpellDisplay {
effects: [SPELL_EFFECT_LEARN_SPELL, 0, 0],
effect_trigger_spell: [200, 0, 0],
..Default::default()
};
let spells = SpellCatalog::from_displays(HashMap::from([
(100, pet_wrapper),
(101, plain_wrapper),
(200, taught),
]));
let deps = Deps::new();
// A level-2 pet (skill 10) against a player with no skill in the taught spell's line at all.
with_text(&spells, 0, 10, None, |text| {
assert_eq!(
service_description(
&wire(100, trainer_spell_state::GREEN, 10, 1, 0),
0,
&spells,
&deps.items,
&deps.commands,
text,
),
"Causes 11 threat.",
"the pet's level drives the taught spell's points"
);
assert_eq!(
service_description(
&wire(101, trainer_spell_state::GREEN, 10, 1, 0),
0,
&spells,
&deps.items,
&deps.commands,
text,
),
"Causes 10 threat.",
"the same taught spell under LEARN_SPELL reads the player's"
);
});
}
/// The `$z` token: the bind point's area name (`AreaTable.dbc`), and raw where no bind point has
/// arrived, as the item and tooltip feeds' `$z` behaves.
#[test]
fn service_description_expands_z_to_the_bind_area() {
let d = SpellDisplay {
description: Some("Returns you to $z.".into()),
..Default::default()
};
let spells = SpellCatalog::from_displays(HashMap::from([(556, d)]));
let deps = Deps::new();
for (home, want) in [
(Some("Razor Hill"), "Returns you to Razor Hill."),
(None, "Returns you to $z."),
] {
with_text(&spells, 0, 0, home, |text| {
assert_eq!(
service_description(
&wire(556, trainer_spell_state::GREEN, 4000, 30, 0),
0,
&spells,
&deps.items,
&deps.commands,
text,
),
want
);
});
}
}
/// On the shipped `Spell.dbc`: the warrior wrapper 1605 teaches 78 Heroic Strike, so the row's
/// description is the taught ability's text, not the wrapper's empty one. Skips without client
/// data.
#[test]
fn service_description_on_real_data_expands_the_taught_ability() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = benilla_formats::load_spell_catalog(&mut chain).expect("load Spell");
let svc = resolve_service(
&wire(1605, trainer_spell_state::GREEN, 10, 1, 0),
0,
&spells,
None,
&BTreeSet::new(),
);
assert!(
svc.description
.starts_with("A strong attack that increases melee damage by "),
"the taught 78's description, not the wrapper's empty one: {:?}",
svc.description
);
assert!(
!svc.description.contains('$'),
"every token resolved: {:?}",
svc.description
);
}
/// On the shipped `Spell.dbc`: 1352 is the shaman trainer's Astral Recall wrapper, teaching 556,
/// whose text is the one trainer-taught description carrying `$z`. Skips without client data.
#[test]
fn service_description_on_real_data_reads_astral_recalls_bind_area() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = benilla_formats::load_spell_catalog(&mut chain).expect("load Spell");
let deps = Deps::new();
let svc = with_text(&spells, 0, 0, Some("Razor Hill"), |text| {
super::resolve_service(
&wire(1352, trainer_spell_state::GREEN, 4000, 30, 0),
0,
&spells,
None,
&BTreeSet::new(),
None,
&deps.items,
&deps.commands,
text,
&probe_strings,
)
});
assert_eq!(
svc.description,
"Yanks the caster through the twisting nether back to Razor Hill. Speak to an \
Innkeeper in a different place to change your home location."
);
}
/// On the shipped `Spell.dbc`: 2020 is the "Apprentice Blacksmith" wrapper a Blacksmithing trainer
/// lists, teaching 2018 "Blacksmithing" — the profession-learn row whose text is the taught
/// profession's. Skips without client data.
#[test]
fn service_description_on_real_data_reads_the_profession_learn_row() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = benilla_formats::load_spell_catalog(&mut chain).expect("load Spell");
// The wrapper carries no text of its own, so the taught profession's is the only one there is.
assert_eq!(
spells.get(2020).and_then(|d| d.description.as_deref()),
None
);
let svc = resolve_service(
&wire(2020, trainer_spell_state::GREEN, 9, 5, 0),
TRAINER_TYPE_TRADESKILL,
&spells,
None,
&BTreeSet::new(),
);
assert_eq!(
svc.description,
"Allows a Blacksmith to make basic weapons and armor up to a maximum potential skill of \
75. Requires stone and metal found with the mining skill."
);
}
/// On the shipped `Spell.dbc`: 7820 is the recipe wrapper a Blacksmithing trainer lists, 7818 the
/// recipe it teaches and 6338 the Silver Rod it makes, so the row's text is that item's
/// description, read out of the item cache (`0x4d9cd0`-`0x4d9d22`). The product's record arrives
/// with the item query, so the template seeded below, with vmangos's text, stands in for it.
/// Skips without client data.
#[test]
fn service_description_on_real_data_reaches_for_the_recipes_product() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = benilla_formats::load_spell_catalog(&mut chain).expect("load Spell");
// Neither the wrapper nor the recipe carries text of its own, so the item is the only source.
assert_eq!(
spells.get(7820).and_then(|d| d.description.as_deref()),
None
);
assert_eq!(
spells.get(7818).and_then(|d| d.description.as_deref()),
None
);
// The law alone, never through `resolve_service`: the icon law asks for the same template
// first, and one ask per pending entry reaches the cache.
let describe = |deps: &Deps| {
with_text(&spells, 0, 0, None, |text| {
service_description(
&wire(7820, trainer_spell_state::GREEN, 90, 0, 164),
TRAINER_TYPE_TRADESKILL,
&spells,
&deps.items,
&deps.commands,
text,
)
})
};
let deps = Deps::new();
assert_eq!(
describe(&deps),
"",
"nothing to show until the product's template lands"
);
assert_eq!(
deps.queried_entries(),
vec![6338],
"the description law reached for the crafted rod's template"
);
// With the product's record present, its description is the row's text, verbatim.
let mut product = crate::items::test_template("Silver Rod");
product.description = "Needed by Enchanters.".into();
let landed = {
let mut deps = Deps::new();
deps.items.insert_template(6338, Some(product));
deps
};
assert_eq!(describe(&landed), "Needed by Enchanters.");
}
#[test]
fn snapshot_is_none_when_closed_and_lists_services_when_open() {
let spells = empty_catalog();
+196 -32
View File
@@ -64,9 +64,9 @@ pub(crate) struct CombatTextEvent {
///
/// Deviation: fires with our descriptor present, so `UnitExists("player")` answers true where the
/// reference's (`0x515970`) misses, because matching it breaks `UnitExists` for no stock reader.
fn fire_leaving_world_on_worldport(
pub(crate) fn fire_leaving_world_on_worldport(
script: Option<NonSendMut<UiScript>>,
mut armed: ResMut<crate::ui_script::LeavingWorldArmed>,
mut sweep: crate::ui_script::LeaveWorldSweep,
mut ports: MessageReader<crate::net::WorldportMessage>,
) {
// `needs_ack` false is the initial-login map, an arrival; the whole iterator is read so no
@@ -77,7 +77,7 @@ fn fire_leaving_world_on_worldport(
}
// The world latch keeps this producer and the shutdown tail from both claiming one departure;
// spent even with no VM, as the reference clears it at `0x490a8d`, ahead of the fire.
if !armed.spend() {
if !sweep.run() {
return;
}
let Some(mut script) = script else {
@@ -101,7 +101,7 @@ struct UnitFeedMemo {
/// The lazy caches' landing counters: their per-frame `&mut` misses would trip `is_changed`.
names_generation: gate::Watch,
guild_generation: gate::Watch,
/// Whether `PLAYER_ENTERING_WORLD` has fired for this world entry.
/// Whether the world-enter events have fired for this world entry.
entered_world: bool,
/// Per token, the last snapshot pushed.
last: HashMap<String, UnitState>,
@@ -145,6 +145,7 @@ impl Plugin for UiUnitPlugin {
.add_message::<crate::net::WorldportMessage>()
.add_message::<crate::net::FieldChanged>()
.init_resource::<crate::ui_script::LeavingWorldArmed>()
.add_message::<crate::ui_script::WorldLeaveSweepMessage>()
.add_systems(
Update,
(
@@ -583,15 +584,15 @@ pub(crate) fn player_token_guid(
/// The one unit-token resolver, the reference's `0x515970`: case-insensitive compares, then the
/// object manager; a caller wanting a type tests the resolved unit. [`Selection`] is a parameter
/// because [`crate::target::SelectCommit`] holds it as `ResMut`. `npc` is recognised but
/// unresolved here, a quiet nil.
/// because [`crate::target::SelectCommit`] holds it as `ResMut`.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct UnitTokens<'w, 's> {
/// `Option`, like the two below: a UI-only harness lacks their plugins; a bare `Res` panics.
/// `Option`, like those below: a UI-only harness lacks their plugins; a bare `Res` panics.
index: Option<Res<'w, crate::net::GuidIndex>>,
pet: Option<Res<'w, crate::ui_pet::PetBar>>,
hovered: Option<Res<'w, crate::target::Hovered>>,
hovered_go: Option<Res<'w, crate::target::HoveredObject>>,
interact: Option<Res<'w, crate::ui_session::InteractNpc>>,
group: Res<'w, crate::ui_party::GroupState>,
pub(crate) stores: Query<'w, 's, &'static ObjectStore>,
me: Query<'w, 's, (Entity, &'static Guid), With<SelfPlayer>>,
@@ -645,8 +646,8 @@ impl UnitTokens<'_, '_> {
.and_then(|member| self.pet_of(member, true)),
UnitBase::Raid(row) => self.raid_member(row).and_then(|guid| self.held(guid)),
UnitBase::Party(row) => self.party_member(row).and_then(|guid| self.held(guid)),
// The interaction NPC is unresolved here, a quiet nil.
UnitBase::Npc => None,
// The interaction NPC's guid, `[0xb4e2d0]` (`0x515c00`).
UnitBase::Npc => self.held(self.interact.as_ref()?.1?),
}
}
@@ -928,6 +929,13 @@ pub(crate) fn snapshot(
pvp_team: store.0.unit_race().map_or(-1, race_pvp_team),
// `PLAYER_BYTES_3` byte 2, the city-protector title (`PVP_MEDAL<n>`), unset by vmangos.
pvp_medal: store.0.player_pvp_medal().unwrap_or(0),
// The quest-log window, off a player alone: `IsUnitOnQuest` looks the guid up with
// TYPEMASK_PLAYER (`0x4dfe97`) before it reads `[obj+0xe68]`.
quest_log: if matches!(store.0.object_type(), Some(ObjectType::Player)) {
store.0.player_quest_log_window()
} else {
Default::default()
},
// `is_player` and the creature-record fields come from [`enrich_unit`].
..Default::default()
}
@@ -1144,6 +1152,15 @@ pub(crate) fn fire_transitions(
if edges.moved(cur.guid, benilla_protocol::field::FIELD_UNIT_DYNAMIC_FLAGS) {
script.fire_event("UNIT_DYNAMIC_FLAGS", vec![tok()]);
}
// `UNIT_QUEST_LOG_CHANGED` (id 522), the player-window watch over the quest-log slots
// (`0x51bc63`: offset `0x28`, length `0xf0`, callback `0x51bd90`): one compare over the 60
// dwords, so one event however many moved. A group mate's ids are all of it on the wire.
let log = benilla_protocol::field::FIELD_PLAYER_QUEST_LOG_1_1;
if (log..log + 3 * u16::from(benilla_protocol::messages::PLAYER_QUEST_LOG_SLOTS))
.any(|i| edges.moved(cur.guid, i))
{
script.fire_event("UNIT_QUEST_LOG_CHANGED", vec![tok()]);
}
// 1.12 names the power events per resource (`UNIT_MANA`, `UNIT_MAXRAGE`, …;
// `UnitFrame.lua:190-199`), and `power_token` yields the suffix.
if changed(|u| u64::from(u.power)) {
@@ -1177,6 +1194,75 @@ pub(crate) fn fire_transitions(
}
}
/// `"player"`'s snapshot off our own descriptor: no reaction (the unit is ours), the raid icon,
/// both faction-group spellings and the guild. One builder for [`feed_units`] and for the seat a
/// `/reload`'s load runs under ([`live_player_seat`]).
fn player_snapshot(
store: &ObjectStore,
guid: u64,
tables: &SnapshotTables,
names: &NameCache,
commands: &NetCommands,
group: &crate::ui_party::GroupState,
factions: Option<&Factions>,
guild: &mut crate::ui_guild::GuildState,
) -> UnitState {
let name = names
.resolve_unit(guid, Some(store), commands)
.map(str::to_string);
let mut s = snapshot(store, guid, name, 0, tables.classes(), tables.types(names));
s.is_player = true;
s.raid_target = group.raid_target_index(guid);
s.faction_group = faction_group(store, factions);
s.faction_group_localized = faction_group_localized(store, factions);
// `GetGuildInfo`: the public guild fields (191/192) joined against the lazy guild cache.
s.guild = crate::ui_guild::unit_guild(&store.0, guild, commands);
s
}
/// `UnitXP("player")` and `UnitXPMax("player")` off our own descriptor, zero while unsent.
fn xp_pair(store: &ObjectStore) -> (u32, u32) {
(
store.0.player_xp().unwrap_or(0),
store.0.player_next_level_xp().unwrap_or(0),
)
}
/// The live player a UI load seats before any file runs: its snapshot and XP pair, as
/// [`feed_units`] pushes them.
pub(crate) struct LivePlayerSeat {
pub(crate) state: UnitState,
pub(crate) xp: (u32, u32),
}
/// [`LivePlayerSeat`] for an exclusive edge, `None` before our own create. A `/reload` loads with
/// the player object still in the world, so `UI_Init`'s file scope, `ADDON_LOADED` and
/// `VARIABLES_LOADED` read it there as `PLAYER_LOGIN` does (`0x490168`).
pub(crate) fn live_player_seat(
tables: SnapshotTables,
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
names: Res<NameCache>,
commands: Res<NetCommands>,
group: Res<crate::ui_party::GroupState>,
factions: Option<Res<Factions>>,
mut guild: ResMut<crate::ui_guild::GuildState>,
) -> Option<LivePlayerSeat> {
let (store, guid) = self_q.iter().next()?;
Some(LivePlayerSeat {
state: player_snapshot(
store,
guid.0,
&tables,
&names,
&commands,
&group,
factions.as_deref(),
&mut guild,
),
xp: xp_pair(store),
})
}
fn feed_units(
script: Option<NonSendMut<UiScript>>,
tables: SnapshotTables,
@@ -1263,17 +1349,16 @@ fn feed_units(
// A missing unit is `None`, which `set_unit` clears; a name miss lands on a later frame.
let self_pair = self_q.iter().next();
let player = self_pair.map(|(store, guid)| {
let name = names
.resolve_unit(guid.0, Some(store), &commands)
.map(str::to_string);
let mut s = snapshot(store, guid.0, name, 0, chr, types);
s.is_player = true;
s.raid_target = group.raid_target_index(guid.0);
s.faction_group = faction_group(store, factions.as_deref());
s.faction_group_localized = faction_group_localized(store, factions.as_deref());
// `GetGuildInfo`: the public guild fields (191/192) joined against the lazy guild cache.
s.guild = crate::ui_guild::unit_guild(&store.0, &mut guild, &commands);
s
player_snapshot(
store,
guid.0,
&tables,
&names,
&commands,
&group,
factions.as_deref(),
&mut guild,
)
});
// Log once when our template names no side, almost always GM mode (vmangos forces template
// 35, group mask 0): every `UnitFactionGroup` surface loses its side, as in the reference.
@@ -1373,12 +1458,11 @@ fn feed_units(
}
}
// The XP pair and `PLAYER_XP_UPDATE`, ahead of the `PLAYER_ENTERING_WORLD` fire so its
// handlers read real values. The event fires on first sight too, at login and after a
// `/reload`, where the reference's field watchers stay silent.
// The XP pair and `PLAYER_XP_UPDATE`, ahead of the world-enter events so `PLAYER_LOGIN` and
// `PLAYER_ENTERING_WORLD` handlers read real values. The event fires on first sight too, at
// login and after a `/reload`, where the reference's field watchers stay silent.
if let Some((store, _)) = self_q.iter().next() {
let xp = store.0.player_xp().unwrap_or(0);
let next = store.0.player_next_level_xp().unwrap_or(0);
let (xp, next) = xp_pair(store);
if memo.last_xp != Some((xp, next)) {
gate.audit("feed_units", "the XP pair");
memo.last_xp = Some((xp, next));
@@ -1387,7 +1471,7 @@ fn feed_units(
}
}
// The rest snapshot (rest-state byte, pool, `PLAYER_FLAGS`), ahead of the entering-world fire
// The rest snapshot (rest-state byte, pool, `PLAYER_FLAGS`), ahead of the world-enter events
// as the reference's descriptor is. Below `0x5ee990`'s local-GUID gate (`0x5eea93`) each arm
// tests its own bits: `PLAYER_UPDATE_RESTING` on either edge of `0x20` (`0x5eead0`, fire
// `0x5eeaf2`), `PLAYTIME_CHANGED` on `0x3000` (`0x5eeb65`, fire `0x5eeb6f`).
@@ -1444,7 +1528,7 @@ fn feed_units(
// `ChatFrame.lua:1283-1320` reads them; missing gains are zeros, since stock's
// `if ( argN > 0 )` raises on nil.
let (info, talent_points) = sink.chat.take_level_up_gains(level).unzip();
let gain = |f: fn(&benilla_protocol::messages::LevelUpInfo) -> u32| {
let gain = |f: fn(&benilla_protocol::messages::LevelUpInfo) -> i32| {
ScriptValue::Int(i64::from(info.as_ref().map_or(0, f)))
};
script.fire_event(
@@ -1478,12 +1562,16 @@ fn feed_units(
}
}
// `PLAYER_ENTERING_WORLD` once per world entry, after our descriptor lands, as the reference's.
// At world exit, re-arm it and forget the player-global memos so the next character seeds them.
// The world-enter cascade's events once per world entry, after our descriptor lands and the
// pushes above seat it: the reference runs the cascade from the local player's own create
// (`0x5deb60 call 0x4908c0`) and, on a `/reload`, from `UI_Init` (`0x490168`), the player in
// the world both times. `PLAYER_LOGIN` only when this VM's load armed it, so a worldport's
// re-entry fires `PLAYER_ENTERING_WORLD` alone. At world exit, re-arm the entry and forget the
// player-global memos so the next character seeds them.
if self_pair.is_some() {
if !memo.entered_world {
gate.audit("feed_units", "the PLAYER_ENTERING_WORLD arm");
script.fire_event("PLAYER_ENTERING_WORLD", vec![]);
gate.audit("feed_units", "the world-enter events");
script.fire_world_enter();
memo.entered_world = true;
}
} else if memo.entered_world {
@@ -2204,7 +2292,6 @@ mod tests {
"targetfoo",
"mouseoverx",
"raid3targettargetfoo",
"npc",
"npctarget",
"bogus",
"",
@@ -2579,6 +2666,46 @@ mod tests {
);
}
/// `UNIT_QUEST_LOG_CHANGED` (id 522) is the player-window watch over the 60 quest-log dwords
/// (`0x51bc63`: offset `0x28`, length `0xf0`, callback `0x51bd90`): any of them moving fires it
/// once with the token, and a dword either side does not.
#[test]
fn a_quest_log_change_fires_unit_quest_log_changed_with_the_token() {
let fired = |prev: &UnitState, cur: &UnitState, edges: &[(u64, u16)]| -> Vec<String> {
let mut s = UiScript::new().unwrap();
s.run(
r#"
SEEN = {}
local f = CreateFrame("Frame")
f:RegisterEvent("UNIT_QUEST_LOG_CHANGED")
f:SetScript("OnEvent", function() table.insert(SEEN, event .. ":" .. arg1) end)
"#,
)
.unwrap();
fire_transitions(&mut s, "party1", Some(prev), cur, &FieldEdges::of(edges));
s.eval::<Vec<String>>("return SEEN").unwrap()
};
const MATE: u64 = 0x300;
const LOG: u16 = benilla_protocol::field::FIELD_PLAYER_QUEST_LOG_1_1;
let before = UnitState {
exists: true,
has_object: true,
guid: MATE,
..Default::default()
};
let mut after = before.clone();
after.quest_log[4][0] = 783;
let once = vec!["UNIT_QUEST_LOG_CHANGED:party1".to_string()];
assert_eq!(fired(&before, &after, &[(MATE, LOG + 12)]), once);
// One watch over the window: two slots moving in one pass is one event.
assert_eq!(
fired(&before, &after, &[(MATE, LOG), (MATE, LOG + 59)]),
once
);
assert!(fired(&before, &after, &[(MATE, LOG - 1), (MATE, LOG + 60)]).is_empty());
assert!(fired(&before, &after, &[(MATE + 1, LOG + 12)]).is_empty());
}
/// LOST going down, GAINED going up (boot is in control); far sight on every change.
#[test]
fn the_control_and_far_sight_edges_fire_once_each_way() {
@@ -3153,6 +3280,42 @@ mod tests {
);
}
/// The quest-log window rides a player's snapshot whole, and a non-player's is all zero, as
/// `IsUnitOnQuest`'s lookup is TYPEMASK_PLAYER (`0x4dfe97`).
#[test]
fn a_players_snapshot_carries_its_quest_log_window_and_a_creatures_none() {
use benilla_protocol::messages::ObjectFields;
const OBJECT_TYPE: u16 = 2;
const QUEST_LOG_1_1: u16 = 198;
let log = [
(QUEST_LOG_1_1, 783),
(QUEST_LOG_1_1 + 1, 0x0100_0003),
(QUEST_LOG_1_1 + 57, 7),
];
let snap = |type_bits: u32| {
snapshot(
&ObjectStore(ObjectFields::from_pairs(
&[log.as_slice(), &[(OBJECT_TYPE, type_bits)]].concat(),
)),
0,
None,
0,
None,
Default::default(),
)
};
let player = snap(0x19);
assert_eq!(player.quest_log[0], [783, 0x0100_0003, 0]);
assert_eq!(player.quest_log[19], [7, 0, 0]);
assert_eq!(player.quest_log[1], [0, 0, 0]);
assert_eq!(
snap(0x09).quest_log,
[[0; 3]; 20],
"a creature has no PLAYER block"
);
}
/// The rank getter's two gates (`0x605620`), through `enrich_unit`'s wiring.
#[test]
fn the_rank_gate_zeroes_a_pet_or_charm() {
@@ -3594,6 +3757,7 @@ mod tests {
let mut app = App::new();
app.add_message::<crate::net::WorldportMessage>()
.init_resource::<crate::ui_script::LeavingWorldArmed>()
.add_message::<crate::ui_script::WorldLeaveSweepMessage>()
.add_systems(Update, fire_leaving_world_on_worldport);
app.insert_non_send_resource(UiScript::new().expect("VM"));
app.world_mut()
+118 -140
View File
@@ -4,7 +4,8 @@
//! `AreaTable`, `WorldMapContinent`, `Map` and the `.zmp` bitmaps, built as the reference's
//! `0x4a5d00` builds it, with the instance maps no continent owns beside it.
//! - [`feed_world_map`], every frame: the player's selection (`0x4a6650`), the blips projected
//! onto the displayed map, and the landmark list (`0x4a67a0`), rebuilt when its inputs change.
//! onto the displayed map, and the landmark sources the setter `0x4a67a0` builds its list
//! from, pushed when they change.
//! - [`dev_map_jump`]: Alt+click the map to go there.
use bevy::ecs::system::NonSendMut;
@@ -16,7 +17,8 @@ use benilla_formats::{
load_zone_map, WorldMapArea,
};
use benilla_ui::script::{
UiScript, WorldMapContinentView, WorldMapLandmarkView, WorldMapOverlayView, WorldMapZoneView,
UiScript, WorldMapContinentView, WorldMapLandmarkSource, WorldMapLevel, WorldMapOverlayView,
WorldMapZoneView,
};
use crate::net::{ObjectStore, SelfPlayer};
@@ -316,39 +318,11 @@ fn build_catalog_from_world(world: &World) -> Option<(Vec<WorldMapContinentView>
build_catalog(&mut chain, &areas.0, maps)
}
/// The displayed level the landmark gates branch on, from the selection cells `[0x84506c]`,
/// `[0x845070]` and `[0x845074]`. An instance map is `Zone`: both gates treat it as zone level
/// (`0x4a79de`; `0x4a8856`/`0x4a885f` fall through to `0x4a8868`).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum MapLevel {
/// Both continents on one sheet (the reference's `continent == -1`).
World,
/// One continent, no zone selected (`continent >= 0, zone == -1`).
Continent,
/// One zone (`continent >= 0, zone >= 0`), or an instance map (`continent == -2`).
Zone,
}
impl MapLevel {
/// From the engine's selection, the 1-based `(continent, zone)` with 0 for whole plus the
/// direct area, which is read first because it shares the world sheet's `(0, 0)`.
fn of((continent, zone, direct): (u32, u32, Option<u32>)) -> Self {
match (continent, zone, direct) {
(_, _, Some(_)) => Self::Zone,
(0, _, None) => Self::World,
(_, 0, None) => Self::Continent,
_ => Self::Zone,
}
}
}
/// The inputs the landmark list is a pure function of. `0x4a67a0` rebuilds only on these: a
/// selection change, a world-state push (`0x48fa0d`), a `PLAYER_EXPLORED_ZONES` change
/// The inputs the landmark sources are a pure function of. Besides a selection change, `0x4a67a0`
/// rebuilds the list on these: a world-state push (`0x48fa0d`), a `PLAYER_EXPLORED_ZONES` change
/// (`0x4a6477`, an UpdateFields callback on byte `0xe6c`) and the gossip marker's set and clear.
#[derive(PartialEq, Eq)]
struct LandmarkKey {
/// All three selection cells, the direct area's included.
selection: (u32, u32, Option<u32>),
map: u32,
states: u64,
explored: Vec<u32>,
@@ -360,27 +334,24 @@ struct LandmarkKey {
impl LandmarkKey {
fn matches(
&self,
selection: (u32, u32, Option<u32>),
map: u32,
states: u64,
explored: &[u32],
marker: Option<(u32, [u32; 3], u32)>,
) -> bool {
self.selection == selection
&& self.map == map
self.map == map
&& self.states == states
&& self.marker == marker
&& self.explored == explored
}
}
/// The builder's near-zero skip (`0x4a6868`/`0x4a687a`, epsilon `2.384e-7`): a UV of 0 on both
/// axes is off the displayed map, which is how POIs are filtered by continent; a POI on a rect's
/// edge has one non-zero axis and stays.
fn is_degenerate(uv: (f32, f32)) -> bool {
const EPS: f32 = 2.384e-7;
uv.0.abs() < EPS && uv.1.abs() < EPS
}
/// [`WorldMapLandmarkSource::icons`]' order.
const LEVELS: [WorldMapLevel; 3] = [
WorldMapLevel::World,
WorldMapLevel::Continent,
WorldMapLevel::Zone,
];
/// `0x4a67a0`'s AreaPOI gates, in the reference's order:
///
@@ -394,15 +365,15 @@ fn is_degenerate(uv: (f32, f32)) -> bool {
/// The loop reads no `ContinentID`, `Importance`, `FactionID` or `Icon`.
fn landmark_gates_pass(
poi: &benilla_formats::AreaPoi,
level: MapLevel,
level: WorldMapLevel,
areas: &benilla_formats::AreaTableCatalog,
explored: &[u32],
world_states: &crate::world_state::WorldStates,
) -> bool {
let level_ok = match level {
MapLevel::Zone => poi.flags & 0x04 != 0,
MapLevel::Continent => poi.flags & 0x08 != 0,
MapLevel::World => poi.flags & 0x10 != 0 && poi.flags & 0x08 != 0,
WorldMapLevel::Zone => poi.flags & 0x04 != 0,
WorldMapLevel::Continent => poi.flags & 0x08 != 0,
WorldMapLevel::World => poi.flags & 0x10 != 0 && poi.flags & 0x08 != 0,
};
if !level_ok {
return false;
@@ -429,10 +400,10 @@ fn explored_bit(explored: &[u32], bit: u32) -> bool {
/// `GetMapLandmarkInfo`'s `textureIndex` (`0x4a8848`-`0x4a8877`): the row's `Icon`, but cell 15
/// at zone level for a row without `Flags & 0x80`, which the shipped data sets on exactly the
/// world-state rows.
fn landmark_texture_index(poi: &benilla_formats::AreaPoi, level: MapLevel) -> u32 {
fn landmark_texture_index(poi: &benilla_formats::AreaPoi, level: WorldMapLevel) -> u32 {
/// The generic zone-level POI cell of `Interface\Minimap\POIIcons`.
const ZONE_SUBSTITUTE: u32 = 15;
match poi.flags & 0x80 != 0 || level != MapLevel::Zone {
match poi.flags & 0x80 != 0 || level != WorldMapLevel::Zone {
true => poi.icon,
false => ZONE_SUBSTITUTE,
}
@@ -594,6 +565,16 @@ fn feed_world_map(
let top_zone = world.area().and_then(|aid| areas.0.top_zone(aid));
let player_sel = resolve_player_selection(&data, map.0, top_zone);
// `GetWorldLocMapPosition` and the setter's landmark pass project synchronously with the
// engine's own selection, so the engine holds the projection over its own copy of the catalog.
{
let memo = memo.get(&script);
if !memo.projector || data.is_changed() {
memo.projector = true;
install_world_loc_projector(&mut script, &data);
}
}
// First world enter: the zone updater `0x494780` (`crate::area`), finding the cached zone id
// 0, selects the player's zone itself through `0x4a6650` and the setter `0x4a67a0` before any
// Lua asks. Once per VM, each login being a first enter.
@@ -606,16 +587,6 @@ fn feed_world_map(
}
}
// `GetWorldLocMapPosition` projects synchronously with the engine's own selection, so the
// engine holds the projection over its own copy of the catalog.
{
let memo = memo.get(&script);
if !memo.projector || data.is_changed() {
memo.projector = true;
install_world_loc_projector(&mut script, &data);
}
}
let selection = script.world_map_selection();
let project =
|pos_map: u32, px: f32, py: f32| project_on_displayed(&data, selection, pos_map, px, py);
@@ -629,7 +600,8 @@ fn feed_world_map(
)
});
// The landmarks (`GetNumMapLandmarks`), rebuilt only when a `LandmarkKey` input moves.
// The landmark sources, rebuilt only when a `LandmarkKey` input moves; the engine projects
// them onto each selection it stores.
let marker = poi_marker
.on_map(map.0)
.map(|m| (m.continent_id, m.pos.map(f32::to_bits), m.icon));
@@ -638,53 +610,52 @@ fn feed_world_map(
.get(&script)
.landmarks
.as_ref()
.is_some_and(|k| k.matches(selection, map.0, states_gen, &explored, marker));
.is_some_and(|k| k.matches(map.0, states_gen, &explored, marker));
if !unchanged {
let level = MapLevel::of(selection);
let mut landmarks = Vec::new();
// The DBC rows first, in file order, the Lua landmark index (`0x4a6819`'s walk of
// `[0xc0e054]`).
if let Some(pois) = pois.as_ref() {
for (_, poi) in pois.0.rows() {
if !landmark_gates_pass(poi, level, &areas.0, &explored, &world_states) {
continue;
}
let Some(uv) = project(poi.continent_id, poi.pos[0], poi.pos[1]) else {
continue;
};
if is_degenerate(uv) {
continue;
}
landmarks.push(WorldMapLandmarkView {
name: poi.name.clone(),
description: poi.description.clone(),
texture_index: landmark_texture_index(poi, level),
uv,
});
}
}
let mut sources: Vec<WorldMapLandmarkSource> = pois
.as_ref()
.map(|pois| {
pois.0
.rows()
.filter_map(|(_, poi)| {
let icons = LEVELS.map(|level| {
landmark_gates_pass(poi, level, &areas.0, &explored, &world_states)
.then(|| landmark_texture_index(poi, level))
});
icons
.iter()
.any(Option::is_some)
.then(|| WorldMapLandmarkSource {
name: poi.name.clone(),
description: poi.description.clone(),
map: poi.continent_id,
pos: (poi.pos[0], poi.pos[1]),
icons,
})
})
.collect()
})
.unwrap_or_default();
// Then the guard-directions marker, last (`0x4a69c7`): no gate and no icon substitution,
// only the degenerate-projection test.
// only the near-zero skip.
if let Some(poi) = poi_marker.on_map(map.0) {
if let Some(uv) = project(poi.continent_id, poi.pos[0], poi.pos[1]) {
if !is_degenerate(uv) {
landmarks.push(WorldMapLandmarkView {
name: poi.name.clone(),
description: poi.description.clone(),
texture_index: poi.icon,
uv,
});
}
}
sources.push(WorldMapLandmarkSource {
name: poi.name.clone(),
description: poi.description.clone(),
map: poi.continent_id,
pos: (poi.pos[0], poi.pos[1]),
icons: [Some(poi.icon); 3],
});
}
memo.get(&script).landmarks = Some(LandmarkKey {
selection,
map: map.0,
states: states_gen,
explored: explored.clone(),
marker,
});
script.set_world_map_landmarks(landmarks);
script.set_world_map_landmark_sources(sources);
}
// The `party1..4` blips: a streamed member's transform, else the `i16` position from
@@ -739,7 +710,6 @@ fn dev_map_jump(
buttons: Res<ButtonInput<MouseButton>>,
keys: Res<ButtonInput<KeyCode>>,
windows: Query<&Window, With<bevy::window::PrimaryWindow>>,
ui_scale: Res<crate::ui_script::UiScaleCvar>,
script: Option<NonSendMut<UiScript>>,
data: Option<Res<WorldMapUiData>>,
net: Res<crate::net::NetCommands>,
@@ -758,10 +728,9 @@ fn dev_map_jump(
let Some(cursor) = window.cursor_position() else {
return;
};
// Window px (y-down) to the VM's y-up 768-unit space, as the pointer feed converts them.
let s = crate::ui_script::seam_scale(window.height(), ui_scale.0);
let Some((u, v)) = script.world_map_uv_at(cursor.x / s, (window.height() - cursor.y) / s)
else {
// Window px (y-down) to the VM's y-up root units, as the pointer feed converts them.
let at = crate::ui_script::window_to_ui(window.height(), cursor);
let Some((u, v)) = script.world_map_uv_at(at.x, at.y) else {
return;
};
let (c, z, direct) = script.world_map_selection();
@@ -856,24 +825,24 @@ mod tests {
};
// A town (0x0d = 0x01|0x04|0x08): zone and continent, not the world sheet.
assert!(pass(0x0d, MapLevel::Zone));
assert!(pass(0x0d, MapLevel::Continent));
assert!(!pass(0x0d, MapLevel::World));
assert!(pass(0x0d, WorldMapLevel::Zone));
assert!(pass(0x0d, WorldMapLevel::Continent));
assert!(!pass(0x0d, WorldMapLevel::World));
// A capital (0x1d = 0x0d|0x10): all three.
assert!(pass(0x1d, MapLevel::Zone));
assert!(pass(0x1d, MapLevel::Continent));
assert!(pass(0x1d, MapLevel::World));
assert!(pass(0x1d, WorldMapLevel::Zone));
assert!(pass(0x1d, WorldMapLevel::Continent));
assert!(pass(0x1d, WorldMapLevel::World));
// An Eastern Plaguelands tower (0x87 = 0x01|0x02|0x04|0x80): zone only.
assert!(pass(0x87, MapLevel::Zone));
assert!(!pass(0x87, MapLevel::Continent));
assert!(!pass(0x87, MapLevel::World));
assert!(pass(0x87, WorldMapLevel::Zone));
assert!(!pass(0x87, WorldMapLevel::Continent));
assert!(!pass(0x87, WorldMapLevel::World));
// `0x10` alone, which 1.12 does not ship: world level still wants `0x08`.
assert!(!pass(0x10, MapLevel::World));
assert!(!pass(0x10, MapLevel::Continent));
assert!(!pass(0x10, MapLevel::Zone));
assert!(!pass(0x10, WorldMapLevel::World));
assert!(!pass(0x10, WorldMapLevel::Continent));
assert!(!pass(0x10, WorldMapLevel::Zone));
}
#[test]
@@ -885,7 +854,7 @@ mod tests {
let horde = poi(0x87, 0, 2373);
let plain = poi(0x87, 0, 0);
let pass = |p: &AreaPoi, st: &WorldStates| {
landmark_gates_pass(p, MapLevel::Zone, &areas, &explored, st)
landmark_gates_pass(p, WorldMapLevel::Zone, &areas, &explored, st)
};
assert!(
@@ -928,8 +897,9 @@ mod tests {
v[1] = 1 << 8;
v
};
let pass =
|p: &AreaPoi, ex: &[u32]| landmark_gates_pass(p, MapLevel::Zone, &areas, ex, &states);
let pass = |p: &AreaPoi, ex: &[u32]| {
landmark_gates_pass(p, WorldMapLevel::Zone, &areas, ex, &states)
};
assert!(!pass(&poi(0x04, 139, 0), &unexplored), "not discovered yet");
assert!(pass(&poi(0x04, 139, 0), &explored));
@@ -952,13 +922,20 @@ mod tests {
fn the_zone_level_icon_substitution_spares_the_world_state_rows() {
let town = poi(0x0d, 0, 0);
let tower = poi(0x87, 0, 2372);
assert_eq!(landmark_texture_index(&town, MapLevel::Zone), 15);
assert_eq!(landmark_texture_index(&town, WorldMapLevel::Zone), 15);
assert_eq!(
landmark_texture_index(&town, MapLevel::Continent),
landmark_texture_index(&town, WorldMapLevel::Continent),
town.icon
);
assert_eq!(landmark_texture_index(&town, MapLevel::World), town.icon);
for level in [MapLevel::Zone, MapLevel::Continent, MapLevel::World] {
assert_eq!(
landmark_texture_index(&town, WorldMapLevel::World),
town.icon
);
for level in [
WorldMapLevel::Zone,
WorldMapLevel::Continent,
WorldMapLevel::World,
] {
assert_eq!(
landmark_texture_index(&tower, level),
tower.icon,
@@ -967,26 +944,17 @@ mod tests {
}
}
#[test]
fn only_a_both_axes_zero_projection_is_dropped() {
assert!(is_degenerate((0.0, 0.0)));
assert!(is_degenerate((1e-8, -1e-8)));
assert!(!is_degenerate((0.0, 0.5)));
assert!(!is_degenerate((0.5, 0.0)));
assert!(!is_degenerate((1e-6, 0.0)));
}
#[test]
fn the_selection_pair_maps_onto_the_reference_levels() {
assert_eq!(MapLevel::of((0, 0, None)), MapLevel::World);
assert_eq!(WorldMapLevel::of((0, 0, None)), WorldMapLevel::World);
assert_eq!(
MapLevel::of((0, 3, None)),
MapLevel::World,
WorldMapLevel::of((0, 3, None)),
WorldMapLevel::World,
"continent 0 wins"
);
assert_eq!(MapLevel::of((2, 0, None)), MapLevel::Continent);
assert_eq!(MapLevel::of((2, 7, None)), MapLevel::Zone);
assert_eq!(MapLevel::of((0, 0, Some(443))), MapLevel::Zone);
assert_eq!(WorldMapLevel::of((2, 0, None)), WorldMapLevel::Continent);
assert_eq!(WorldMapLevel::of((2, 7, None)), WorldMapLevel::Zone);
assert_eq!(WorldMapLevel::of((0, 0, Some(443))), WorldMapLevel::Zone);
}
#[test]
@@ -1019,7 +987,7 @@ mod tests {
v
};
let tower_shows = |st: &WorldStates, ex: &[u32]| {
landmark_gates_pass(tower, MapLevel::Zone, &areas, ex, st)
landmark_gates_pass(tower, WorldMapLevel::Zone, &areas, ex, st)
};
assert!(
!tower_shows(&states, &explored_epl),
@@ -1032,7 +1000,13 @@ mod tests {
"and it still needs the zone discovered"
);
assert!(
!landmark_gates_pass(tower, MapLevel::Continent, &areas, &explored_epl, &states),
!landmark_gates_pass(
tower,
WorldMapLevel::Continent,
&areas,
&explored_epl,
&states
),
"a tower is a zone-level row (Flags 0x87 carries no 0x08)"
);
@@ -1050,19 +1024,23 @@ mod tests {
u32::MAX,
"continent-wide, so no exploration gate"
);
for level in [MapLevel::World, MapLevel::Continent, MapLevel::Zone] {
for level in [
WorldMapLevel::World,
WorldMapLevel::Continent,
WorldMapLevel::Zone,
] {
assert!(
landmark_gates_pass(city, level, &areas, &nothing_explored, &states),
"{name} shows at every level on a fresh character"
);
}
assert_eq!(
landmark_texture_index(city, MapLevel::Continent),
landmark_texture_index(city, WorldMapLevel::Continent),
city.icon,
"the city icon is its own at continent level"
);
assert_eq!(
landmark_texture_index(city, MapLevel::Zone),
landmark_texture_index(city, WorldMapLevel::Zone),
15,
"and the generic cell inside a zone map"
);
@@ -1250,8 +1228,8 @@ mod player_zone_tests {
None,
"the world sheet has no continent for map 489 at all"
);
assert_eq!(MapLevel::of(sel), MapLevel::Zone);
assert_eq!(MapLevel::of((0, 0, None)), MapLevel::World);
assert_eq!(WorldMapLevel::of(sel), WorldMapLevel::Zone);
assert_eq!(WorldMapLevel::of((0, 0, None)), WorldMapLevel::World);
}
/// `0x494780` calls `0x4a6650` at `0x4947ac` when the cached zone id `[0xb4e314]` was 0.
+4 -8
View File
@@ -308,7 +308,6 @@ fn drive_vplates(
net_commands: Res<NetCommands>,
// `None` in a run with no UI VM.
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
ui_scale: Res<crate::ui_script::UiScaleCvar>,
anchor_q: (
Query<&BoneAttach>,
Query<&benilla_world::rig_anim::RigPose>,
@@ -374,10 +373,8 @@ fn drive_vplates(
let (pw, ph) = (gx(PLATE_W), gx(PLATE_H));
let scale = window.map_or(1.0, |w| w.scale_factor());
// Everything below is in window px, divided by this once for the widget layer's units.
let seam = window.map_or(1.0, |w| {
crate::ui_script::seam_scale(w.height(), ui_scale.0)
});
// Everything below is in window px, divided by this once for the widget layer's root units.
let seam = window.map_or(1.0, |w| crate::ui_script::seam_scale(w.height()));
let mut states: Vec<PlateState> = Vec::new();
// Plates seat in squared-distance order from the gx point (0.4, 0.3), Y mirrored from the
@@ -541,9 +538,8 @@ fn drive_vplates(
}
// ── The plate's state, handed to the widget layer ──
//
// The reference's plates sit outside the uiScale cascade, but benilla folds uiScale into
// one global seam, so dividing by it here keeps plate pixels uiScale-blind.
// The raid-target index is 1-based here, 0-based for the atlas cell.
// The plates hang off `WorldFrame`, outside `UIParent`'s scale, so root units are their
// own. The raid-target index is 1-based here, 0-based for the atlas cell.
let mark = group.raid_target_index(guid.0);
let x_units = (plate.min.x + plate.max.x) * 0.5 / seam;
let y_units = (viewport.y - plate.min.y) / seam;
+5 -10
View File
@@ -374,10 +374,9 @@ fn only_the_bids_pane_gets_its_page_without_being_opened() {
assert!(s.take_errors().is_empty());
}
/// The reference's show cascade `0x76ae10` is post-order: a frame marks itself visible
/// (`0x76ae7b`), walks its children re-reading live links, and fires its own `OnShow` last
/// (`0x76aef5`). benilla's is pre-order, a gap in the engine; for this window both orders reach
/// the same state, so only the `ShowOrder` assertions are benilla's order.
/// The show cascade `0x76ae10` is post-order: a frame marks itself visible (`0x76ae7b`), walks its
/// children re-reading live links, and fires its own `OnShow` last (`0x76aef5`), so the pane the
/// XML leaves shown is notified before the window.
#[test]
fn the_show_cascade_notifies_the_bids_pane_once_and_keeps_its_page() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -408,11 +407,7 @@ fn the_show_cascade_notifies_the_bids_pane_once_and_keeps_its_page() {
};
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
assert_eq!(
order(&s),
"window,pane",
"OURS, and a known deviation: the reference's cascade is post-order, \"pane,window\""
);
assert_eq!(order(&s), "pane,window", "children first, the window last");
assert_eq!(
page(&s),
"0",
@@ -431,7 +426,7 @@ fn the_show_cascade_notifies_the_bids_pane_once_and_keeps_its_page() {
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
assert_eq!(
order(&s),
"window,pane,window",
"pane,window,window",
"the reopen notifies the window and not the pane it left hidden"
);
assert_eq!(page(&s), "0", "kept from the first open, not re-assigned");
+127
View File
@@ -0,0 +1,127 @@
//! `Cfg_Configs.dbc`, the realm types. A realm's wire type (its realm-list record's first dword)
//! is joined against `RealmType` by a linear scan that takes the first match, in the realm list
//! (`GetRealmInfo`, `0x46efda`), the character screen's `GetServerName` (`0x46d280`) and at world
//! entry (`0x4015b1`, the latch `GetZonePVPInfo` reads).
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, u32_at};
use crate::Chain;
const CFG_CONFIGS: &str = "DBFilesClient\\Cfg_Configs.dbc";
/// One realm type's row.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RealmConfig {
/// Column 1, the key a realm's wire type is matched against.
pub realm_type: u32,
/// Column 2, `PlayerKillingAllowed` (`+0x08`): `GetRealmInfo`'s `pvp`.
pub pvp: bool,
/// Column 3, `RoleplayingRealm` (`+0x0c`): `GetRealmInfo`'s `rp`.
pub rp: bool,
}
/// The realm types, in file order.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RealmConfigs(Vec<RealmConfig>);
impl RealmConfigs {
/// A table over rows given directly.
pub fn from_rows(rows: Vec<RealmConfig>) -> Self {
Self(rows)
}
/// The first row whose `RealmType` is `realm_type`, as every reader's scan stops there.
pub fn get(&self, realm_type: u32) -> Option<&RealmConfig> {
self.0.iter().find(|c| c.realm_type == realm_type)
}
/// The rows, in file order.
pub fn rows(&self) -> &[RealmConfig] {
&self.0
}
}
/// `ID`, `RealmType`, `PlayerKillingAllowed`, `RoleplayingRealm`: the 4 columns × 0x10 bytes the
/// loader (`0x5413f0`) gates on.
fn cfg_configs_schema() -> Schema {
let mut s = Schema::new("Cfg_Configs");
for name in [
"ID",
"RealmType",
"PlayerKillingAllowed",
"RoleplayingRealm",
] {
s.add_field(SchemaField::new(name, FieldType::UInt32));
}
s
}
/// Every row of `Cfg_Configs.dbc`.
pub fn load_realm_configs(chain: &mut Chain) -> Result<RealmConfigs> {
let bytes = chain
.read_file(CFG_CONFIGS)
.with_context(|| format!("reading {CFG_CONFIGS}"))?;
let rs = parse(&bytes, cfg_configs_schema(), "Cfg_Configs")?;
Ok(RealmConfigs(
rs.records()
.iter()
.filter_map(|r| {
Some(RealmConfig {
realm_type: u32_at(r, 1)?,
pvp: u32_at(r, 2)? != 0,
rp: u32_at(r, 3)? != 0,
})
})
.collect(),
))
}
#[cfg(test)]
mod tests {
use super::*;
/// The shipped table: 11 rows, types 0 to 10 in order; PvP on 1, 3, 5, 8 and 10, RP on 6, 7
/// and 8.
#[test]
fn the_shipped_table_is_eleven_types_with_pvp_on_1_3_5_8_10() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let configs = load_realm_configs(&mut chain).expect("Cfg_Configs.dbc");
let rows: Vec<(u32, bool, bool)> = configs
.rows()
.iter()
.map(|c| (c.realm_type, c.pvp, c.rp))
.collect();
assert_eq!(
rows,
[
(0, false, false),
(1, true, false),
(2, false, false),
(3, true, false),
(4, false, false),
(5, true, false),
(6, false, true),
(7, false, true),
(8, true, true),
(9, false, false),
(10, true, false),
]
);
assert!(configs.get(11).is_none(), "no row past type 10");
}
/// The scan stops at the first match.
#[test]
fn a_duplicated_type_reads_its_first_row() {
let row = |pvp| RealmConfig {
realm_type: 4,
pvp,
rp: false,
};
let configs = RealmConfigs::from_rows(vec![row(true), row(false)]);
assert!(configs.get(4).expect("row").pvp);
}
}
+2
View File
@@ -56,6 +56,8 @@ pub use gm_ticket_category::{
};
mod cfg_categories;
pub use cfg_categories::{load_realm_categories, RealmCategory};
mod cfg_configs;
pub use cfg_configs::{load_realm_configs, RealmConfig, RealmConfigs};
mod wow_ini;
pub use wow_ini::client_region;
mod itembagfamily;
+59 -4
View File
@@ -1,8 +1,8 @@
//! The spell-description `$`-token engine the 1.12 client runs over `Spell.dbc` description and
//! aura text (`0x5075f0` → `0x507710`), effect values from `GetEffectPoints 0x6e3800`. Effect
//! point values print unsigned, as the client's do. `$g` takes the first form without gender
//! input. `$c` and `$p` (`507dde`, `507ded`), which no shipped text uses, and unknown or
//! unresolved tokens stay raw.
//! point values print unsigned, as the client's do. `$g` and `$G` branch on the active player's
//! gender ([`TokenContext::gender`]). `$c` and `$p` (`507dde`, `507ded`), which no shipped text
//! uses, and unknown or unresolved tokens stay raw.
use super::soft_float;
use super::{SpellDisplay, SpellDurationCatalog, SpellRadiusCatalog, SpellRangeCatalog};
@@ -40,6 +40,9 @@ pub struct TokenContext<'a> {
/// points skip their modifiers (`6e3925`), and so does the duration of `$d` and `$o`
/// (`0x6ea000`'s flag, pushed at `507ccd` and `507917`).
pub unmodified_points: bool,
/// The active player's gender, `UNIT_FIELD_BYTES_0` byte 2 (`0x508214`), which `$g`/`$G`
/// branch on. Asked only where a branch expands.
pub gender: &'a dyn Fn() -> u8,
/// The `$z` token: the home-bind area's name, from `SMSG_BINDPOINTUPDATE`'s area id through
/// `AreaTable.dbc`; `None` leaves the token raw. Asked only where a `$z` expands, so a caller
/// can tell which texts read the bind point.
@@ -416,6 +419,12 @@ fn scale_prefix(text: &str, at: usize) -> Option<(f32, usize)> {
Some((scale, semi + 1))
}
/// `0x5081c8`-`0x5081d7` and `0x50822a`-`0x50823b`: a `$g`/`$G` branch arm's leading spaces are
/// skipped, and `0x508267`-`0x508280` trims its trailing spaces — both forms alike.
fn trim_spaces(arm: &str) -> &str {
arm.trim_matches(' ')
}
/// The CRT's `atoi`: leading whitespace, an optional sign, then decimal digits up to the first
/// other character; nothing parsed is 0.
fn atoi(bytes: &[u8]) -> i32 {
@@ -494,7 +503,8 @@ pub fn substitute(text: &str, spell: &SpellDisplay, ctx: &TokenContext) -> Strin
b
}
}
_ => a, // $g: the first form, as there is no gender input
// `$g`/`$G` (`0x508180`): the first form on gender 0, else the second.
_ => trim_spaces(if (ctx.gender)() == 0 { a } else { b }),
};
out.push_str(pick);
i = i + 1 + end + 1;
@@ -671,6 +681,7 @@ mod tests {
lookup,
mods: None,
unmodified_points: false,
gender: &|| 0,
global: &global,
printf: &printf,
}
@@ -737,6 +748,50 @@ mod tests {
assert_eq!(d(8), "<forever>");
}
/// `0x508180`'s branch: the first form on gender 0, the second on anything else, the chosen
/// form trimmed of spaces at both ends (`0x5081c8`-`0x5081d7`, `0x50822a`-`0x50823b`,
/// `0x508267`-`0x508280`).
#[test]
fn gender_takes_the_players_side_of_the_branch() {
let durations = SpellDurationCatalog::default();
let radii = SpellRadiusCatalog::default();
let d = SpellDisplay::default();
let gender = std::cell::Cell::new(0u8);
let c = TokenContext {
gender: &|| gender.get(),
..ctx(&durations, &radii, &none_lookup)
};
assert_eq!(substitute("$ghis:her;", &d, &c), "his");
gender.set(1);
assert_eq!(substitute("$ghis:her;", &d, &c), "her");
gender.set(2);
assert_eq!(substitute("$ghis:her;", &d, &c), "her");
// `G` is the same branch (`0x508138`'s table maps both spellings to `0x50786d`).
assert_eq!(substitute("$GLad:LAss;", &d, &c), "LAss");
// The chosen form's spaces go from both ends.
gender.set(0);
assert_eq!(substitute("$g his : her ;", &d, &c), "his");
gender.set(1);
assert_eq!(substitute("$g his : her ;", &d, &c), "her");
// An empty chosen arm consumes the token.
gender.set(0);
assert_eq!(substitute("$g:male;", &d, &c), "");
gender.set(1);
assert_eq!(substitute("$g:male;", &d, &c), "male");
assert_eq!(substitute("$g female:;", &d, &c), "");
// The shipped wording: Conjure Food 587 and Hellfire 1949.
assert_eq!(
substitute("providing the mage and $ghis:her; allies", &d, &c),
"providing the mage and her allies"
);
gender.set(0);
assert_eq!(
substitute("damage to $ghimself:herself;", &d, &c),
"damage to himself"
);
}
#[test]
fn duration_modifier_updates_description_and_overtime_total() {
let mut durations = SpellDurationCatalog::default();
+2 -1
View File
@@ -20,7 +20,8 @@ pub use messages::{
CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_HIDE_HELM, CHARACTER_FLAG_RENAME,
};
pub use world::{
WardenRequired, WorldAuthReject, WorldReader, WorldSession, WorldWriter, WORLD_PORT,
observe_sends, SendObserver, WardenRequired, WorldAuthReject, WorldReader, WorldSession,
WorldWriter, WORLD_PORT,
};
use std::net::TcpStream;
@@ -2,7 +2,7 @@
use std::io::{self, Read};
use crate::wire::{read_f32_le, read_u32_le, read_u64_le, read_u8};
use crate::wire::{read_f32_le, read_i32_le, read_u32_le, read_u64_le, read_u8};
/// `SMSG_LOG_XPGAIN`: an XP award; `victim` is 0 for non-kill XP (vmangos `Misc.cpp:512-522`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -92,27 +92,29 @@ pub fn talent_wipe_confirm(trainer_guid: u64) -> Vec<u8> {
pub struct LevelUpInfo {
/// The level just reached.
pub level: u32,
/// Hit points gained.
pub health: u32,
/// Hit points gained, negative on a loss.
pub health: i32,
/// Power gains: mana, rage, focus, energy, happiness. The server fills only mana, which is
/// the reference chat line's mana argument.
pub powers: [u32; 5],
pub powers: [i32; 5],
/// Stat gains in `SPELL_STAT0..4` order (str, agi, stam, int, spirit), one chat line per
/// positive entry.
pub stats: [u32; 5],
pub stats: [i32; 5],
}
/// Read `SMSG_LEVELUP_INFO`: `u32` level, health, 5 powers, 5 stats (`Misc.cpp:524-532`).
/// Read `SMSG_LEVELUP_INFO`: `u32` level, `i32` health, 5 powers, 5 stats (`Misc.cpp:524-532`).
/// The reference fires the gains as `%d` (`0x5e413e`), so a loss the server wraps into its `u32`
/// (vmangos `Player.cpp:3203-3215`) reads negative.
pub(super) fn read_level_up_info(r: &mut impl Read) -> io::Result<LevelUpInfo> {
let level = read_u32_le(r)?;
let health = read_u32_le(r)?;
let mut powers = [0u32; 5];
let health = read_i32_le(r)?;
let mut powers = [0; 5];
for p in &mut powers {
*p = read_u32_le(r)?;
*p = read_i32_le(r)?;
}
let mut stats = [0u32; 5];
let mut stats = [0; 5];
for s in &mut stats {
*s = read_u32_le(r)?;
*s = read_i32_le(r)?;
}
Ok(LevelUpInfo {
level,
@@ -202,6 +202,17 @@ impl ObjectFields {
timer,
})
}
/// The whole `PLAYER_QUEST_LOG` window, `[id, count-state, timer]` per slot as the wire left
/// it, 0 where absent: a group mate's carries the ids alone, the one dword per slot vmangos
/// flags `GROUP_ONLY` (`UpdateFields_1_12_1.h:128-129`).
pub fn player_quest_log_window(&self) -> [[u32; 3]; PLAYER_QUEST_LOG_SLOTS as usize] {
std::array::from_fn(|slot| {
std::array::from_fn(|k| {
self.get_u32(FIELD_PLAYER_QUEST_LOG_1_1 + 3 * slot as u16 + k as u16)
.unwrap_or(0)
})
})
}
/// `PLAYER_EXPLORED_ZONES_1 + i`: bitset dword `i`; bit `n` is `AreaTable` explore flag `n`.
pub fn player_explored_zone_slot(&self, i: u16) -> u32 {
if i >= PLAYER_EXPLORED_ZONES_SLOTS {
@@ -135,4 +135,17 @@ mod tests {
let f = ObjectFields::from_pairs(&[(198, 0), (199, 0), (200, 0)]);
assert_eq!(f.player_quest_log(0).map(|s| s.quest_id), Some(0));
}
#[test]
fn quest_log_window_is_every_slot_raw_and_zero_where_absent() {
// A group mate's view: ids alone in slots 0 and 19, the private dwords never sent; slot 2
// carries a count-state dword, kept as sent rather than unpacked.
let f = ObjectFields::from_pairs(&[(198, 783), (198 + 57, 7), (205, 0x0100_0005)]);
let w = f.player_quest_log_window();
assert_eq!(w.len(), usize::from(PLAYER_QUEST_LOG_SLOTS));
assert_eq!(w[0], [783, 0, 0]);
assert_eq!(w[1], [0, 0, 0]);
assert_eq!(w[2], [0, 0x0100_0005, 0]);
assert_eq!(w[19], [7, 0, 0]);
}
}
+20 -1
View File
@@ -6,6 +6,7 @@
use std::io::{Read, Write};
use std::net::TcpStream;
use std::sync::OnceLock;
use anyhow::{anyhow, Result};
use benilla_srp::vanilla_header::{DecrypterHalf, EncrypterHalf};
@@ -47,7 +48,21 @@ pub(super) fn recv_packet(
messages::parse_server(opcode, &body).map_err(|e| anyhow!("parsing opcode {opcode:#x}: {e}"))
}
/// What [`observe_sends`] takes: called with a sent packet's opcode and body length.
pub type SendObserver = fn(opcode: u16, body_len: usize);
static SEND_OBSERVER: OnceLock<SendObserver> = OnceLock::new();
/// Report every client packet that reaches a world socket, from the `CMSG_AUTH_SESSION` of the
/// handshake through each [`WorldSession`] and [`WorldWriter`] send, on the thread that wrote it
/// and right after the write. The first observer set is kept for the process; with none set a send
/// pays one atomic load.
pub fn observe_sends(observer: SendObserver) {
let _ = SEND_OBSERVER.set(observer);
}
/// Write one client packet: a 6-byte header, its size counting opcode and body, then the body.
/// The sole write path of the world socket, so the one place [`observe_sends`] reports from.
pub(super) fn send_packet(
stream: &mut TcpStream,
encrypter: Option<&mut EncrypterHalf>,
@@ -70,5 +85,9 @@ pub(super) fn send_packet(
packet.extend_from_slice(body);
stream
.write_all(&packet)
.map_err(|e| anyhow!("sending opcode {opcode:#x}: {e}"))
.map_err(|e| anyhow!("sending opcode {opcode:#x}: {e}"))?;
if let Some(observer) = SEND_OBSERVER.get() {
observer(opcode, body.len());
}
Ok(())
}
+2 -2
View File
@@ -284,7 +284,8 @@ impl WorldSession {
self.send(opcode::CMSG_PLAYER_LOGIN, &messages::full_guid(guid))
}
/// Declare the unit we move, as the 1.12 client does at login; vmangos drops moves until then.
/// Declare the unit we move, as the 1.12 client does at its own create; vmangos drops moves
/// until then.
pub fn set_active_mover(&mut self, guid: u64) -> Result<()> {
self.send(opcode::CMSG_SET_ACTIVE_MOVER, &messages::full_guid(guid))
}
@@ -777,7 +778,6 @@ impl WorldSession {
stream: self.stream,
encrypter,
chat_language: self.chat_language,
sent: None,
},
))
}
@@ -25,7 +25,8 @@ impl WorldWriter {
)
}
/// `CMSG_MEETINGSTONE_STATUS_QUERY`, empty: the reference sends it once per world session.
/// `CMSG_MEETINGSTONE_STATUS_QUERY`, empty: the reference sends it at each world-enter
/// cascade, so at login, after every cross-map worldport and on every `/reload`.
pub fn meeting_stone_status_query(&mut self) -> Result<()> {
self.send(opcode::CMSG_MEETINGSTONE_STATUS_QUERY, &[])
}
@@ -60,9 +60,6 @@ mod vendor;
pub struct WorldWriter {
pub(super) stream: TcpStream,
pub(super) encrypter: EncrypterHalf,
/// Every packet that reached the socket since the last drain, as `(opcode, body length)`;
/// `None` until [`Self::watch_sends`]. Pushed only after a successful write.
pub(super) sent: Option<Vec<(u16, usize)>>,
/// The character's faction language, set at login from its race, which a chat send naming no
/// language carries. vmangos drops the whole message, dot-commands included, when the
/// character does not know the language.
@@ -72,26 +69,6 @@ pub struct WorldWriter {
impl WorldWriter {
/// Frame, encrypt and write one packet: the sole write path every verb goes through.
fn send(&mut self, opcode: u16, body: &[u8]) -> Result<()> {
let sent = send_packet(&mut self.stream, Some(&mut self.encrypter), opcode, body);
if sent.is_ok() {
if let Some(log) = &mut self.sent {
log.push((opcode, body.len()));
}
}
sent
}
/// Start recording what reaches the socket; a second call keeps what is not yet drained.
pub fn watch_sends(&mut self) {
self.sent.get_or_insert_with(Vec::new);
}
/// Hand over and clear the recorded `(opcode, body length)` pairs, in send order.
pub fn drain_sent(&mut self, mut each: impl FnMut(u16, usize)) {
if let Some(log) = &mut self.sent {
for (opcode, len) in log.drain(..) {
each(opcode, len);
}
}
send_packet(&mut self.stream, Some(&mut self.encrypter), opcode, body)
}
}
@@ -94,3 +94,50 @@ fn a_warden_server_is_refused_at_the_handshake() {
"expected WardenRequired, got: {err:#}"
);
}
/// Every packet the observer saw, with the thread that wrote it: the tests of this file share one
/// process, so each filters for its own thread.
static SEEN: std::sync::Mutex<Vec<(thread::ThreadId, u16, usize)>> =
std::sync::Mutex::new(Vec::new());
fn record(opcode: u16, len: usize) {
SEEN.lock()
.unwrap()
.push((thread::current().id(), opcode, len));
}
/// A packet sent before the session splits (the handshake's `CMSG_AUTH_SESSION`, the world entry's
/// `CMSG_PLAYER_LOGIN`) reaches the observer in write order, ahead of the writer's own.
#[test]
fn sends_before_the_split_are_observed_in_order() {
benilla_protocol::observe_sends(record);
let addr = fake_server(vec![]);
let mut session = WorldSession::connect(&addr, "one", SESSION_KEY).unwrap();
session.player_login(0x2a).unwrap();
let (_reader, mut writer) = session.into_split().unwrap();
writer.complete_cinematic().unwrap();
let me = thread::current().id();
let mine: Vec<(u16, usize)> = SEEN
.lock()
.unwrap()
.iter()
.filter(|(t, ..)| *t == me)
.map(|&(_, op, len)| (op, len))
.collect();
let auth_len = messages::auth_session(
u32::from(benilla_protocol::CLIENT_BUILD),
"ONE",
0,
&[0; 20],
&messages::STOCK_SECURE_ADDONS,
)
.len();
assert_eq!(
mine,
[
(opcode::CMSG_AUTH_SESSION, auth_len),
(opcode::CMSG_PLAYER_LOGIN, messages::full_guid(0x2a).len()),
(opcode::CMSG_COMPLETE_CINEMATIC, 0),
]
);
}
+30 -2
View File
@@ -1003,8 +1003,8 @@ fn combat_log_wire_golden() {
other => panic!("exploration xp event, got {other:?}"),
}
// SMSG_LEVELUP_INFO: twelve u32, level, healthGain, powerGains[5] (mana..happiness) and
// statGains[5] (str..spirit), no guid (vmangos `Misc.cpp:524-532`).
// SMSG_LEVELUP_INFO: u32 level, then the i32 gains, healthGain, powerGains[5] (mana..happiness)
// and statGains[5] (str..spirit), no guid (vmangos `Misc.cpp:524-532`).
let mut body = Vec::new();
for v in [7u32, 22, 15, 0, 0, 0, 0, 1, 0, 1, 2, 1] {
body.extend_from_slice(&v.to_le_bytes());
@@ -1029,6 +1029,34 @@ fn combat_log_wire_golden() {
SessionEvent::LevelUp(l) => assert_eq!((l.level, l.health, l.powers[0]), (7, 22, 15)),
other => panic!("level up event, got {other:?}"),
}
// A loss the server wraps into its u32 reads negative, as the reference's `%d` fire prints it.
let mut body = Vec::new();
for v in [
7u32, 0xFFFFFFFE, 0xFFFFFFFD, 0, 0, 0, 0, 0xFFFFFFFE, 0, 0xFFFFFFFB, 0xFFFFFFFA, 0xFFFFFFF9,
] {
body.extend_from_slice(&v.to_le_bytes());
}
assert_eq!(body.len(), 48);
let packet = messages::parse_server(messages::opcode::SMSG_LEVELUP_INFO, &body).unwrap();
match &packet {
ServerPacket::LevelUp(l) => {
assert_eq!(
l,
&LevelUpInfo {
level: 7,
health: -2,
powers: [-3, 0, 0, 0, 0],
stats: [-2, 0, -5, -6, -7],
}
);
}
other => panic!("level up, got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::LevelUp(l) => assert_eq!((l.level, l.health, l.powers[0]), (7, -2, -3)),
other => panic!("level up event, got {other:?}"),
}
}
/// Raw caster guid, u32 spellId, u32 count, raw guids (vmangos `Spell.cpp:4974-4997`); in the
+12 -4
View File
@@ -315,8 +315,10 @@ fn element_from_node(node: roxmltree::Node) -> Element {
/// attributes override or extend the template's (case-insensitively). The reference merges
/// nothing: `LoadXML` re-enters itself on the one object against each template, the most distant
/// first, then the instance (`0x76985c`; `<Frames>` repeat the walk, `0x76a060`), and reads `name`
/// and `id` from the instance alone (`PreLoadXML 0x769770`). Here `name` and `virtual` merge like
/// any attribute. A cycle is skipped with a warning.
/// and `id` from the instance alone (`PreLoadXML 0x769770`; a region's factory reads its `name`
/// once, outside the walk, `0x6f2751`/`0x6f27e1`). So here `name` and `id` come from the element
/// alone, and an unnamed instance stays unnamed; `virtual` merges like any attribute, read only at
/// registration. A cycle is skipped with a warning.
pub fn expand(
element: &Element,
templates: &HashMap<&str, &Element>,
@@ -390,9 +392,15 @@ fn expand_inner(
}
}
/// [`expand`]'s merge; `over`'s tag wins, and its body when it has one.
/// [`expand`]'s merge; `over`'s tag wins, and its body when it has one. `base`'s `name` and `id`
/// are dropped: they belong to the node they are written on.
fn merge(base: &Element, over: &Element) -> Element {
let mut attrs = base.attrs.clone();
let mut attrs: Vec<(String, String)> = base
.attrs
.iter()
.filter(|(k, _)| !k.eq_ignore_ascii_case("name") && !k.eq_ignore_ascii_case("id"))
.cloned()
.collect();
for (k, v) in &over.attrs {
if let Some(slot) = attrs.iter_mut().find(|(ek, _)| ek.eq_ignore_ascii_case(k)) {
slot.1 = v.clone();
+11 -2
View File
@@ -642,7 +642,7 @@ impl Loader<'_> {
}
/// Everything a frame element does to an existing frame, from `LoadXML` attributes to its
/// `OnLoad` (last, bottom-up, `0x76a060`); shared by [`Self::materialize`] and
/// `OnLoad` (bottom-up, `0x76a060`) and the post-load OnShow; shared by [`Self::materialize`] and
/// [`apply_template`]. `$parent` is `self_name` in this frame's contents and `parent_name` in
/// its own anchors (`0x76c5b0`).
pub(super) fn decorate(
@@ -655,6 +655,9 @@ impl Loader<'_> {
) {
// This frame's deferred anchors drain before its OnLoad; a nested frame drains its own.
let deferred_mark = self.deferred_anchors.len();
// 1c · the pre-load hook opens the frame's load window (`0x7697c2`): until the post-load
// hook, its own OnShow and OnHide fire nothing.
crate::script::visibility::set_load_window(self.lua(), wrapper, true);
// 2 · LoadXML attributes (`0x769820`).
self.apply_attrs(el, wrapper, dbg_name);
// 3 · <Size> and 4 · <Anchors> (the CLayoutFrame geometry base, `0x767800`).
@@ -681,7 +684,9 @@ impl Loader<'_> {
// 6 · <Scripts> handlers (`0x769ef0`); OnLoad is captured to fire bottom-up below.
let onload = self.apply_scripts(el, wrapper, dbg_name);
// 7 · nested <Frames>, whose OnLoads fire before ours (`0x76a060`).
// 7 · the post-load hook (`0x76a2f0`): close the window (`0x76a2ff`), then the nested
// <Frames>, whose OnLoads fire before ours (`0x76a060`).
crate::script::visibility::set_load_window(self.lua(), wrapper, false);
for frames_el in children_named(el, "Frames") {
for child in &frames_el.children {
let expanded = self.expand(child);
@@ -717,6 +722,8 @@ impl Loader<'_> {
if let Some(func) = onload {
self.fire_onload(wrapper, &func, dbg_name);
}
// 9 · and its OnShow, if it is visible now (`0x76a3b3`).
crate::script::visibility::post_load_show(self.lua(), wrapper);
}
/// Apply one anchor, deferring it (when `may_defer`) while its named `relativeTo` is unbuilt.
@@ -898,5 +905,7 @@ pub(super) fn font_object_from_element(el: &Element) -> FontObject {
}
}
#[cfg(test)]
mod template_name_tests;
#[cfg(test)]
mod tests;
@@ -0,0 +1,87 @@
//! `name=` and `id=` belong to the node they are written on: `PreLoadXML 0x769770` reads them from
//! the instance before `LoadXML` walks its templates (`0x76985c`), and the region factories read
//! `name` once, outside that walk (`0x6f2751`, `0x6f27e1`).
use crate::framexml;
use crate::loader::load;
use crate::script::UiScript;
const DOC: &str = r#"<Ui>
<Button name="TmpCloseTemplate" id="3" virtual="true">
<Size><AbsDimension x="32" y="32"/></Size>
</Button>
<Texture name="TmpTexTemplate" virtual="true">
<Color r="1" g="0" b="0" a="1"/>
</Texture>
<Frame name="Holder">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture inherits="TmpTexTemplate"/>
</Layer>
</Layers>
<Frames>
<Button inherits="TmpCloseTemplate"/>
<Button name="$parentClose" inherits="TmpCloseTemplate"/>
</Frames>
</Frame>
</Ui>"#;
fn loaded() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let report = load(&s, &framexml::parse(DOC).unwrap(), &|_| None);
assert!(report.errors.is_empty(), "{:?}", report.errors);
s
}
/// Stock `UIOptionsFrame.xml:1477`'s unnamed `<Button inherits="UIPanelCloseButton">` is the
/// shape: in 1.12.1 the template's name is no global and the button answers no name.
#[test]
fn an_unnamed_instance_takes_neither_its_templates_name_nor_its_id() {
let s = loaded();
assert!(
s.eval::<bool>("return TmpCloseTemplate == nil").unwrap(),
"the unnamed instance published the template's name as a global"
);
assert!(s
.eval::<bool>("return ({Holder:GetChildren()})[1]:GetName() == nil")
.unwrap());
assert_eq!(
s.eval::<i64>("return ({Holder:GetChildren()})[1]:GetID()")
.unwrap(),
0
);
}
#[test]
fn a_named_instance_keeps_its_own_name_and_takes_no_template_id() {
let s = loaded();
assert_eq!(
s.eval::<String>("return HolderClose:GetName()").unwrap(),
"HolderClose"
);
assert_eq!(s.eval::<i64>("return HolderClose:GetID()").unwrap(), 0);
}
#[test]
fn an_unnamed_region_takes_no_name_from_its_template() {
let s = loaded();
assert!(s.eval::<bool>("return TmpTexTemplate == nil").unwrap());
assert!(s
.eval::<bool>("return ({Holder:GetRegions()})[1]:GetName() == nil")
.unwrap());
}
/// `CreateFrame` builds its node with the `name` it was given (`0x70622d`), nil included.
#[test]
fn create_frame_with_no_name_stays_unnamed_under_a_named_template() {
let s = loaded();
assert!(s
.eval::<bool>(
"local b = CreateFrame('Button', nil, Holder, 'TmpCloseTemplate') \
return b:GetName() == nil and TmpCloseTemplate == nil"
)
.unwrap());
}
+106
View File
@@ -2470,6 +2470,112 @@ mod chunk_name_tests {
}
}
/// The post-load hook's OnShow (`0x76a2f0`), for a frame visible at the end of its load.
mod post_load_show_tests {
use crate::framexml;
use crate::loader::*;
use crate::script::UiScript;
fn no_files(_: &str) -> Option<Vec<u8>> {
None
}
fn parse(text: &str) -> framexml::ParsedDocument {
framexml::parse(text).expect("valid FrameXML")
}
/// The post-load hook `0x76a2f0` builds the `<Frames>` children (`0x76a36f`, their own hooks
/// inside), fires OnLoad (`0x76a387`), then fires OnShow (`0x76a3b3`) when the frame is visible
/// (`0x76a38c`) and has the handler (`0x76a396`): a frame visible at the end of its load gets
/// one OnShow, after its OnLoad and its children's.
#[test]
fn a_frame_visible_at_the_end_of_its_load_gets_one_onshow_after_its_onload() {
let s = UiScript::new().unwrap();
s.run("LOG = {} function Log(tag) table.insert(LOG, tag) end")
.unwrap();
let doc = parse(
r#"<Ui>
<Frame name="Born">
<Scripts>
<OnLoad>Log("load")</OnLoad>
<OnShow>Log("show")</OnShow>
</Scripts>
<Frames>
<Frame name="BornChild">
<Scripts>
<OnLoad>Log("child-load")</OnLoad>
<OnShow>Log("child-show")</OnShow>
</Scripts>
</Frame>
</Frames>
</Frame>
</Ui>"#,
);
let report = load_in(&s, &doc, "Test.xml", &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert_eq!(
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
"child-load,child-show,load,show"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The post-load OnShow is keyed on the frame's visibility at `0x76a38c`, and nothing records
/// an OnShow already given: a frame first shown by its own OnLoad gets that `Show()`'s OnShow
/// and then the hook's, while a frame still hidden gets none.
#[test]
fn a_frame_shown_by_its_onload_gets_two_onshows_and_a_hidden_one_none() {
let s = UiScript::new().unwrap();
s.run("LOG = {} function Log(tag) table.insert(LOG, tag) end")
.unwrap();
let doc = parse(
r#"<Ui>
<Frame name="ShownByLoad" hidden="true">
<Scripts>
<OnLoad>Log("a-load") this:Show() Log("a-load-end")</OnLoad>
<OnShow>Log("a-show")</OnShow>
</Scripts>
</Frame>
<Frame name="StaysHidden" hidden="true">
<Scripts>
<OnLoad>Log("b-load")</OnLoad>
<OnShow>Log("b-show")</OnShow>
<OnHide>Log("b-hide")</OnHide>
</Scripts>
</Frame>
</Ui>"#,
);
let report = load_in(&s, &doc, "Test.xml", &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert_eq!(
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
"a-load,a-show,a-load-end,a-show,b-load"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The post-load OnShow is the script alone (`0x76a3b3` calls the runner `0x702690`, not the
/// notify slot `+0x30`), so an `autoFocus` EditBox born visible runs its OnShow and still takes
/// no keyboard: the focus is the EditBox notify's (`0x77a750`), which only a transition reaches.
#[test]
fn the_post_load_onshow_runs_the_script_without_the_editbox_autofocus() {
let s = UiScript::new().unwrap();
let doc = parse(
r#"<Ui>
<EditBox name="BornBox" autoFocus="true">
<Size><AbsDimension x="100" y="20"/></Size>
<Scripts><OnShow>BOX_SHOWN = true</OnShow></Scripts>
</EditBox>
</Ui>"#,
);
let report = load_in(&s, &doc, "Test.xml", &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert!(s.eval::<bool>("return BOX_SHOWN == true").unwrap());
assert_eq!(s.focused_editbox_name(), None);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
}
#[test]
fn join_ref_resolves_relative_framexml_paths() {
use super::join_ref;
+44 -15
View File
@@ -123,18 +123,25 @@ fn aa_corners(x0: f32, x1: f32, y_bottom: f32, y_top: f32) -> [[f32; 2]; 4] {
/// The drawable pieces for a frame's resolved rect (y-up), in paint order: the background (layer
/// `BACKGROUND`), then the 8 border pieces (layer `BORDER`). Geometry `0x77e8d0`, UVs `0x77f0c0`.
pub fn pieces(frame: Rect, bd: &Backdrop) -> Vec<BackdropPiece> {
/// The pieces are textures anchored and sized in the frame's own units, so the layout multiplies
/// their geometry by the frame's effective `scale` (`0x76ac90`), as it does any region's. The
/// texcoord repeats are not: `0x77f0c0` divides the scaled rect by the stored, unscaled
/// `edgeSize` and `tileSize` (`0x77f101 fdiv [esi+0x48]`, `0x77f195`), so a scaled frame's edge
/// and background repeat the art more or less often than at scale 1.
pub fn pieces(frame: Rect, bd: &Backdrop, scale: f32) -> Vec<BackdropPiece> {
let mut out = Vec::with_capacity(9);
let e = bd.edge_size;
let e = bd.edge_size * scale;
// The texcoord divisor, unscaled.
let e_uv = bd.edge_size;
let (l, r, b, t) = (frame.left, frame.right, frame.bottom, frame.top);
// ── The background piece ──
if bd.has_bg() {
let (il, ir, it, ib) = (
bd.insets.left,
bd.insets.right,
bd.insets.top,
bd.insets.bottom,
bd.insets.left * scale,
bd.insets.right * scale,
bd.insets.top * scale,
bd.insets.bottom * scale,
);
// The four anchors, y-up. Bottom-right takes the top inset, as the reference does
// (`0x77e9ae` loads `+0x50`, top, not `+0x54`); invisible when the two are equal.
@@ -146,7 +153,11 @@ pub fn pieces(frame: Rect, bd: &Backdrop) -> Vec<BackdropPiece> {
];
// Untiled, no `SetTexCoord` runs and the background stretches over `[0,1]` (`0x77f0c0`).
let uvs = if bd.tile {
let period = if bd.tile_size != 0.0 { bd.tile_size } else { e };
let period = if bd.tile_size != 0.0 {
bd.tile_size
} else {
e_uv
};
let bg_w = (r - ir) - (l + il);
let bg_h = (t - it) - (b + ib);
let (wt, ht) = (bg_w / period, bg_h / period);
@@ -165,8 +176,8 @@ pub fn pieces(frame: Rect, bd: &Backdrop) -> Vec<BackdropPiece> {
// ── The 8 border pieces ──
if bd.has_border() {
// Edge strips tile `run` periods between the corners, 0 below two edges (`0x77f0c0`).
let w_run = ((r - l) / e - 2.0).max(0.0);
let h_run = ((t - b) / e - 2.0).max(0.0);
let w_run = ((r - l) / e_uv - 2.0).max(0.0);
let h_run = ((t - b) / e_uv - 2.0).max(0.0);
let mut edge = |x0: f32, x1: f32, y0: f32, y1: f32, uvs: [[f32; 2]; 4]| {
out.push(BackdropPiece {
@@ -311,7 +322,7 @@ mod tests {
fn piece_geometry_200x100_edge16_inset5() {
let frame = Rect::new(0.0, 0.0, 100.0, 200.0);
let bd = tooltip_backdrop(16.0, 5.0);
let ps = pieces(frame, &bd);
let ps = pieces(frame, &bd, 1.0);
assert_eq!(ps.len(), 9);
// Symmetric insets hide the bottom-right quirk.
@@ -365,7 +376,7 @@ mod tests {
},
..tooltip_backdrop(16.0, 0.0)
};
let bg = pieces(frame, &bd)[0];
let bg = pieces(frame, &bd, 1.0)[0];
assert_eq!(bg.corners[3], [3.0, 9.0]);
// BR takes the top inset, 7, not the bottom's 9.
assert_eq!(bg.corners[2], [196.0, 7.0]);
@@ -378,7 +389,7 @@ mod tests {
// 20/16 - 2 = -0.75.
let frame = Rect::new(0.0, 0.0, 20.0, 20.0);
let bd = tooltip_backdrop(16.0, 5.0);
let ps = pieces(frame, &bd);
let ps = pieces(frame, &bd, 1.0);
let left = ps[1]; // LEFT edge
assert_eq!(left.uvs[3][1], 0.0, "h_run clamped to 0"); // BL v
let top = ps[3]; // TOP edge
@@ -388,16 +399,34 @@ mod tests {
#[test]
fn presence_gates_pieces() {
let frame = Rect::new(0.0, 0.0, 100.0, 100.0);
assert!(pieces(frame, &Backdrop::default()).is_empty());
assert!(pieces(frame, &Backdrop::default(), 1.0).is_empty());
let bg_only = Backdrop {
bg_file: Some("bg".into()),
..Default::default()
};
assert_eq!(pieces(frame, &bg_only).len(), 1);
assert_eq!(pieces(frame, &bg_only, 1.0).len(), 1);
let border_only = Backdrop {
edge_file: Some("edge".into()),
..Default::default()
};
assert_eq!(pieces(frame, &border_only).len(), 8);
assert_eq!(pieces(frame, &border_only, 1.0).len(), 8);
}
/// A half-scale frame: the geometry halves, but `0x77f0c0` divides the scaled rect by the
/// unscaled sizes, so a 100-wide (on screen) edge repeats `100/16 - 2` times and the 96-wide
/// background `96/16` times.
#[test]
fn a_scaled_frames_texcoords_repeat_by_the_unscaled_sizes() {
let frame = Rect::new(0.0, 0.0, 50.0, 100.0);
let bd = tooltip_backdrop(16.0, 4.0);
let ps = pieces(frame, &bd, 0.5);
let bg = ps[0];
assert_eq!(bg.corners[0], [2.0, 48.0], "inset 4 units, 2 on screen");
assert_eq!(bg.uvs[2], [6.0, 46.0 / 16.0]);
// TOP, slice 2, rotated: `u` holds the run.
let top = ps[3];
assert_eq!(top.corners[0][1], 50.0);
assert_eq!(top.corners[2][1], 42.0, "8 thick on screen");
assert_eq!(top.uvs[0][1], 100.0 / 16.0 - 2.0);
}
}
@@ -798,6 +798,20 @@ mod tests {
}
}
/// The markers are textures of the picker, sized in its units, so a half-scale picker draws
/// them at half size: 5 and 24 on screen.
#[test]
fn the_markers_scale_with_the_picker() {
let mut s = picker();
s.run("TestPicker:SetScale(0.5); TestPicker:SetColorRGB(1, 0, 0)")
.unwrap();
s.resolve();
let wheel = marker_rect(&s, 5.0).expect("the wheel marker, 10 units at half scale");
assert!((wheel.top - wheel.bottom - 5.0).abs() < 0.01, "{wheel:?}");
let value = marker_rect(&s, 24.0).expect("the value marker, 48 units at half scale");
assert!((value.top - value.bottom - 7.0).abs() < 0.01, "{value:?}");
}
/// The extracted rect of the marker `width` wide: the two share one BLP and differ by size, 10
/// for the wheel's and 48 for the strip's.
fn marker_rect(s: &UiScript, width: f32) -> Option<crate::layout::Rect> {
@@ -64,7 +64,7 @@ fn caret(lua: &Lua, h: FrameHandle) -> bool {
placed
}
/// `OnCursorChanged(x, y, w, h)` (`0x77de1b`, in UI units via `0x77dd5f`), `x` the caret's advance
/// `OnCursorChanged(x, y, w, h)` (`0x77de1b`, in the box's own units via `0x77dd5f`), `x` the caret's advance
/// along its line, `y` minus the row index times the row pitch, `w` the constant 4.0 and `h` the
/// line height. The arguments are computed only for a box with the script (`0x77dd8c`), from the
/// box's advance table, which the installed font engine measures here when the text or font
@@ -103,6 +103,14 @@ fn cursor_changed(lua: &Lua, h: FrameHandle) -> bool {
let Some((x, y, pitch)) = args else {
return false;
};
// The advance table is in root units (measured at the box's scale, divided by the seam
// alone); the handler gets the box's own, the reference dividing by its effective scale
// (`0x77db23 fdiv [esi+0x7c]`).
let k = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
crate::script::object::eff_scale(&model, h)
};
let (x, y, pitch) = (x / k, y / k, pitch / k);
if let Err(e) = event::fire_widget_handler(
lua,
id,
@@ -509,3 +509,49 @@ fn on_cursor_changed_reports_the_first_row_at_plus_zero() {
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// [`Mono`] as the host answers a scaled box: glyphs 7 and lines 14 in the box's own units, the
/// table in root units (measured at the box's scale, divided by the seam alone).
struct ScaledMono;
impl TextMeasure for ScaledMono {
fn measure(&mut self, req: &MeasureRequest) -> (f32, f32, f32) {
Mono.measure(req)
}
fn editbox_advances(
&mut self,
req: &EditBoxAdvanceRequest,
) -> Option<(Vec<f32>, Vec<usize>, f32)> {
let (cum, rows, pitch) = Mono.editbox_advances(req)?;
let k = req.scale;
Some((cum.into_iter().map(|a| a * k).collect(), rows, pitch * k))
}
}
/// `OnCursorChanged` answers in the box's own units: the reference divides by its effective scale
/// (`0x77db23 fdiv [esi+0x7c]`), so a half-scale box reports what a full-scale one does.
#[test]
fn on_cursor_changed_answers_in_the_boxs_own_units() {
let mut s = script();
s.set_text_measurer(Box::new(ScaledMono));
logged_box(&mut s);
s.run(
r#"
E:SetScale(0.5)
E:SetScript("OnCursorChanged", function()
table.insert(LOG, format("cursor@%g,%g,%g", arg1, arg2, arg4))
end)
E:SetMultiLine(true)
E:SetText("abc\nd")
"#,
)
.unwrap();
s.tick(0.016);
assert_eq!(
take_log(&s),
"early update cursor@7,-14,14 text:abc\nd late",
"one glyph along the second row, a row pitch down, one pitch tall"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}

Some files were not shown because too many files have changed in this diff Show More