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:
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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, ¬hing_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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user