Compare commits

...

8 Commits

Author SHA1 Message Date
sindoring 95a9a7cdd5 лаунчер + деплой 2026-10-04 13:09:54 +04:00
sindoring f1c362f17f Merge branch 'main' of https://github.com/samwhosung/benilla into feat/moonwell-deploy 2026-10-04 11:35:17 +04:00
goran2711 622e1c3e7a fix: overhead names carry the player's PvP title, as 1.12.1 does (#792)
- capture: a name-rank scenario shoots a ranked player's overhead name line
- Overhead names carry the player's PvP title under UnitNamePlayerPVPTitle, as 1.12.1 does (#550)
- review: bundle the rank-title inputs; doc the snapshot and rank-19 limits
- nameplates: read rank strings per VM; test the composed name line
- pvp: correct the rank-title docs; convert the credit team without a cast
- Fix the titles row comment; name-rank reuses WOLF_POS, cites :900
- rank strings: the honor feed reads them off the VM; nameplates only reads the value

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
2026-10-04 11:04:32 +07:00
elipp aa103d5a65 fix: level-up gains read signed, so a loss prints -2, as 1.12.1 does (#791)
- Change `LevelUpInfo` to signed to match vanilla behavior
- Add parse test for negative values in SMSG_LEVELUP_INFO, update comments
- grammar
- level-up gains: cite the %d fire and vmangos's wrapped deltas; pin the reported line

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
2026-10-04 10:18:24 +07:00
Mathih13 4a389add47 fix: ignore a loot right-click while moving, as 1.12.1 does (#790)
- A loot right-click while moving does nothing, as in 1.12.1 (#760)
- loot gate: name the emitter's drain and write the landing deviation

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
2026-10-04 10:01:50 +07:00
goran2711 9bc7276dd8 fix: spell text $g takes the player's gender, as 1.12.1 does (#787)
- spell text: $g/$G take the active player's gender and the chosen form is space-trimmed, so a female character reads "her" (0x508180, 0x508214)
- spell text: trim the $g docs, drop the colon-less $g pin
- spell tooltips: $g reads the view's player store; nets cover gender
- item tooltips: re-substitute $-texts when the gender byte moves
- tests: share one real-spells loader beside Spells

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
2026-10-04 08:29:04 +07:00
Mathih13 cf891dc375 fix: close a loot window that opens while you move, as 1.12.1 does (#786)
- A loot window that opens while you move closes, as in 1.12.1 (#782)
- Set auto_loot in the LootConfig initializer (clippy field_reassign_with_default)
- Close a window opened on the move in the feed's pass, so it never paints

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
2026-10-03 17:36:21 +07:00
sindoring 6693fb5306 добавил сборку под наш сервер 2026-10-03 11:17:51 +04:00
45 changed files with 4476 additions and 392 deletions
+6
View File
@@ -1,5 +1,11 @@
# Rust build artifacts
/target/
/dist/
/.env
/.env.*
# Locally supplied MoonWell branding and patch source content.
/crates/moonwell/assets/*.ico
/src/Data/
**/*.rs.bk
# Cargo.lock IS committed (this workspace produces binaries).
Generated
+812 -51
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

+24 -6
View File
@@ -453,10 +453,11 @@
"RowWeatherDensity",
"RowDisplayMode", "RowVerticalSync", "RowRenderScale",
},
-- Guild Names is a child of Player Names (UIOptionsFrame.lua:703-709).
-- Guild Names and Player Titles are children of Player Names
-- (UIOptionsFrame.lua:703-709).
Nameplates = {
"RowEnemyPlates", "RowFriendlyPlates",
"RowPlayerNames", "RowGuildNames", "RowNpcNames", "RowOwnName",
"RowPlayerNames", "RowGuildNames", "RowPlayerTitles", "RowNpcNames", "RowOwnName",
},
-- 1.12's Display box by column, then Help; target-of-target sits under Status Text.
Interface = {
@@ -849,9 +850,13 @@
return
end
if key == "Nameplates" then
-- UIOptionsFrame.lua:703-709: Player Names off greys Player Guild Names.
BenillaOptionsRow_SetEnabled(BenillaOptionsFrameContainerBodyNameplatesRowGuildNames,
GetCVar("UnitNamePlayer") ~= "0")
-- UIOptionsFrame.lua:703-709: Player Names off greys Player Guild Names and
-- Player Titles.
local on = GetCVar("UnitNamePlayer") ~= "0"
BenillaOptionsRow_SetEnabled(
BenillaOptionsFrameContainerBodyNameplatesRowGuildNames, on)
BenillaOptionsRow_SetEnabled(
BenillaOptionsFrameContainerBodyNameplatesRowPlayerTitles, on)
return
end
if key == "Interface" then
@@ -2638,11 +2643,24 @@
this.parentRow = "RowPlayerNames"
</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>
@@ -1297,6 +1297,50 @@ pub(super) fn seed_ui_fixture(
// A plated unit draws no floating name, so enemy plates go off.
set_enemy_plates(script.as_deref(), false);
}
UiFixture::NameRank => {
use benilla_protocol::messages::ObjectFields;
// The self player at the eye, as `name-water`; the subject is the other player's name.
const SELF_GUID: u64 = 0x51;
names.insert_player(SELF_GUID, "Benilla".into(), None);
commands.spawn((
crate::net::ObjectStore(ObjectFields::from_pairs(&[
(34, 2), // UNIT_FIELD_LEVEL
(35, 1), // UNIT_FIELD_FACTIONTEMPLATE: human
(36, 0x0101), // UNIT_FIELD_BYTES_0: race human, class warrior
])),
crate::net::SelfPlayer,
crate::net::Guid(SELF_GUID),
Transform::from_translation(wow_to_bevy(scenario.eye)),
));
// The subject at the `vplates` wolf's dry spot: a human player holding rank,
// `PLAYER_BYTES_3` byte 3 (`PLAYER_BYTES_3_OFFSET_HONOR_RANK`, vmangos
// `HonorMgr.cpp:900`) at internal 7: the visual rank 3, "Sergeant" (team 1 off race
// 1's `ChrRaces` row).
const RANKED_GUID: u64 = 0x52;
names.insert_player(RANKED_GUID, "Bob".into(), None);
commands.spawn((
crate::net::Guid(RANKED_GUID),
crate::net::NetEntity {
kind: benilla_protocol::EntityKind::Player,
// HumanMale, the body a human `UNIT_FIELD_DISPLAYID` carries.
display_id: Some(49),
scale: 1.0,
},
crate::net::ObjectStore(ObjectFields::from_pairs(&[
(34, 60), // UNIT_FIELD_LEVEL
(35, 1), // UNIT_FIELD_FACTIONTEMPLATE: human
(36, 0x0101), // UNIT_FIELD_BYTES_0: race human, class warrior, male
(195, 7 << 24), // PLAYER_BYTES_3 byte 3: the current honor rank, internal 7
])),
Transform {
translation: wow_to_bevy(WOLF_POS),
..default()
},
Visibility::default(),
));
// A plated unit draws no floating name, so enemy plates go off.
set_enemy_plates(script.as_deref(), false);
}
// The lighting matrix: one spawn with a streamed entity's component set, anonymous and
// with plates off, so no glyph rides over the body.
UiFixture::Subject { kind, at } => {
@@ -111,6 +111,10 @@ pub(super) enum UiFixture {
/// `water-noon` camera). Deep water is opaque (`WATER_DEEP_ALPHA` 1.0), so a name sorted before
/// the liquid is painted out; it must read at full strength.
NameWater,
/// The ranked player's overhead name line: a level-60 human holding honor rank 7 (internal;
/// the visual rank 3, "Sergeant") at the fixture's dry subject spot, framed close off the
/// `vplates` camera's bearing.
NameRank,
/// One cell of the lighting matrix: a creature or GameObject spawned through the live path at
/// `at` ([`SubjectKind`], the note above [`SUBJECT_SUN`]).
Subject {
@@ -910,6 +914,16 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: Some(UiFixture::NameWater),
},
// The ranked player's name line at the fixture's subject spot, 5 yd out level with it on the
// `vplates` camera's horizontal bearing, so the body and its name line fill the frame.
Scenario {
name: "name-rank",
map: Some(MAP_AZEROTH),
eye: [-8953.92, -135.53, 85.5],
look: [-8949.95, -132.49, 85.5],
minute: 720,
ui: Some(UiFixture::NameRank),
},
];
/// The `name-close` subject: the `name-water` wolf's name, orbited at any distance. World text is
@@ -111,10 +111,13 @@ pub(crate) mod move_flags {
/// Any horizontal direction bit: the client's `[9e8] & 0xf` gate.
pub const ANY_MOVE: u32 = FORWARD | BACKWARD | STRAFE_LEFT | STRAFE_RIGHT;
/// The low byte: forward, back, strafe, turn and pitch, the loot response's test (`0x5ebc40`).
pub const STEERING: u32 = 0xff;
/// The bits that get a mover integrated at all, the client's `0x20ff` (`0x616e20`, `0x6166f5`):
/// without one a mover keeps its last packet's pose, and a flag-less unit is not even in the
/// mover list (`0x618940`). Swim and the mode bits are not among them.
pub const INTEGRATED: u32 = 0xff | FALLING;
pub const INTEGRATED: u32 = STEERING | FALLING;
/// The committed-lower-body test routing a one-shot to the masked overlay (`0x5fe6dc`); the
/// client's separate mouse-turn test (`d58 & 0x1800`) reads a field benilla does not model.
+9 -2
View File
@@ -1050,10 +1050,15 @@ mod tests {
assert_eq!(d["UnitNameNPC"] != 0.0, names.npc);
assert_eq!(d["UnitNameOwn"] != 0.0, names.own);
assert_eq!(d["UnitNamePlayerGuild"] != 0.0, names.player_guild);
assert_eq!(d["UnitNamePlayerPVPTitle"] != 0.0, names.player_pvp_title);
assert!(
names.player && !names.npc && !names.own && names.player_guild,
names.player
&& !names.npc
&& !names.own
&& names.player_guild
&& names.player_pvp_title,
"the binary registers UnitNamePlayer \"1\", NPC \"0\", Own \"0\", \
PlayerGuild \"1\""
PlayerGuild \"1\", PlayerPVPTitle \"1\""
);
let camera_opts = crate::player::camera_dynamics::CameraOptions::default();
assert_eq!(d["cameraPivot"] != 0.0, camera_opts.pivot);
@@ -1324,6 +1329,8 @@ mod tests {
assert!(!res::<NameConfig>(&app).npc);
apply(&mut app, "unitnameown", "1");
assert!(res::<NameConfig>(&app).own);
apply(&mut app, "unitnameplayerpvptitle", "0");
assert!(!res::<NameConfig>(&app).player_pvp_title);
apply(&mut app, "ChatBubbles", "0");
assert!(!res::<BubbleConfig>(&app).all);
apply(&mut app, "chatbubblesparty", "0");
+4 -2
View File
@@ -294,9 +294,11 @@ pub(crate) const REGISTERED: &[Registered] = &[
same("UnitNameOwn", "0"),
// `UnitNamePlayerGuild` (`0x86c680`, "1", mask bit `0x10`) is not a show gate: `ShouldShowName`
// (`0x6070a0`) reads bits `0x1/0x2/0x4`, and this gates the `"\n<%s>"` guild line at
// `0x609085`. `UnitNamePlayerPVPTitle` (bit `0x20`, "1") has no row: nothing here draws the
// rank prefix.
// `0x609085`.
same("UnitNamePlayerGuild", "1"),
// `UnitNamePlayerPVPTitle` (`0x86c668`, "1", mask bit `0x20`): the a4 rank prefix on the main
// name line (`0x609370`), `PVP_RANK_%d_%d` through `0x5efe60`.
same("UnitNamePlayerPVPTitle", "1"),
// `WorldDetail` is no 1.12 CVar but the `GetWorldDetail`/`SetWorldDetail` verb name
// (`OptionsFrame.lua:27`); stops 0/1/2 are `frillDensity` 16/32/48. `SetWorldDetail 0x488dd0`
// also writes `SmallCull` {0.07, 0.04, 0.01}, and `GetWorldDetail` reads only `SmallCull`,
+2
View File
@@ -1149,6 +1149,8 @@ pub(crate) mod schedule_tests {
"both packets answer the `CMSG_LOGOUT_REQUEST`/cancel the game menu sent"),
("ui_loot/mod.rs", "drain_loot", Because::Deliberate,
"the pre-VM take of `LootMoveStart` is documented at the line and publishes nothing to the VM; the event-firing takes are Lua's queues"),
("ui_loot/mod.rs", "feed_loot", Because::PlayerRoundTrip,
"it takes `LootMoveStart` only on a window it has just opened, which answers a loot click"),
("ui_loot_roll.rs", "drain_loot_rolls", Because::FilledByVm,
"only a Need/Greed/Pass click queues a confirm or vote, held in the VM"),
("ui_loot_roll.rs", "feed_loot_rolls", Because::PlayerRoundTrip,
+361 -97
View File
@@ -12,12 +12,18 @@
//! - Show gate (`ShouldShowName`, `0x6070a0`): the own unit by `UnitNameOwn`, before the target
//! rescue; the current target (`[0xb4e2d8]`, the selection) regardless of CVars; a dead creature
//! only through that rescue; others by `UnitNamePlayer` or `UnitNameNPC`.
//! - Lines (`0x608f50`): an NPC's name and `<Subname>`; a player's `<AFK>`/`<DND>`/`<GM>` prefixes,
//! name and `<Guild>`. The guild (a5) and subname (a6) slots share the format `"\n<%s>"`
//! (`0x860f9c`) and are never both reached; a5 alone is CVar-gated (`0x609085`).
//! - Lines (`0x608f50`): an NPC's name and `<Subname>`; a player's `<AFK>`/`<DND>`/`<GM>`
//! prefixes, the a4 rank prefix (`UnitNamePlayerPVPTitle`, bit `0x20`) and name, and `<Guild>`.
//! The guild (a5) and subname (a6) slots share the format `"\n<%s>"` (`0x860f9c`) and are never
//! both reached; a5 alone is CVar-gated (`0x609085`).
//! - The a4 slot is `0x609370`'s ranked-player leg (A, `0x6093a5`): `UNIT_PVP_NAME` filled rank
//! first, `PVP_RANK_<rank>_<team>` off the unit's public `PLAYER_BYTES_3` byte 3 and its race's
//! team digit, and never a creature's.
//!
//! Not built: the a4 PvP rank prefix (`UnitNamePlayerPVPTitle`, bit `0x20`), whose faction side
//! `ui_unit` does not resolve for another player; a7 and a8 have no cross-realm wire here.
//! Not built: `0x609370`'s civilian leg (`0x609449`, `PVP_RANK_CIVILIAN` and a space before a
//! hostile PvP-flagged civilian NPC's name) and its city-protector line (`0x6093ef`, `"\n"` and
//! `PVP_MEDAL<n>` for a set `PLAYER_BYTES_3` byte 2, which vmangos's `.character citytitle` sets);
//! a7 and a8 have no cross-realm wire here.
use std::collections::HashMap;
@@ -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.
@@ -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 {
@@ -448,23 +538,23 @@ pub(crate) fn drive_nameplates(
};
seen.insert(entity);
let key = title_key(&name_cfg, &pvp_strings, net, store);
match plates.live.get(&entity) {
// The steady frame compares the cached stack in place, allocating nothing.
Some((_, l, c)) if *c == color && lines_current(l, flags, name, bracketed) => {}
// The steady frame compares the inputs in place, allocating nothing; a rebuild from
// these inputs is the stack `LiveName` holds.
Some(live)
if live.paint == color
&& live.flags == flags
&& live.key == key
&& live.name == name
&& live.bracketed.as_deref() == bracketed => {}
stale => {
if let Some((old, _, _)) = stale {
let old = *old;
if let Ok(mut e) = commands.get_entity(old) {
if let Some(live) = stale {
if let Ok(mut e) = commands.get_entity(live.plate) {
e.despawn();
}
}
// Keep in sync with `lines_current`; its differential test pins this shape.
let prefix = flag_prefix(flags);
let mut lines = vec![if prefix.is_empty() {
name.to_owned()
} else {
format!("{prefix}{name}")
}];
let mut lines = vec![name_line(flags, key, &pvp_strings, name)];
if let Some(bracketed) = bracketed {
lines.push(format!("<{bracketed}>"));
}
@@ -495,16 +585,27 @@ pub(crate) fn drive_nameplates(
let plate = commands
.spawn((Mesh3d(mesh), MeshMaterial3d(material), place, NamePlate))
.id();
plates.live.insert(entity, (plate, lines, color));
plates.live.insert(
entity,
LiveName {
plate,
lines,
paint: color,
flags,
name: name.to_owned(),
bracketed: bracketed.map(str::to_owned),
key,
},
);
}
}
}
// Plates whose unit despawned or gated off this frame.
plates.live.retain(|unit, (plate, _, _)| {
plates.live.retain(|unit, live| {
if seen.contains(unit) {
true
} else {
if let Ok(mut e) = commands.get_entity(*plate) {
if let Ok(mut e) = commands.get_entity(live.plate) {
e.despawn();
}
false
@@ -540,8 +641,8 @@ fn place_nameplates(
};
let facing = cam_tf.rotation;
let blend = 1.0 - (-ROCK_MEAN_RATE * trace.1.delta_secs()).exp();
for (&unit, (plate, ..)) in plates.live.iter() {
let (Ok(tf), Ok((mut ptf, mut pglobal))) = (units.get(unit), plate_tfs.get_mut(*plate))
for (&unit, live) in plates.live.iter() {
let (Ok(tf), Ok((mut ptf, mut pglobal))) = (units.get(unit), plate_tfs.get_mut(live.plate))
else {
continue; // spawned this frame and not yet flushed, or the unit is despawning
};
@@ -603,13 +704,14 @@ pub(crate) struct NameplatesPlugin;
#[derive(Resource)]
struct NameAnchorTrace(bool);
/// The overhead-name rows' change callback: the name trio and the guild line.
/// The overhead-name rows' change callback: the name trio, the guild line and the rank prefix.
pub(crate) fn on_cvar(ev: On<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 +724,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 +756,8 @@ fn drop_stale_glyph_caches(plates: &mut Nameplates, generation: u64, commands: &
plates.meshes.len(),
plates.live.len(),
);
for (plate, _, _) in plates.live.values() {
if let Ok(mut e) = commands.get_entity(*plate) {
for live in plates.live.values() {
if let Ok(mut e) = commands.get_entity(live.plate) {
e.despawn();
}
}
@@ -750,40 +853,181 @@ mod tests {
const RED: Color = Color::linear_rgb(1.0, 0.0, 0.0);
/// Pinned against the build over every pair of cases, both ways: an unflagged `<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 +1044,18 @@ mod tests {
.spawn((Transform::default(), GlobalTransform::default(), NamePlate))
.id();
let mut plates = Nameplates::default();
plates
.live
.insert(unit, (plate, vec!["Bob".to_string()], NamePaint::Flash));
plates.live.insert(
unit,
LiveName {
plate,
lines: vec!["Bob".to_string()],
paint: NamePaint::Flash,
flags: 0,
name: "Bob".into(),
bracketed: None,
key: None,
},
);
world.insert_resource(plates);
// First seat: the anchor fallback (no model) is the unit's own translation.
@@ -847,7 +1100,18 @@ mod tests {
};
plates.meshes.insert(lines.clone(), Handle::default());
plates.materials.insert(NamePaint::Flash, Handle::default());
plates.live.insert(unit, (plate, lines, NamePaint::Flash));
plates.live.insert(
unit,
LiveName {
plate,
lines,
paint: NamePaint::Flash,
flags: 0,
name: "Young Wolf".into(),
bracketed: None,
key: None,
},
);
plates
}
+194 -52
View File
@@ -224,6 +224,7 @@ pub(crate) struct Dispatch<'w, 's> {
service: ServiceArms<'w>,
feedback: Feedback<'w>,
auto: crate::player::AutoMove<'w, 's>,
player: Res<'w, crate::player::Player>,
}
/// The Loot walk's stop radius: the arm (`0x611130`) takes the square root (`0x6111ab`) of what
@@ -246,6 +247,11 @@ impl Dispatch<'_, '_> {
self.stores.get(me).ok().map(|(s, _)| s)
}
/// Any `0x20ff` bit in our last streamed movement word: a direction, turn, pitch or fall.
fn moving(&self) -> bool {
self.player.move_flags() & crate::creature_anim::move_flags::INTEGRATED != 0
}
/// The reference's one mounted predicate, the player's `UNIT_FIELD_MOUNTDISPLAYID`.
fn mounted(&self) -> bool {
self.self_store()
@@ -449,10 +455,13 @@ impl Dispatch<'_, '_> {
}
}
/// `0x5df130`: `CMSG_LOOT` at a lootable corpse, walked to first when `walk` and beyond its
/// 5 yd (`0x5df1c3`), stopping at [`loot_stop`] of 5. A walk that does not start falls through
/// to the send.
/// `0x5df130`: `CMSG_LOOT` at a lootable corpse, never while [`Self::moving`] (`0x5df173`),
/// walked to first when `walk` and beyond its 5 yd (`0x5df1c3`), stopping at [`loot_stop`] of
/// 5. A walk that does not start falls through to the send.
fn loot_corpse(&mut self, entity: Entity, guid: u64, walk: bool) {
if self.moving() {
return;
}
if walk
&& self
.dist_sq(entity)
@@ -474,10 +483,10 @@ impl Dispatch<'_, '_> {
}
/// `0x5df2a0`: `CMSG_LOOT` at a dead unit, which `can_loot` (`CanLootNow 0x5ec110`) must
/// allow, walked to first when `walk` and beyond melee reach. A walk that does not start falls
/// through to the send.
/// allow, never while [`Self::moving`] (`0x5df2e9`), walked to first when `walk` and beyond
/// melee reach. A walk that does not start falls through to the send.
fn loot_unit(&mut self, entity: Entity, guid: u64, can_loot: bool, walk: bool) {
if !can_loot {
if !can_loot || self.moving() {
return;
}
let reach = self.melee_reach(entity);
@@ -987,6 +996,14 @@ pub(super) fn act_on_arrival(
) {
use crate::player::{ApproachVerb, Subject};
use benilla_protocol::EntityKind;
// Owed while any `0x20ff` bit is set: the reference drains it from the movement emitter
// `0x60e0a0` only at a stop, strafe stop, root or unroot ack that leaves none (`0x60e352`), never
// at a landing. Deviation: it runs on the first frame with none, so an arrival that sends no stop
// (mid-air, rooted, under a held turn key) acts at once or on landing or release, where the
// reference waits for the next ground stop and can act long after, wherever the player then is.
if dispatch.moving() {
return;
}
let Some((verb, guid)) = dispatch.auto.approach.arrived.take() else {
return;
};
@@ -1430,7 +1447,7 @@ pub(crate) fn service_action(
/// Drain the guid-scoped deselects ([`DeselectGuid`]). `ClearTarget()`, the last leg of the Esc
/// chain (`UIParent.lua:1492`), is a script call and lands in call order
/// ([`crate::script_calls`]).
pub(super) fn clear_target_requests(
pub(crate) fn clear_target_requests(
mut selection: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam,
engaged: Query<(), (With<Engaged>, With<SelfPlayer>)>,
@@ -2551,33 +2568,8 @@ mod tests {
/// `0x5df2a0`: beyond melee reach the walk comes first and nothing is looted yet.
#[test]
fn a_far_body_to_loot_is_walked_to_before_the_loot() {
const BODY: u64 = 0xB0D7;
const F_DYNAMIC_FLAGS: u16 = 143;
let (mut world, _vendor, rx) = walking_world(Vec3::new(14.0, 0.0, 0.0), true);
let body = world
.spawn((
Guid(BODY),
store(&[(F_HEALTH, 0), (F_MAXHEALTH, 100), (F_DYNAMIC_FLAGS, 0x1)]),
Transform::from_xyz(0.0, 0.0, -12.0),
))
.id();
*world.resource_mut::<PressPick>() = PressPick {
hovered: Hovered {
target: Some(body),
guid: Some(BODY),
distance: 12.0,
..Hovered::default()
},
cursor: WorldCursor {
kind: cursor_mode::CursorKind::Pickup,
unable: false,
},
..PressPick::default()
};
world
.resource_mut::<Messages<WorldRightClick>>()
.write(WorldRightClick);
world.run_system_once(act_on_right_click).unwrap();
let (mut world, body, corpse, rx) = loot_world(12.0, 0, true);
right_click_loot(&mut world, loot_hover(false, body, corpse, 12.0));
assert!(world.resource::<crate::player::Approach>().active());
assert!(!rx
.try_iter()
@@ -2589,24 +2581,8 @@ mod tests {
/// for a unit.
#[test]
fn a_loot_walk_stops_at_the_square_root_of_the_melee_reach() {
const BODY: u64 = 0xB0D7;
const CORPSE: u64 = 0xC0D5;
const F_DYNAMIC_FLAGS: u16 = 143;
const F_COMBAT_REACH: u16 = 130;
let loot_click = |world: &mut World, hovered: Hovered| {
*world.resource_mut::<PressPick>() = PressPick {
hovered,
cursor: WorldCursor {
kind: cursor_mode::CursorKind::Pickup,
unable: false,
},
..PressPick::default()
};
world
.resource_mut::<Messages<WorldRightClick>>()
.write(WorldRightClick);
world.run_system_once(act_on_right_click).unwrap();
};
// A unit of reach 4 against our default 1.5: 4 + 1.5 + 1.3333 = 6.8333.
let (mut world, _vendor, _rx) = walking_world(Vec3::new(14.0, 0.0, 0.0), true);
let body = world
@@ -2621,7 +2597,7 @@ mod tests {
Transform::from_xyz(0.0, 0.0, -12.0),
))
.id();
loot_click(
right_click_loot(
&mut world,
Hovered {
target: Some(body),
@@ -2647,7 +2623,7 @@ mod tests {
Transform::from_xyz(0.0, 0.0, -12.0),
))
.id();
loot_click(
right_click_loot(
&mut world,
Hovered {
corpse: Some(corpse),
@@ -3002,4 +2978,170 @@ mod tests {
);
}
}
const BODY: u64 = 0xB0D7;
const CORPSE: u64 = 0xC0D5;
/// A dead lootable unit and a lootable corpse `at` yd off and the vendor in reach, with our last
/// streamed movement word `flags` and Click to Move set as `walk`.
fn loot_world(
at: f32,
flags: u32,
walk: bool,
) -> (
World,
Entity,
Entity,
crossbeam_channel::Receiver<ClientCommand>,
) {
const F_DYNAMIC_FLAGS: u16 = 143;
let (mut world, _vendor, rx) = walking_world(Vec3::new(2.5, 0.0, 0.0), walk);
world.insert_resource(crate::player::Player::with_move_flags(flags));
let body = world
.spawn((
Guid(BODY),
store(&[(F_HEALTH, 0), (F_MAXHEALTH, 100), (F_DYNAMIC_FLAGS, 0x1)]),
Transform::from_xyz(0.0, 0.0, -at),
crate::net::NetEntity {
kind: benilla_protocol::EntityKind::Unit,
display_id: None,
scale: 1.0,
},
))
.id();
world
.resource_mut::<crate::net::GuidIndex>()
.0
.insert(BODY, body);
let corpse = world
.spawn((
Guid(CORPSE),
store(&[(benilla_protocol::field::FIELD_CORPSE_DYNAMIC_FLAGS, 0x1)]),
Transform::from_xyz(0.0, 0.0, at),
))
.id();
(world, body, corpse, rx)
}
fn right_click_loot(world: &mut World, hovered: Hovered) {
*world.resource_mut::<PressPick>() = PressPick {
hovered,
cursor: WorldCursor {
kind: cursor_mode::CursorKind::Pickup,
unable: false,
},
..PressPick::default()
};
world
.resource_mut::<Messages<WorldRightClick>>()
.write(WorldRightClick);
world.run_system_once(act_on_right_click).unwrap();
}
/// The press over the corpse when `on_corpse`, else over the dead unit.
fn loot_hover(on_corpse: bool, body: Entity, corpse: Entity, at: f32) -> Hovered {
let (unit, object) = if on_corpse {
(None, Some(corpse))
} else {
(Some(body), None)
};
Hovered {
target: unit,
guid: unit.map(|_| BODY),
corpse: object,
corpse_guid: object.map(|_| CORPSE),
distance: at,
..Hovered::default()
}
}
/// `0x5df2e9` and `0x5df173`: both loot senders return silently on any `0x20ff` bit, a
/// direction, turn, pitch or fall; swimming and walk mode are outside the mask.
#[test]
fn a_loot_click_while_moving_sends_nothing() {
use crate::creature_anim::move_flags as f;
for (flags, loots) in [
(0, true),
(f::FORWARD, false),
(f::BACKWARD, false),
(f::STRAFE_LEFT, false),
(f::TURN_RIGHT, false),
(f::FALLING, false),
(f::FORWARD | f::FALLING, false),
(f::SWIMMING, true),
(f::WALK_MODE, true),
] {
for on_corpse in [false, true] {
let (mut world, body, corpse, rx) = loot_world(2.0, flags, false);
right_click_loot(&mut world, loot_hover(on_corpse, body, corpse, 2.0));
let looted = rx
.try_iter()
.any(|c| matches!(c, ClientCommand::Loot { .. }));
let latched = world.resource::<crate::ui_loot::LootLatch>().0.is_some();
assert_eq!(
(looted, latched),
(loots, loots),
"flags {flags:#x}, corpse {on_corpse}"
);
}
}
}
/// The movement test comes before the walk (`0x5df2f6`, `0x5df180`): a far body or corpse
/// clicked on the run starts no Click to Move walk.
#[test]
fn a_far_loot_click_while_moving_starts_no_walk() {
use crate::creature_anim::move_flags as f;
for (flags, walks) in [(0, true), (f::FORWARD, false), (f::FALLING, false)] {
for on_corpse in [false, true] {
let (mut world, body, corpse, rx) = loot_world(12.0, flags, true);
right_click_loot(&mut world, loot_hover(on_corpse, body, corpse, 12.0));
assert_eq!(
world.resource::<crate::player::Approach>().active(),
walks,
"flags {flags:#x}, corpse {on_corpse}"
);
assert!(!rx
.try_iter()
.any(|c| matches!(c, ClientCommand::Loot { .. })));
}
}
}
/// `0x60e352`: the owed verb (`0x60fa20`) runs only once no `0x20ff` bit is left, and the cell
/// stays armed until then.
#[test]
fn a_verb_owed_on_arrival_waits_for_a_word_with_no_move_bit() {
use crate::creature_anim::move_flags as f;
use crate::player::ApproachVerb;
for (verb, guid) in [(ApproachVerb::Loot, BODY), (ApproachVerb::Talk, VENDOR)] {
let (mut world, _body, _corpse, rx) = loot_world(2.0, f::FALLING, true);
world.resource_mut::<crate::player::Approach>().arrived = Some((verb, guid));
world.run_system_once(act_on_arrival).unwrap();
assert_eq!(rx.try_iter().count(), 0, "{verb:?} mid-air");
assert_eq!(
world.resource::<crate::player::Approach>().arrived,
Some((verb, guid)),
"{verb:?} stays owed"
);
world.insert_resource(crate::player::Player::with_move_flags(0));
world.run_system_once(act_on_arrival).unwrap();
let sent: Vec<ClientCommand> = rx.try_iter().collect();
assert!(
match verb {
ApproachVerb::Loot =>
matches!(sent.as_slice(), [ClientCommand::Loot { guid: BODY }]),
_ => matches!(
sent.as_slice(),
[ClientCommand::ListInventory { guid: VENDOR }]
),
},
"{verb:?} landed: {sent:?}"
);
assert!(world
.resource::<crate::player::Approach>()
.arrived
.is_none());
}
}
}
+15
View File
@@ -144,6 +144,21 @@ impl Spells {
}
}
/// The 5875 spell data; `None` skips where the install is absent.
#[cfg(test)]
pub(crate) fn real_spells() -> Option<Spells> {
let data = benilla_formats::wow_data_or_skip!(None);
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
Some(Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
forms: benilla_formats::load_shapeshift_forms(&mut chain).expect("forms"),
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("ranges"),
cast_times: benilla_formats::load_spell_cast_times(&mut chain).expect("cast times"),
durations: benilla_formats::load_spell_durations(&mut chain).expect("durations"),
radii: benilla_formats::load_spell_radii(&mut chain).expect("radii"),
})
}
/// The reference's learned-ability latches: at learn time `0x4b25e0` stores a spell's id by its
/// `Effect[0]`, and the unlearn path `0x4b2c50` clears it. The skin cursor requires the latch
/// (`0x482589`), so a player who never learned Skinning gets no skin cursor.
+33 -35
View File
@@ -2288,31 +2288,9 @@ fn level_up_gains_are_matched_by_level_and_a_miss_is_not_an_absence() {
);
}
/// Stock `ChatFrame_OnEvent` prints the whole level-up block from `PLAYER_LEVEL_UP`
/// (`ChatFrame.lua:1283-1323`); the app composes none of it.
#[test]
fn the_ding_block_is_printed_once() {
let _data = benilla_formats::wow_data_or_skip!();
use benilla_protocol::messages::LevelUpInfo;
let mut s = chat_vm();
let mut log = super::ChatLog::default();
let info = LevelUpInfo {
level: 10,
health: 22,
powers: [15, 0, 0, 0, 0],
stats: [1, 0, 0, 0, 0],
};
// The packet's apply parks the gains and prints nothing.
log.push_level_up_gains(&info, 1);
let before = lines_in_window(&s);
assert_eq!(
lines_in_window(&s) - before,
0,
"the app composes no ding line of its own"
);
// Tap the window's `AddMessage` to read the block itself.
/// Fires `PLAYER_LEVEL_UP` with the nine reference args and returns every line the stock handler
/// added to `ChatFrame1`.
fn ding_lines(s: &mut benilla_ui::script::UiScript, args: [i64; 9]) -> Vec<String> {
s.run(
r#"
DingLines = {}
@@ -2325,23 +2303,27 @@ fn the_ding_block_is_printed_once() {
)
.unwrap();
// The event `ui_unit` fires, with the reference's nine arguments.
let args: Vec<benilla_ui::script::ScriptValue> = [10i64, 22, 15, 1, 1, 0, 0, 0, 0]
let args = args
.into_iter()
.map(benilla_ui::script::ScriptValue::Int)
.collect();
s.fire_event("PLAYER_LEVEL_UP", args);
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
assert_eq!(
lines_in_window(&s) - before,
4,
"LEVEL_UP, the health/mana pair, CHAR_POINTS, and one STAT — once each"
);
// The singular `LEVEL_UP_CHAR_POINTS` (`GetText`'s plural pick) and one `LEVEL_UP_STAT`.
let lines: Vec<String> = (1..=4)
let n: usize = s.eval("return table.getn(DingLines)").unwrap();
(1..=n)
.map(|i| s.eval::<String>(&format!("return DingLines[{i}]")).unwrap())
.collect();
.collect()
}
/// Stock `ChatFrame_OnEvent` prints the whole level-up block from `PLAYER_LEVEL_UP`
/// (`ChatFrame.lua:1283-1323`), once; the app composes none of it. The singular
/// `LEVEL_UP_CHAR_POINTS` is `GetText`'s plural pick.
#[test]
fn the_ding_block_is_printed_once() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_vm();
let lines = ding_lines(&mut s, [10, 22, 15, 1, 1, 0, 0, 0, 0]);
assert_eq!(
lines,
[
@@ -2353,6 +2335,22 @@ fn the_ding_block_is_printed_once() {
);
}
/// A loss reaches stock signed, as the reference's `%d` fire passes it (`0x5e413e`): the health
/// line prints it, and a lost stat prints no line (`if ( argN > 0 )`, `ChatFrame.lua:1302-1321`).
#[test]
fn a_negative_gain_prints_signed() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_vm();
let lines = ding_lines(&mut s, [2, -2, 5, 0, -1, 0, 0, 0, 0]);
assert_eq!(
lines,
[
"Congratulations, you have reached level 2!",
"You have gained -2 hit points and 5 mana.",
]
);
}
/// On `CHARACTER_POINTS_CHANGED` with `arg2 > 0`, stock `ChatFrame_OnEvent` prints
/// `LEVEL_UP_SKILL_POINTS` (`ChatFrame.lua:1324-1334`).
#[test]
+1
View File
@@ -135,6 +135,7 @@ fn feed_craft(
lookup: &|id| spells.catalog.get(id),
mods: Some(&*spell_mods),
unmodified_points: false,
gender: &|| store.0.unit_gender().unwrap_or(0),
home_area: &|| None,
global: &global,
printf: &crate::ui_script::token_printf,
+104 -1
View File
@@ -13,6 +13,8 @@
//! Deviation: the reference watches the whole `PLAYER_BYTES_3` dword, so a drunkenness change also
//! fires `PLAYER_PVP_RANK_CHANGED`; we watch byte 3 alone, because that repaint is identical.
use std::collections::HashMap;
use bevy::prelude::*;
use benilla_ui::script::{HonorState, InspectHonorData, ScriptValue, UiScript};
@@ -64,10 +66,43 @@ impl Plugin for UiHonorPlugin {
net::register(app);
app.init_resource::<InspectHonor>()
.init_resource::<HonorFeedState>()
.add_systems(Update, feed_honor.in_set(UiFeed));
.init_resource::<crate::nameplates::PvpNameStrings>()
.add_systems(Update, feed_honor.in_set(UiFeed))
// A VM holder, so after the tick; before the driver, so a new VM's strings reach the
// lines the frame it arrives.
.add_systems(
Update,
feed_pvp_name_strings
.after(crate::ui_script::UiInput)
.before(crate::nameplates::drive_nameplates),
);
}
}
/// The overhead name's rank strings ([`crate::nameplates::PvpNameStrings`]), read off a VM the
/// first frame it is in the world. Every VM is installed whole, outside `Update` (the boot VM with
/// `GlobalStrings.lua` run, a world VM after its FrameXML and addon load), so the read sees what
/// the load defined; any other frame pays one compare.
fn feed_pvp_name_strings(
script: Option<NonSend<UiScript>>,
mut strings: ResMut<crate::nameplates::PvpNameStrings>,
) {
let Some(script) = script else {
return;
};
if strings.session() != Some(script.session()) {
strings.set(script.session(), pvp_name_strings(&script));
}
}
/// The [`benilla_ui::script::pvp_name_global_keys`] that `script` defines, with their values.
fn pvp_name_strings(script: &UiScript) -> HashMap<String, String> {
benilla_ui::script::pvp_name_global_keys()
.into_iter()
.filter_map(|key| benilla_ui::strings::global(script.lua(), &key).map(|value| (key, value)))
.collect()
}
/// Read the honor block off our own descriptor, `None` until it streams. That decides only when
/// the first push and its events happen: the reference reads a field it was never sent as 0
/// (`0x51a4b0`-`0x51a7c0`), and so do our bindings.
@@ -264,4 +299,72 @@ mod tests {
fn the_first_push_fires_both() {
assert_eq!(events_for(None, &state()), (true, true));
}
fn vm(src: &str) -> UiScript {
let script = UiScript::new().expect("a VM");
script.run(src).expect("globals set");
script
}
/// The read takes the overhead name's keys a VM defines, `_FEMALE` twins included, and
/// nothing else.
#[test]
fn the_rank_strings_read_takes_only_the_title_keys() {
let strings = pvp_name_strings(&vm(r#"UNIT_PVP_NAME = "%s %s"
PVP_RANK_7_1 = "Sergeant"
PVP_RANK_7_1_FEMALE = "Sergeant (f)"
GARBAGE_KEY = "x""#));
assert_eq!(
strings.get("UNIT_PVP_NAME").map(String::as_str),
Some("%s %s")
);
assert_eq!(
strings.get("PVP_RANK_7_1_FEMALE").map(String::as_str),
Some("Sergeant (f)")
);
assert!(!strings.contains_key("GARBAGE_KEY"), "only the title keys");
}
/// Each VM is read once, the first frame it is in the world, so a world VM whose addon renames
/// a title reaches the overhead line.
#[test]
fn each_new_vm_is_read_once() {
use bevy::ecs::system::RunSystemOnce;
let mut world = World::new();
world.init_resource::<crate::nameplates::PvpNameStrings>();
world.run_system_once(feed_pvp_name_strings).unwrap();
assert_eq!(
world
.resource::<crate::nameplates::PvpNameStrings>()
.session(),
None,
"no VM, no read"
);
let stock = r#"UNIT_PVP_NAME = "%s %s"
PVP_RANK_7_1 = "Sergeant""#;
let boot = vm(stock);
let boot_session = boot.session();
world.insert_non_send_resource(boot);
world.run_system_once(feed_pvp_name_strings).unwrap();
let held = world.resource::<crate::nameplates::PvpNameStrings>();
assert_eq!(held.session(), Some(boot_session));
assert_eq!(held.get("PVP_RANK_7_1"), Some("Sergeant"));
// The same VM is not read again: a global it sets later stays out.
world
.non_send_resource::<UiScript>()
.run(r#"PVP_RANK_7_1 = "Later""#)
.expect("global set");
world.run_system_once(feed_pvp_name_strings).unwrap();
let held = world.resource::<crate::nameplates::PvpNameStrings>();
assert_eq!(held.get("PVP_RANK_7_1"), Some("Sergeant"));
let world_vm = vm(&format!("{stock}\nPVP_RANK_7_1 = \"Feldwebel\""));
let world_session = world_vm.session();
world.insert_non_send_resource(world_vm);
world.run_system_once(feed_pvp_name_strings).unwrap();
let held = world.resource::<crate::nameplates::PvpNameStrings>();
assert_eq!(held.session(), Some(world_session));
assert_eq!(held.get("PVP_RANK_7_1"), Some("Feldwebel"));
}
}
+80 -31
View File
@@ -60,6 +60,8 @@ fn spell_desc_text(
// The player's skill in a spell's line, the `$`-tokens' level (`TokenContext::skill`).
skill: &dyn Fn(u32) -> u32,
home_area: Option<&str>,
// The active player's `UNIT_FIELD_BYTES_0` byte 2, the `$g`/`$G` branch's input (`0x508214`).
gender: &dyn Fn() -> u8,
mods: Option<&crate::spell::SpellModifiers>,
// The VM's strings for the keyed `$d`/`$s` tokens.
global: &dyn Fn(&str) -> Option<String>,
@@ -77,6 +79,7 @@ fn spell_desc_text(
lookup: &|i| sp.catalog.get(i),
mods: mods.map(|m| m as &dyn benilla_formats::SpellMods),
unmodified_points: false,
gender,
home_area: &|| home_area,
global,
printf: &crate::ui_script::token_printf,
@@ -87,6 +90,15 @@ fn spell_desc_text(
}
}
/// What the `$`-tokens read of the player beside the modifiers: the skills the per-level terms
/// scale by, and the gender byte `$g`/`$G` branch on (`0x508214`).
type TokenReads = (crate::spell::SkillSnapshot, u8);
fn token_reads(me: Option<&ObjectStore>) -> TokenReads {
let gender = me.and_then(|s| s.0.unit_gender()).unwrap_or(0);
(crate::spell::skill_snapshot(me), gender)
}
/// Only descriptions with substitution tokens can change when the caster's spell mods change.
fn spell_has_mod_tokens(spells: Option<&crate::ui_action::Spells>, id: u32) -> bool {
spells
@@ -137,6 +149,8 @@ fn template_view(
skill: &dyn Fn(u32) -> u32,
skill_lines: Option<&benilla_formats::SkillLineCatalog>,
home_area: Option<&str>,
// The active player's gender byte, the trigger text's `$g`/`$G` branch (`0x508214`).
gender: &dyn Fn() -> u8,
factions: Option<&benilla_formats::FactionCatalog>,
sub_classes: Option<&benilla_formats::ItemSubClassCatalog>,
classes: Option<&benilla_formats::ItemClassCatalog>,
@@ -150,7 +164,7 @@ fn template_view(
.and_then(|s| s.catalog.get(id))
.map(|sd| sd.name.clone())
};
let spell_text = |id: u32| spell_desc_text(spells, id, skill, home_area, mods, get);
let spell_text = |id: u32| spell_desc_text(spells, id, skill, home_area, gender, mods, get);
benilla_ui::script::ItemTemplateView {
name: t.name.clone(),
quality: t.quality,
@@ -254,7 +268,7 @@ pub(super) fn feed_item_sets(
crate::ui_script::VmMemo<std::collections::HashMap<u32, benilla_ui::script::ItemSetView>>,
>,
mut mod_sensitive: Local<crate::ui_script::VmMemo<HashSet<u32>>>,
mut last_skills: Local<crate::ui_script::VmMemo<Option<crate::spell::SkillSnapshot>>>,
mut last_reads: Local<crate::ui_script::VmMemo<Option<TokenReads>>>,
) {
let Some(mut script) = script else {
return;
@@ -265,11 +279,12 @@ pub(super) fn feed_item_sets(
for id in script.take_item_set_asks() {
pending.entry(id).or_default();
}
// The `$`-tokens' per-level terms follow the player's skills.
let skills = Some(crate::spell::skill_snapshot(me));
let skills_changed = *last_skills.get(&script) != skills;
*last_skills.get(&script) = skills;
if spell_mods.is_changed() || skills_changed {
// The `$`-tokens follow the player's skills and gender.
let reads = token_reads(me);
let gender = reads.1;
let reads_changed = last_reads.get(&script).as_ref() != Some(&reads);
*last_reads.get(&script) = Some(reads);
if spell_mods.is_changed() || reads_changed {
for &id in mod_sensitive.iter() {
pending.entry(id).or_default();
}
@@ -308,8 +323,16 @@ pub(super) fn feed_item_sets(
.bonuses
.iter()
.filter_map(|&(n, spell)| {
spell_desc_text(spell_res, spell, &skill, None, Some(&spell_mods), &global)
.map(|desc| (n, desc))
spell_desc_text(
spell_res,
spell,
&skill,
None,
&|| gender,
Some(&spell_mods),
&global,
)
.map(|desc| (n, desc))
})
.collect(),
required_skill: row.required_skill,
@@ -392,7 +415,7 @@ pub(super) fn feed_item_stats(
mut pending: Local<crate::ui_script::VmMemo<std::collections::HashSet<u32>>>,
mut mod_sensitive: Local<crate::ui_script::VmMemo<HashSet<u32>>>,
mut last_home: Local<crate::ui_script::VmMemo<Option<String>>>,
mut last_skills: Local<crate::ui_script::VmMemo<Option<crate::spell::SkillSnapshot>>>,
mut last_reads: Local<crate::ui_script::VmMemo<Option<TokenReads>>>,
) {
let Some(mut script) = script else {
return;
@@ -401,10 +424,11 @@ pub(super) fn feed_item_stats(
let mod_sensitive = mod_sensitive.get(&script);
let last_home = last_home.get(&script);
let me = self_q.single().ok();
// The `$`-tokens' per-level terms follow the player's skills.
let skills = Some(crate::spell::skill_snapshot(me));
let skills_changed = *last_skills.get(&script) != skills;
*last_skills.get(&script) = skills;
// The `$`-tokens follow the player's skills and gender.
let reads = token_reads(me);
let gender = reads.1;
let reads_changed = last_reads.get(&script).as_ref() != Some(&reads);
*last_reads.get(&script) = Some(reads);
// `GetBindLocation()`'s push, here so it and the `$z` token share one name, and ahead of the
// pending gate below: the bind point can arrive while the feed idles.
let home_area: Option<&str> = home_bind
@@ -421,7 +445,7 @@ pub(super) fn feed_item_stats(
pending.extend(items.take_fresh());
pending.extend(script.take_item_stat_asks());
if spell_mods.is_changed() || skills_changed {
if spell_mods.is_changed() || reads_changed {
pending.extend(mod_sensitive.iter().copied());
}
if pending.is_empty() {
@@ -464,6 +488,7 @@ pub(super) fn feed_item_stats(
&skill,
skill_catalog,
home_area,
&|| gender,
factions.as_deref().map(|f| f.catalog()),
sub_classes.as_deref().map(|s| &s.0),
classes.as_deref().map(|c| &c.0),
@@ -1692,6 +1717,7 @@ mod tests {
this return"
);
}
use crate::ui_action::real_spells;
use benilla_protocol::messages::ItemSpellEntry;
use benilla_ui::script::{ContainerState, UiScript};
use std::collections::HashMap;
@@ -1911,19 +1937,7 @@ mod tests {
/// line (`0x52da29`-`0x52da31`), and a described one does.
#[test]
fn an_undescribed_spell_prints_no_trigger_line_on_real_data() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = crate::ui_action::Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
forms: benilla_formats::load_shapeshift_forms(&mut chain)
.expect("SpellShapeshiftForm.dbc"),
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("SpellRange.dbc"),
cast_times: benilla_formats::load_spell_cast_times(&mut chain)
.expect("SpellCastTimes.dbc"),
durations: benilla_formats::load_spell_durations(&mut chain)
.expect("SpellDuration.dbc"),
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
};
let Some(spells) = real_spells() else { return };
// No string table: Fireball's description reaches no keyed token.
let no_strings = |_: &str| None;
@@ -1936,7 +1950,7 @@ mod tests {
"spell {id} has a name — which is exactly what must NOT leak into the tooltip"
);
assert_eq!(
super::spell_desc_text(Some(&spells), id, &|_| 0, None, None, &no_strings),
super::spell_desc_text(Some(&spells), id, &|_| 0, None, &|| 0, None, &no_strings),
None,
"spell {id} ({:?}) has no description, so the reference prints no trigger line",
d.name
@@ -1944,11 +1958,46 @@ mod tests {
}
// The control: Fireball (133), described, still yields its line.
let fireball = super::spell_desc_text(Some(&spells), 133, &|_| 0, None, None, &no_strings)
.expect("a described spell still yields its line");
let fireball =
super::spell_desc_text(Some(&spells), 133, &|_| 0, None, &|| 0, None, &no_strings)
.expect("a described spell still yields its line");
assert!(
fireball.contains("damage"),
"expected the substituted Fireball description, got {fireball:?}"
);
}
/// The trigger text's `$g` branch reads the gender handed in (`0x508214`): Conjure Food 587's
/// "providing the mage and `$ghis:her;` allies" takes "her" on 1, "his" on 0.
#[test]
fn a_trigger_texts_gender_branch_follows_the_player() {
let Some(spells) = real_spells() else { return };
let no_strings = |_: &str| None;
let line = |gender: u8| {
super::spell_desc_text(
Some(&spells),
587,
&|_| 0,
None,
&move || gender,
None,
&no_strings,
)
.expect("a described spell")
};
assert!(line(1).contains("the mage and her allies"), "{}", line(1));
assert!(line(0).contains("the mage and his allies"), "{}", line(0));
}
/// The item feeds re-substitute a held `$`-text when what the tokens read moves: the gender
/// byte `$g`/`$G` branch on (`0x508214`), and no other byte of `UNIT_FIELD_BYTES_0`.
#[test]
fn the_token_reads_move_with_the_gender_byte_alone() {
let store = |bytes: u32| {
crate::net::ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(36, bytes)]))
};
let male = super::token_reads(Some(&store(4 | 1 << 8)));
assert_ne!(super::token_reads(Some(&store(4 | 1 << 8 | 1 << 16))), male);
assert_eq!(super::token_reads(Some(&store(1 | 2 << 8 | 1 << 24))), male);
}
}
+39 -1
View File
@@ -363,7 +363,8 @@ impl LootLatch {
/// A movement start that closes the loot: the reference's guard `0x60e990`, called by every
/// movement-start emitter, turning included, but not by mouse-look facing. No loot target reaches
/// the range gate `0x493230`, so there is no distance leash. vmangos also releases on movement
/// (`MovementHandler.cpp:1104`), but the close is the client's.
/// (`MovementHandler.cpp:1104`), but the close is the client's. A loot response that lands while
/// we move sets it too (`0x5ebc3a`).
#[derive(Resource, Default)]
pub(crate) struct LootMoveStart(pub(crate) bool);
@@ -674,6 +675,7 @@ fn feed_loot(
group: Res<GroupState>,
names: Res<NameCache>,
objects: LootSourceObjects,
mut moving: OpenWhileMoving,
) {
let Some(mut script) = script else {
return;
@@ -755,6 +757,19 @@ fn feed_loot(
}
}
}
// The response's tail (`0x5ebc3a`-`0x5ebc51`) closes a window that opened while we
// move, after the copier's open and sweep and in the same handler: `LOOT_CLOSED` fires
// in this pass, so the window never paints.
if std::mem::take(&mut moving.move_start.0) {
let closed = close_on_move_start(&mut loot, &mut moving.latch, &commands);
if closed.is_some() {
moving.dead.after_close(closed);
script.set_loot(None);
script.fire_event("LOOT_CLOSED", vec![]);
*last = None;
return;
}
}
}
// One `LOOT_SLOT_CLEARED` per row that went, with its 1-based row: the stock handler hides
// that button and pages down when the page empties (`LootFrame.lua:22-50`).
@@ -862,6 +877,14 @@ fn close_on_move_start(
close_interaction(loot, latch, Some(commands))
}
/// What [`feed_loot`] needs to take the move-start close on a window it has just opened.
#[derive(bevy::ecs::system::SystemParam)]
struct OpenWhileMoving<'w, 's> {
move_start: ResMut<'w, LootMoveStart>,
latch: ResMut<'w, LootLatch>,
dead: DeadUnitDeselect<'w, 's>,
}
/// Sends what the Lua asked for, as the take dispatcher `0x4c2790(slot, flag)`: a row click
/// (a `LootButton`'s own, flag 0) and the `LOOT_BIND` confirm (`LootSlot`, flag 1) stay apart,
/// so a second click on a bind-on-pickup row asks again.
@@ -1170,6 +1193,11 @@ mod tests {
let (tx, _rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_message::<crate::sound::LootPickupSound>()
// What the feed's move-start close reads (`OpenWhileMoving`).
.add_message::<crate::target::DeselectGuid>()
.init_resource::<LootMoveStart>()
.init_resource::<LootLatch>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<LootState>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<GroupState>()
@@ -1227,6 +1255,11 @@ mod tests {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_message::<crate::sound::LootPickupSound>()
// What the feed's move-start close reads (`OpenWhileMoving`).
.add_message::<crate::target::DeselectGuid>()
.init_resource::<LootMoveStart>()
.init_resource::<LootLatch>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<LootState>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<GroupState>()
@@ -1460,6 +1493,11 @@ mod tests {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_message::<crate::sound::LootPickupSound>()
// What the feed's move-start close reads (`OpenWhileMoving`).
.add_message::<crate::target::DeselectGuid>()
.init_resource::<LootMoveStart>()
.init_resource::<LootLatch>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<LootState>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<GroupState>()
+287 -36
View File
@@ -5,7 +5,7 @@ use benilla_protocol::messages::ItemPushResult;
use benilla_protocol::{SessionEvent, SessionEventKind};
use bevy::prelude::*;
use super::{LootLatch, LootState};
use super::{LootLatch, LootMoveStart, LootState};
use crate::net::{ClientCommand, NetCommands, NetHandlerApp, SelfGuid};
use crate::pending_item_ops::{LockTransitions, PendingItemOps};
use crate::ui_action::{UiError, UiErrorKeys};
@@ -26,8 +26,15 @@ pub(super) fn register(app: &mut App) {
}
/// An admitted response selects a looted unit (`0x5ebc35` → `0x48f3a0`) before the window opens
/// (`0x4c1cb0`), so the outgoing target's teardown closes a window still open on it.
fn on_response(In(ev): In<SessionEvent>, mut select: crate::target::SelectCommit) {
/// (`0x4c1cb0`), so the outgoing target's teardown closes a window still open on it. One that lands
/// while our own body moves takes the move-start close (`0x5ebc3a`-`0x5ebc51`), which the feed runs
/// after it fires `LOOT_OPENED`, or the drain when the window waits on an item template.
fn on_response(
In(ev): In<SessionEvent>,
mut select: crate::target::SelectCommit,
player: Option<Res<crate::player::Player>>,
mut move_start: ResMut<LootMoveStart>,
) {
let SessionEvent::LootResponse {
guid,
loot_type,
@@ -47,6 +54,12 @@ fn on_response(In(ev): In<SessionEvent>, mut select: crate::target::SelectCommit
);
select.select_unit(guid);
select.seam.loot.open(guid, loot_type, gold, items);
if player.is_some_and(|p| {
p.foreign_mover.is_none()
&& p.move_flags() & crate::creature_anim::move_flags::STEERING != 0
}) {
move_start.0 = true;
}
}
fn on_error(
@@ -763,6 +776,8 @@ mod tests {
world.init_resource::<LockTransitions>();
world.init_resource::<bevy::ecs::message::Messages<crate::target::DeselectGuid>>();
world.init_resource::<bevy::ecs::message::Messages<crate::player::StandStateRequest>>();
world.init_resource::<crate::player::Player>();
world.init_resource::<super::super::LootMoveStart>();
world.spawn((
SelfPlayer,
Guid(1),
@@ -988,34 +1003,33 @@ mod tests {
);
}
/// The issue's symptom on the stock frames: the Tab target is on `TargetFrame` when the body's
/// window opens, and the unit feed then names the body there (`TargetFrame.lua:63`,
/// `UnitFrame.lua:25`).
#[test]
fn the_stock_target_frame_names_the_looted_body() {
/// The stock unit frames over `TargetFrame`, for [`stock_app`].
const TARGET_UI: &[&str] = &[
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\UIParent.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\MoneyFrame.lua",
"Interface\\FrameXML\\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\CombatFeedback.xml",
"Interface\\FrameXML\\UnitPopup.xml",
"Interface\\FrameXML\\UnitFrame.xml",
"Interface\\FrameXML\\PlayerFrame.xml",
"Interface\\FrameXML\\PartyFrame.xml",
"Interface\\FrameXML\\TargetFrame.xml",
];
/// [`tabbed_world`] under the stock `ui` files, with the unit feed and both creatures named.
fn stock_app(ui: &[&[&str]]) -> App {
use benilla_ui::script::UiScript;
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().expect("VM");
s.set_screen_size(1024.0, 768.0);
for f in crate::ui_script::test_ui::production_order(&[&[
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\UIParent.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\MoneyFrame.lua",
"Interface\\FrameXML\\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\CombatFeedback.xml",
"Interface\\FrameXML\\UnitPopup.xml",
"Interface\\FrameXML\\UnitFrame.xml",
"Interface\\FrameXML\\PlayerFrame.xml",
"Interface\\FrameXML\\PartyFrame.xml",
"Interface\\FrameXML\\TargetFrame.xml",
]]) {
for f in crate::ui_script::test_ui::production_order(ui) {
crate::ui_script::test_ui::load_ui(&s, f);
}
@@ -1048,20 +1062,257 @@ mod tests {
.init_resource::<crate::ui_guild::GuildState>()
.insert_non_send_resource(s);
crate::ui_unit::add_unit_feed(&mut app);
let shown = |app: &mut App| -> String {
let s = app.world().non_send_resource::<UiScript>();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.eval("return TargetFrame:IsShown() and TargetFrame.name:GetText() or ''")
.expect("the target frame's name")
};
app
}
/// The name on the stock `TargetFrame`, empty while it is hidden.
fn target_frame_name(app: &App) -> String {
let s = app
.world()
.non_send_resource::<benilla_ui::script::UiScript>();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.eval("return TargetFrame:IsShown() and TargetFrame.name:GetText() or ''")
.expect("the target frame's name")
}
/// The issue's symptom on the stock frames: the Tab target is on `TargetFrame` when the body's
/// window opens, and the unit feed then names the body there (`TargetFrame.lua:63`,
/// `UnitFrame.lua:25`).
#[test]
fn the_stock_target_frame_names_the_looted_body() {
benilla_formats::wow_data_or_skip!();
let mut app = stock_app(&[TARGET_UI]);
app.update();
assert_eq!(shown(&mut app), "Kobold Vermin", "the Tab target");
assert_eq!(target_frame_name(&app), "Kobold Vermin", "the Tab target");
app.world_mut().resource_mut::<LootLatch>().0 = Some(CORPSE);
respond(app.world_mut(), CORPSE, 1);
app.update();
assert_eq!(shown(&mut app), "Kobold Worker", "the looted body");
assert_eq!(target_frame_name(&app), "Kobold Worker", "the looted body");
}
/// What [`super::super::drain_loot`] reads beside [`seat_tabbed`]'s, with our body's movement
/// word at `player`'s.
fn seat_drain(world: &mut World, player: crate::player::Player) {
world.insert_resource(player);
world.init_resource::<crate::ui_party::GroupState>();
world.init_resource::<crate::items::Items>();
world.init_resource::<bevy::ecs::message::Messages<crate::sound::LootPickupSound>>();
}
/// The deselects asked for since the last call.
fn deselects(world: &mut World) -> Vec<u64> {
world
.resource_mut::<bevy::ecs::message::Messages<crate::target::DeselectGuid>>()
.drain()
.map(|d| d.0)
.collect()
}
/// A response on the corpse with `player` seated, then one drain.
fn open_while(
player: crate::player::Player,
) -> (World, crossbeam_channel::Receiver<ClientCommand>) {
let (mut world, rx) = tabbed_world();
seat_drain(&mut world, player);
world.resource_mut::<LootLatch>().0 = Some(CORPSE);
respond(&mut world, CORPSE, 1);
world
.run_system_once(super::super::drain_loot)
.expect("the drain runs as a one-shot system");
(world, rx)
}
/// The response's tail (`0x5ebc3a`-`0x5ebc51`): a bit of our movement word's low byte, forward,
/// back, strafe, turn or pitch, takes the move-start close on the window it just opened, the
/// release after the selection and the dead body's deselect last (`0x48f369`).
#[test]
fn a_window_that_opens_while_moving_closes_as_a_movement_start_does() {
for bit in (0..8).map(|b| 1u32 << b) {
let (mut world, rx) = open_while(crate::player::Player::with_move_flags(bit));
assert_eq!(world.resource::<LootState>().source(), None, "{bit:#x}");
assert_eq!(world.resource::<LootLatch>().0, None, "{bit:#x}");
let sent: Vec<_> = rx.try_iter().collect();
assert!(
matches!(
sent[..],
[ClientCommand::SetSelection { guid: a }, ClientCommand::LootRelease { guid: b }]
if a == CORPSE && b == CORPSE
),
"{bit:#x}: {sent:?}"
);
assert_eq!(deselects(&mut world), [CORPSE], "{bit:#x}");
}
}
/// Only that byte, and only our body's: walking, falling or swimming alone leaves the window
/// open, as does a step of the creature we possess.
#[test]
fn a_window_opened_falling_swimming_or_possessing_stays_open() {
use crate::creature_anim::move_flags as f;
use crate::player::Player;
let mut possessing = Player::with_move_flags(f::FORWARD);
possessing.foreign_mover = Some(KOBOLD);
for (what, player) in [
("still", Player::with_move_flags(0)),
("walk mode", Player::with_move_flags(f::WALK_MODE)),
("falling", Player::with_move_flags(f::FALLING)),
("swimming", Player::with_move_flags(f::SWIMMING)),
("possessing", possessing),
] {
let (mut world, rx) = open_while(player);
assert_eq!(
world.resource::<LootState>().source(),
Some(CORPSE),
"{what}"
);
let sent: Vec<_> = rx.try_iter().collect();
assert!(
matches!(sent[..], [ClientCommand::SetSelection { guid }] if guid == CORPSE),
"{what}: {sent:?}"
);
assert!(deselects(&mut world).is_empty(), "{what}");
}
}
/// The copier `0x4c1cb0` sweeps an auto-loot window (`0x4c1fa0`) before the response's
/// movement close (`0x5ebc51`), so a body auto-looted on the run is taken, then released.
#[test]
fn auto_loot_on_the_run_takes_the_rows_before_the_release() {
const TOUGH_JERKY: u32 = 117;
let mut app = App::new();
let rx = seat_tabbed(app.world_mut());
seat_drain(
app.world_mut(),
crate::player::Player::with_move_flags(crate::creature_anim::move_flags::FORWARD),
);
app.world_mut()
.resource_mut::<crate::items::Items>()
.insert_template(
TOUGH_JERKY,
Some(crate::items::test_template("Tough Jerky")),
);
app.insert_resource(super::super::LootConfig {
auto_loot: true,
..Default::default()
})
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<crate::names::NameCache>()
.init_resource::<ButtonInput<KeyCode>>()
.insert_non_send_resource(benilla_ui::script::UiScript::new().expect("VM"))
.add_systems(
Update,
(super::super::feed_loot, super::super::drain_loot).chain(),
);
app.world_mut().resource_mut::<LootLatch>().0 = Some(CORPSE);
app.world_mut()
.run_system_once_with(
on_response,
SessionEvent::LootResponse {
guid: CORPSE,
loot_type: 1,
gold: 0,
items: vec![benilla_protocol::messages::LootItem {
slot: 0,
item_id: TOUGH_JERKY,
count: 1,
display_info_id: 1117,
random_property_id: 0,
slot_type: 0,
}],
},
)
.expect("the handler runs as a one-shot system");
app.update();
let sent: Vec<_> = rx.try_iter().collect();
assert!(
matches!(
sent[..],
[
ClientCommand::SetSelection { .. },
ClientCommand::AutostoreLootItem { slot: 0 },
ClientCommand::LootRelease { guid },
] if guid == CORPSE
),
"{sent:?}"
);
}
/// The issue's spot on the stock frames: a body's window that opens on the run is shown by
/// `LOOT_OPENED` and hidden by `LOOT_CLOSED` in the same frame (`LootFrame.lua:13-16`,
/// `:53-55`), its one release
/// sent, and `TargetFrame` lets the body go; standing, the window and the target stay.
#[test]
fn the_stock_loot_frame_closes_on_a_body_looted_on_the_run() {
use crate::creature_anim::move_flags as f;
use benilla_ui::script::UiScript;
benilla_formats::wow_data_or_skip!();
for (flags, stays) in [(f::FORWARD, false), (0, true)] {
let mut app = stock_app(&[
crate::ui_script::test_ui::LOOT_UI,
TARGET_UI,
&["Interface\\FrameXML\\LootFrame.xml"],
]);
let (tx, rx) = crossbeam_channel::unbounded();
app.insert_resource(crate::net::NetCommands(tx));
seat_drain(
app.world_mut(),
crate::player::Player::with_move_flags(flags),
);
app.init_resource::<super::super::LootConfig>()
.init_resource::<ButtonInput<KeyCode>>()
.add_systems(
Update,
(
super::super::feed_loot,
super::super::drain_loot,
crate::target::click::clear_target_requests,
)
.chain(),
);
app.world()
.non_send_resource::<UiScript>()
.run(
"LOOT_EVENTS = {} local f = CreateFrame('Frame') \
f:RegisterEvent('LOOT_OPENED') f:RegisterEvent('LOOT_CLOSED') \
f:SetScript('OnEvent', function() tinsert(LOOT_EVENTS, event) end)",
)
.unwrap();
app.update();
app.world_mut().resource_mut::<LootLatch>().0 = Some(CORPSE);
respond(app.world_mut(), CORPSE, 1);
// One frame: the reference opens and closes in the response's handler.
app.update();
let (events, shown): (String, bool) = app
.world()
.non_send_resource::<UiScript>()
.eval("return table.concat(LOOT_EVENTS, ','), LootFrame:IsShown() == 1")
.unwrap();
for _ in 0..2 {
app.update();
}
let releases = rx
.try_iter()
.filter(|c| matches!(c, ClientCommand::LootRelease { guid } if *guid == CORPSE))
.count();
if stays {
assert_eq!(events, "LOOT_OPENED");
assert!(shown, "standing, the window stays");
assert_eq!(releases, 0);
assert_eq!(target_frame_name(&app), "Kobold Worker");
} else {
assert_eq!(events, "LOOT_OPENED,LOOT_CLOSED");
assert!(
!shown,
"on the run, the window closes in the frame it opens"
);
assert_eq!(releases, 1, "released once");
assert_eq!(target_frame_name(&app), "", "and the body is let go");
}
}
}
/// Only a releasing arm reaches `UnlockItem 0x495420`, through the tail `0x5ebac2`.
@@ -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!(
@@ -2314,9 +2317,9 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
);
checked += 1;
}
// 83 rows less the three untipped below; four of the 80 carry a `BENILLA_` key, and a dropdown
// 84 rows less the three untipped below; four of the 81 carry a `BENILLA_` key, and a dropdown
// row is checked on the key it wears at rest.
assert_eq!(checked, 80, "every tipped row carries a live key");
assert_eq!(checked, 81, "every tipped row carries a live key");
assert_eq!(
untipped,
vec![
@@ -2412,8 +2415,8 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
s.errors()
);
}
// Every tipped row: the same 80 the key census counts.
assert_eq!(raised, 80, "every row but Auto Loot raises a description");
// Every tipped row: the same 81 the key census counts.
assert_eq!(raised, 81, "every row but Auto Loot raises a description");
}
/// 1.12's AdvancedOptionsCombatText box as saved-global rows: a click writes the global, never a
@@ -4069,13 +4072,6 @@ fn without_a_seated_measurer_the_same_fit_reads_zero() {
// nothing and reads back nil, so its box would offer a setting benilla does not have. A CVar is
// registered only once something reads it.
const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
(
"UnitNamePlayerPVPTitle",
"the PvP rank prefix on the overhead name line — slot a4 of `0x608f50`, bit `0x20` of the \
same mask, resolved by `0x609370` through the `PVP_RANK_%d_%d` GlobalStrings key. Blocked \
one step further back than its guild twin: the rank byte streams, but the key's second \
index is a FACTION SIDE that `ui_unit` does not resolve for an arbitrary player yet",
),
// No slider CVar may land here: `UIOptionsFrame_Load` hands `GetCVar` to `Slider:SetValue`
// (`UIOptionsFrame.lua:271-275`), which raises on nil (`0x790980`) and stops the stock
// window's load. A check button's `GetCVar(x) == "1"` is nil-safe.
@@ -4379,7 +4375,48 @@ fn show_target_damage_greys_its_own_pair_and_the_floating_text_master_leaves_it_
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The reference's rules: Player Guild Names is dead while player names are off
/// The titles box is the guild row's twin: checked and live at the registered defaults (`"1"`),
/// and a click survives closing and reopening the panel because the CVar is its store.
#[test]
fn the_titles_box_sticks_across_a_panel_reopen() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowNameplates:Click()")
.unwrap();
let checked = |s: &mut UiScript| -> bool {
s.eval::<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() {
@@ -4387,22 +4424,28 @@ fn the_guild_line_greys_with_player_names_and_the_follow_speed_with_the_style()
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
// Nameplates: `UnitNamePlayer` ships "1", so the child arrives live.
// Nameplates: `UnitNamePlayer` ships "1", so both children arrive live.
s.run("BenillaOptionsFrameCategoryListRowNameplates:Click()")
.unwrap();
let guild_on = |s: &mut UiScript| -> bool {
s.eval::<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()")
+1 -1
View File
@@ -501,7 +501,7 @@ pub(crate) const GOSSIP_UI: &[&str] = &[
/// lacks what the app loads at setup (the master-loot menu reads `GROUP` and `GIVE_LOOT`),
/// `ItemButtonTemplate.xml` because a missing template only warns, and `PartyFrame.xml` because
/// `LootFrame.lua:217` reads `MAX_PARTY_MEMBERS` at load. Needs client data.
pub(super) const LOOT_UI: &[&str] = &[
pub(crate) const LOOT_UI: &[&str] = &[
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\BasicControls.xml", // `TEXT`, which UnitPopup.lua reads at file scope
@@ -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 = [
@@ -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,
@@ -112,6 +112,11 @@ pub(super) fn build_view(
deps.borrow_mut().home = true;
home
};
// `$g`/`$G` read the active player's gender byte (`0x508214`), a pet view's too.
let gender = || {
deps.borrow_mut().gender = true;
vctx.store.and_then(|s| s.0.unit_gender()).unwrap_or(0)
};
// `0x6e3130` for this spell: the level the cost's and the cast time's per-level terms read.
let pet_level = |pet| d.skill_level(ViewCaster::pet_skill_value(pet));
let ctx = benilla_formats::TokenContext {
@@ -122,6 +127,7 @@ pub(super) fn build_view(
lookup: &lookup,
mods: Some(vctx.spell_mods),
unmodified_points: false,
gender: &gender,
home_area: &home_area,
global: vctx.get,
printf: &crate::ui_script::token_printf,
+46 -15
View File
@@ -4,7 +4,7 @@ use super::spell_feed::{build_view, feed_spell_tooltips, ViewCaster, ViewCtx};
use super::*;
use crate::items::Items;
use crate::net::{NetCommands, ObjectStore, Objects, SelfPlayer};
use crate::ui_action::{PlayerActions, Spells};
use crate::ui_action::{real_spells, PlayerActions, Spells};
/// The view alone, for the cell tests; the feed keeps what it read beside it.
fn spell_tooltip_view(
@@ -105,20 +105,6 @@ impl TestCtx {
}
}
/// The 5875 spell data the view builder reads; `None` skips where the install is absent.
pub(super) fn real_spells() -> Option<Spells> {
let data = benilla_formats::wow_data_or_skip!(None);
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
Some(Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
forms: benilla_formats::load_shapeshift_forms(&mut chain).expect("forms"),
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("ranges"),
cast_times: benilla_formats::load_spell_cast_times(&mut chain).expect("cast times"),
durations: benilla_formats::load_spell_durations(&mut chain).expect("durations"),
radii: benilla_formats::load_spell_radii(&mut chain).expect("radii"),
})
}
/// A unit's fields: level (34) and base mana (162).
fn unit(level: u32, base_mana: u32) -> benilla_protocol::ObjectFields {
benilla_protocol::ObjectFields::from_pairs(&[(22u16, 100u32), (34, level), (162, base_mana)])
@@ -220,6 +206,51 @@ fn a_pet_view_costs_seduction_from_the_pets_base_mana() {
assert_eq!(v.cost, None);
}
/// The `$g`/`$G` branch (`0x508180`) reads the active player's `UNIT_FIELD_BYTES_0` byte 2
/// (`0x508214`): the first form on 0, the second on anything else. A female player's Conjure Food
/// and Water (587, 5504) tooltips read "her allies" and Hellfire (1949) "to herself".
#[test]
fn a_female_players_spell_tooltips_take_the_second_gender_form() {
let Some(spells) = real_spells() else { return };
let mut t = TestCtx::new();
let mut objs = no_objects();
let objects = objs.get();
// A level 60 player whose `UNIT_FIELD_BYTES_0` (36) holds `gender` in byte 2.
let player = |gender: u32| {
let mut fields = unit(60, 1373);
fields.merge(benilla_protocol::ObjectFields::from_pairs(&[(
36,
gender << 16,
)]));
ObjectStore(fields)
};
let (her, him) = (player(1), player(0));
let mut text = |id: u32, store: &ObjectStore| {
spell_tooltip_view(id, &spells, &mut t.ctx_for(&objects, 0, None, Some(store)))
.unwrap_or_else(|| panic!("spell {id} view"))
.description
};
for id in [587u32, 5504] {
let female = text(id, &her);
assert!(female.contains("the mage and her allies"), "{id}: {female}");
let male = text(id, &him);
assert!(male.contains("the mage and his allies"), "{id}: {male}");
}
let hellfire = text(1949, &her);
assert!(hellfire.contains("Fire damage to herself"), "{hellfire}");
// The branch looks the active player up (`0x508189`-`0x5081a4`): a pet view reads the
// player's gender, never the pet's.
let pet = unit(20, 0);
let pets = spell_tooltip_view(
587,
&spells,
&mut t.pet_ctx(&objects, Some(&her), Some(&pet)),
)
.expect("Conjure Food view")
.description;
assert!(pets.contains("the mage and her allies"), "{pets}");
}
/// An object index with nothing streamed: no worn item, and every reagent count 0.
fn no_objects() -> crate::ui_items::TestObjects {
crate::ui_items::TestObjects::new()
+1 -1
View File
@@ -1444,7 +1444,7 @@ fn feed_units(
// `ChatFrame.lua:1283-1320` reads them; missing gains are zeros, since stock's
// `if ( argN > 0 )` raises on nil.
let (info, talent_points) = sink.chat.take_level_up_gains(level).unzip();
let gain = |f: fn(&benilla_protocol::messages::LevelUpInfo) -> u32| {
let gain = |f: fn(&benilla_protocol::messages::LevelUpInfo) -> i32| {
ScriptValue::Int(i64::from(info.as_ref().map_or(0, f)))
};
script.fire_event(
+59 -4
View File
@@ -1,8 +1,8 @@
//! The spell-description `$`-token engine the 1.12 client runs over `Spell.dbc` description and
//! aura text (`0x5075f0` → `0x507710`), effect values from `GetEffectPoints 0x6e3800`. Effect
//! point values print unsigned, as the client's do. `$g` takes the first form without gender
//! input. `$c` and `$p` (`507dde`, `507ded`), which no shipped text uses, and unknown or
//! unresolved tokens stay raw.
//! point values print unsigned, as the client's do. `$g` and `$G` branch on the active player's
//! gender ([`TokenContext::gender`]). `$c` and `$p` (`507dde`, `507ded`), which no shipped text
//! uses, and unknown or unresolved tokens stay raw.
use super::soft_float;
use super::{SpellDisplay, SpellDurationCatalog, SpellRadiusCatalog, SpellRangeCatalog};
@@ -40,6 +40,9 @@ pub struct TokenContext<'a> {
/// points skip their modifiers (`6e3925`), and so does the duration of `$d` and `$o`
/// (`0x6ea000`'s flag, pushed at `507ccd` and `507917`).
pub unmodified_points: bool,
/// The active player's gender, `UNIT_FIELD_BYTES_0` byte 2 (`0x508214`), which `$g`/`$G`
/// branch on. Asked only where a branch expands.
pub gender: &'a dyn Fn() -> u8,
/// The `$z` token: the home-bind area's name, from `SMSG_BINDPOINTUPDATE`'s area id through
/// `AreaTable.dbc`; `None` leaves the token raw. Asked only where a `$z` expands, so a caller
/// can tell which texts read the bind point.
@@ -416,6 +419,12 @@ fn scale_prefix(text: &str, at: usize) -> Option<(f32, usize)> {
Some((scale, semi + 1))
}
/// `0x5081c8`-`0x5081d7` and `0x50822a`-`0x50823b`: a `$g`/`$G` branch arm's leading spaces are
/// skipped, and `0x508267`-`0x508280` trims its trailing spaces — both forms alike.
fn trim_spaces(arm: &str) -> &str {
arm.trim_matches(' ')
}
/// The CRT's `atoi`: leading whitespace, an optional sign, then decimal digits up to the first
/// other character; nothing parsed is 0.
fn atoi(bytes: &[u8]) -> i32 {
@@ -494,7 +503,8 @@ pub fn substitute(text: &str, spell: &SpellDisplay, ctx: &TokenContext) -> Strin
b
}
}
_ => a, // $g: the first form, as there is no gender input
// `$g`/`$G` (`0x508180`): the first form on gender 0, else the second.
_ => trim_spaces(if (ctx.gender)() == 0 { a } else { b }),
};
out.push_str(pick);
i = i + 1 + end + 1;
@@ -671,6 +681,7 @@ mod tests {
lookup,
mods: None,
unmodified_points: false,
gender: &|| 0,
global: &global,
printf: &printf,
}
@@ -737,6 +748,50 @@ mod tests {
assert_eq!(d(8), "<forever>");
}
/// `0x508180`'s branch: the first form on gender 0, the second on anything else, the chosen
/// form trimmed of spaces at both ends (`0x5081c8`-`0x5081d7`, `0x50822a`-`0x50823b`,
/// `0x508267`-`0x508280`).
#[test]
fn gender_takes_the_players_side_of_the_branch() {
let durations = SpellDurationCatalog::default();
let radii = SpellRadiusCatalog::default();
let d = SpellDisplay::default();
let gender = std::cell::Cell::new(0u8);
let c = TokenContext {
gender: &|| gender.get(),
..ctx(&durations, &radii, &none_lookup)
};
assert_eq!(substitute("$ghis:her;", &d, &c), "his");
gender.set(1);
assert_eq!(substitute("$ghis:her;", &d, &c), "her");
gender.set(2);
assert_eq!(substitute("$ghis:her;", &d, &c), "her");
// `G` is the same branch (`0x508138`'s table maps both spellings to `0x50786d`).
assert_eq!(substitute("$GLad:LAss;", &d, &c), "LAss");
// The chosen form's spaces go from both ends.
gender.set(0);
assert_eq!(substitute("$g his : her ;", &d, &c), "his");
gender.set(1);
assert_eq!(substitute("$g his : her ;", &d, &c), "her");
// An empty chosen arm consumes the token.
gender.set(0);
assert_eq!(substitute("$g:male;", &d, &c), "");
gender.set(1);
assert_eq!(substitute("$g:male;", &d, &c), "male");
assert_eq!(substitute("$g female:;", &d, &c), "");
// The shipped wording: Conjure Food 587 and Hellfire 1949.
assert_eq!(
substitute("providing the mage and $ghis:her; allies", &d, &c),
"providing the mage and her allies"
);
gender.set(0);
assert_eq!(
substitute("damage to $ghimself:herself;", &d, &c),
"damage to himself"
);
}
#[test]
fn duration_modifier_updates_description_and_overtime_total() {
let mut durations = SpellDurationCatalog::default();
@@ -2,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,
+30 -2
View File
@@ -1003,8 +1003,8 @@ fn combat_log_wire_golden() {
other => panic!("exploration xp event, got {other:?}"),
}
// SMSG_LEVELUP_INFO: twelve u32, level, healthGain, powerGains[5] (mana..happiness) and
// statGains[5] (str..spirit), no guid (vmangos `Misc.cpp:524-532`).
// SMSG_LEVELUP_INFO: u32 level, then the i32 gains, healthGain, powerGains[5] (mana..happiness)
// and statGains[5] (str..spirit), no guid (vmangos `Misc.cpp:524-532`).
let mut body = Vec::new();
for v in [7u32, 22, 15, 0, 0, 0, 0, 1, 0, 1, 2, 1] {
body.extend_from_slice(&v.to_le_bytes());
@@ -1029,6 +1029,34 @@ fn combat_log_wire_golden() {
SessionEvent::LevelUp(l) => assert_eq!((l.level, l.health, l.powers[0]), (7, 22, 15)),
other => panic!("level up event, got {other:?}"),
}
// A loss the server wraps into its u32 reads negative, as the reference's `%d` fire prints it.
let mut body = Vec::new();
for v in [
7u32, 0xFFFFFFFE, 0xFFFFFFFD, 0, 0, 0, 0, 0xFFFFFFFE, 0, 0xFFFFFFFB, 0xFFFFFFFA, 0xFFFFFFF9,
] {
body.extend_from_slice(&v.to_le_bytes());
}
assert_eq!(body.len(), 48);
let packet = messages::parse_server(messages::opcode::SMSG_LEVELUP_INFO, &body).unwrap();
match &packet {
ServerPacket::LevelUp(l) => {
assert_eq!(
l,
&LevelUpInfo {
level: 7,
health: -2,
powers: [-3, 0, 0, 0, 0],
stats: [-2, 0, -5, -6, -7],
}
);
}
other => panic!("level up, got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::LevelUp(l) => assert_eq!((l.level, l.health, l.powers[0]), (7, -2, -3)),
other => panic!("level up event, got {other:?}"),
}
}
/// Raw caster guid, u32 spellId, u32 count, raw guids (vmangos `Spell.cpp:4974-4997`); in the
+1 -1
View File
@@ -227,7 +227,7 @@ pub(crate) use model::Model;
pub use model::{FontProbe, TextureProbe, TextureSizeProbe, WorldLocProjector};
pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo};
pub use pet::{PetActionView, PetStats};
pub use pvp::{HonorState, InspectHonorData};
pub use pvp::{decorated_name, pvp_name_global_keys, HonorState, InspectHonorData, RankTitle};
pub use quest::{
QuestAction, QuestItemView, QuestPanel, QuestRewardSpell, QuestSelect, QuestState,
};
+79 -9
View File
@@ -129,7 +129,15 @@ impl super::UiScript {
/// victim's (`0x625374`), and a victim whose team is -1 gets no line at all (`0x625321`),
/// hence `team: u8`.
pub fn pvp_rank_title(&self, rank: u8, team: u8, female: bool) -> Option<String> {
rank_title_gendered(self.lua(), i64::from(rank), i64::from(team), female)
rank_title_gendered(
self.lua(),
RankTitle {
rank,
// Past `i8` names no key, the lookup's own miss.
team: i8::try_from(team).ok()?,
female,
},
)
}
}
@@ -218,14 +226,35 @@ fn rank_title_ungendered(lua: &Lua, rank: i64, team: i64) -> Option<String> {
global_string(lua, &format!("PVP_RANK_{rank}_{team}"))
}
/// The `PVP_RANK_<rank>_<team>[_FEMALE]` inputs, kept together because the live lookup and the
/// world-text pass's builder key on the same three.
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct RankTitle {
/// `PLAYER_BYTES_3` byte 3 (`UnitPVPRank`), the internal rank 0..=18; 19 is the racial
/// "Leader" the keys still name.
pub rank: u8,
/// The team digit from the race: 0 Horde, 1 Alliance, -1 no side.
pub team: i8,
/// Female takes the `_FEMALE` twin (`0x612bf0`) and falls back to the base key.
pub female: bool,
}
/// The same title gendered, `0x612bf0`: it passes 2 (male) or 3 (female) to `0x703bf0`, which
/// asks FrameXML's `GetText` to append `_FEMALE` for 3 (the engine never builds that key), and
/// retries ungendered on a miss (`0x612c2d`). Used by `UnitPVPName` and the credit line.
fn rank_title_gendered(lua: &Lua, rank: i64, team: i64, female: bool) -> Option<String> {
female
.then(|| global_string(lua, &format!("PVP_RANK_{rank}_{team}_FEMALE")))
/// retries ungendered on a miss (`0x612c2d`). The credit line's lookup; `UnitPVPName` and the
/// overhead name reach the same keys through [`decorated_name`].
fn rank_title_gendered(lua: &Lua, title: RankTitle) -> Option<String> {
rank_title(&|key| global_string(lua, key), title)
}
/// [`rank_title_gendered`]'s key construction over a string lookup, so a caller with no VM (the
/// world-text pass) resolves the same `_FEMALE` twin and fallback.
fn rank_title(lookup: &impl Fn(&str) -> Option<String>, title: RankTitle) -> Option<String> {
title
.female
.then(|| lookup(&format!("PVP_RANK_{}_{}_FEMALE", title.rank, title.team)))
.flatten()
.or_else(|| rank_title_ungendered(lua, rank, team))
.or_else(|| lookup(&format!("PVP_RANK_{}_{}", title.rank, title.team)))
}
/// Fill a two-`%s` C format string, the install's template (enUS `UNIT_PVP_NAME` is `"%s %s"`);
@@ -254,14 +283,55 @@ fn format_two_strings(fmt: &str, a: &str, b: &str) -> String {
out
}
/// The install keys a name-line rank prefix can resolve: `UNIT_PVP_NAME` and every
/// `PVP_RANK_<rank>_<team>[_FEMALE]` for ranks 1..=19 and both team digits. The range reaches 19
/// because the builder is range-unchecked and the install's `PVP_RANK_19_*` names the racial
/// "Leader", past the player rank byte's 0..=18. The world-text pass, which runs without the VM,
/// reads them off each VM once; `UnitPVPName` looks them up live.
pub fn pvp_name_global_keys() -> Vec<String> {
let mut keys = Vec::with_capacity(1 + 19 * 2 * 2);
keys.push("UNIT_PVP_NAME".to_string());
for rank in 1..=19 {
for team in 0..=1 {
keys.push(format!("PVP_RANK_{rank}_{team}"));
keys.push(format!("PVP_RANK_{rank}_{team}_FEMALE"));
}
}
keys
}
/// `0x609370`'s ranked-player leg over a string lookup: `UNIT_PVP_NAME` filled rank first, the
/// same builder [`pvp_name`] runs for `UnitPVPName`. `None` for rank 0, or a title or template
/// the lookup lacks, where the callers keep the plain name: the reference prints a missing title
/// as `""` (`" " + name`), which a playable race's 0..=18 rank never reaches in the stock strings.
pub fn decorated_name(
lookup: impl Fn(&str) -> Option<String>,
key: RankTitle,
name: &str,
) -> Option<String> {
if key.rank == 0 {
return None;
}
let title = rank_title(&lookup, key)?;
let template = lookup("UNIT_PVP_NAME")?;
Some(format_two_strings(&template, &title, name))
}
/// `0x609370`, the name builder behind `UnitPVPName` (its legs are at the binding); `name` is the
/// plain `UnitName`.
fn pvp_name(lua: &Lua, u: &super::UnitState, name: &str, player_level: u32) -> String {
// Leg A: a ranked player; the title is gendered by this unit and range-unchecked.
if u.is_player && u.pvp_rank != 0 {
let title = rank_title_gendered(lua, i64::from(u.pvp_rank), team_of(u), u.sex == 3);
if let (Some(fmt), Some(title)) = (global_string(lua, "UNIT_PVP_NAME"), title) {
let mut out = format_two_strings(&fmt, &title, name);
let decorated = decorated_name(
|key| global_string(lua, key),
RankTitle {
rank: u.pvp_rank,
team: u.pvp_team,
female: u.sex == 3,
},
name,
);
if let Some(mut out) = decorated {
// Leg A′: the city-protector medal, on its own line, ungendered (`0x60941d push 0`).
if u.pvp_medal != 0 {
if let Some(medal) = global_string(lua, &format!("PVP_MEDAL{}", u.pvp_medal)) {
+82
View File
@@ -355,6 +355,88 @@ fn the_rust_side_title_lookup_is_gendered_where_the_binding_is_not() {
);
}
/// The lookup-generic builder the world-text pass runs ([`decorated_name`]): the same key
/// construction and `_FEMALE` fallback, and a plain `None` where the builder declines.
#[test]
fn the_lookup_generic_builder_resolves_the_same_keys() {
let strings: std::collections::HashMap<&str, &str> = [
("UNIT_PVP_NAME", "%s %s"),
("PVP_RANK_9_1", "Sergeant Major"),
("PVP_RANK_9_1_FEMALE", "Sergeant Major (f)"),
("PVP_RANK_9_0", "Senior Sergeant"),
]
.into();
let lookup = |key: &str| strings.get(key).map(|value| (*value).to_string());
assert_eq!(
decorated_name(
lookup,
RankTitle {
rank: 9,
team: 1,
female: false,
},
"Bob",
)
.as_deref(),
Some("Sergeant Major Bob"),
"rank first, name second"
);
assert_eq!(
decorated_name(
lookup,
RankTitle {
rank: 9,
team: 1,
female: true,
},
"Bob",
)
.as_deref(),
Some("Sergeant Major (f) Bob"),
"the female twin wins"
);
assert_eq!(
decorated_name(
lookup,
RankTitle {
rank: 9,
team: 0,
female: false,
},
"Bob",
)
.as_deref(),
Some("Senior Sergeant Bob"),
"team 0 keys the Horde list"
);
assert_eq!(
decorated_name(
lookup,
RankTitle {
rank: 0,
team: 1,
female: false,
},
"Bob",
),
None,
"unranked"
);
assert_eq!(
decorated_name(
lookup,
RankTitle {
rank: 6,
team: 1,
female: false,
},
"Bob",
),
None,
"a key the install lacks declines"
);
}
/// The panes' `if not rankName` fallback needs a nil, not an empty string or a raise.
#[test]
fn a_missing_rank_global_reads_nil_not_empty() {
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "moonwell-deploy"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Build vanilla MPQ packages and publish MoonWell launcher manifests to S3"
[lints]
workspace = true
[dependencies]
anyhow.workspace = true
benilla-mpq = { path = "../benilla-mpq" }
clap.workspace = true
http = "1"
md5 = "0.8"
rust-s3 = { version = "0.37.2", default-features = false, features = ["sync-rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
walkdir = "2.5"
wow-mpq = "0.6.4"
[dev-dependencies]
tempfile = "3"
+134
View File
@@ -0,0 +1,134 @@
# MoonWell deployment
`moonwell-deploy` builds MPQ patches, stages the Rust player client, generates
the launcher manifest, and publishes it to S3. It uses the same `wow-mpq` Rust
builder as MoonWellClientSources, with MPQ v1 and 4 KiB sectors for 1.12.1.
Its small single-unit blocks are converted to stored/sectored blocks, preserving
their bytes, because the current client reader rejects single-unit entries.
The 3.3.5 source tree and its WarcraftXL DLLs are not vanilla client data.
The source project's `.env` is read directly with `--env-file`; credentials
are never copied into the package or printed. Process environment values take
precedence. Supported settings are `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`,
optional `AWS_SESSION_TOKEN`, `AWS_DEFAULT_REGION`, `AWS_BUCKET`, `AWS_ENDPOINT`,
and `AWS_USE_PATH_STYLE_ENDPOINT`. Unquoted Windows paths elsewhere in that file
are ignored. `WOW_HOME` is never used as the vanilla data source.
## Windows pipeline
From the repository root:
```powershell
# Build, stage and generate the local manifest; does not upload.
.\scripts\deploy-moonwell.ps1 -DataPath 'D:\WoW-1.12.1\Data' `
-IconPath 'D:\Branding\MoonWell.ico' -AuthHost 'login.example:3724'
# Check the S3 upload plan without changing any objects.
.\scripts\deploy-moonwell.ps1 -DataPath 'D:\WoW-1.12.1\Data' `
-IconPath 'D:\Branding\MoonWell.ico' -AuthHost 'login.example:3724' -DryRun
# Build and publish a complete vanilla distribution.
.\scripts\deploy-moonwell.ps1 -DataPath 'D:\WoW-1.12.1\Data' `
-IconPath 'D:\Branding\MoonWell.ico' -AuthHost 'login.example:3724' -Publish
```
The default `.env` location is the sibling `MoonWellClientSources/.env`.
Override it with `-EnvFile`. Every run creates a new `dist/moonwell-<timestamp>`
directory; `-PackagePath` overrides it. The local manifest is adjacent to that
directory, with the suffix `.manifest.json`. Staging never changes the source
install or deletes an existing directory. With `-SkipClientBuild -BinaryPath`,
provide an already built player executable. Debug instruments must be disabled
in the binary distributed to players.
Optional `-PatchSource` points to your own source tree containing
`Data/patch-M/`, `Data/patch-N/`, etc. The Rust builder produces `Data/patch-M.MPQ`
and mounts them through the client's vanilla patch chain. A supplied patch
source takes precedence over the same named patch in the input install.
Locale subdirectories such as `Data/ruRU/patch-ruRU-5` are rejected because
MoonWell currently has no locale selection or mounting support.
The Windows script includes `vcruntime140.dll` from Visual Studio's x64
redistributable directory beside the game executable. `-VcRuntimePath` can
select this directory explicitly. Windows 10/11 supplies the Universal CRT.
When staging directly through the Rust CLI, include the required runtime DLL
before generating or publishing the manifest.
Only vanilla archives that the client mounts are copied from the Data directory.
All ten vanilla base archives are required. Input archives may use MPQ v1/v2,
as supported by the client reader; the winning `Spell.dbc` must have 173 columns.
These checks reject a normal 3.3.5 installation; they do not prove that a custom
patch's models, Lua, or DBC rows are compatible with 1.12.1. Test custom content
in the client before publishing it. Install content remains outside Git.
## S3 and Web API
```powershell
cargo run -p moonwell-deploy -- inspect --env-file ..\MoonWellClientSources\.env
cargo run -p moonwell-deploy -- check-storage --env-file ..\MoonWellClientSources\.env
cargo run -p moonwell-deploy -- publish --dir dist\moonwell-package `
--env-file ..\MoonWellClientSources\.env --dry-run
```
`check-storage` uploads a temporary 10 MiB object under `moonwell-deploy-check/`,
checks that its bytes round-trip correctly, then deletes that object. It does
not change any release or active manifest.
The inventory is `{ "buildHash": "...", "files": [{ "path": "...",
"size": 123, "sha256": "...", "objectKey": "..." }] }`. `buildHash` uses
the existing Python/launcher contract: sort by path, join
`path:size:sha256` with newline separators, then SHA-256 the UTF-8 bytes.
State, `.env` files, launcher metadata and partial downloads are excluded.
Symlinks and case-insensitive duplicate paths are rejected.
Files are streamed (multipart for large objects) into
`World of Warcraft 1.12.1/releases/<buildHash>/<relative-path>`. The release's
`manifest.json` is written after all files, and `manifest-1.12.1.json` is switched
last. Upload failure leaves the active inventory unchanged. No old objects are
deleted, so a download link already issued for an older release keeps working.
Run one publisher at a time. To roll back, restore a previous release inventory
as the active manifest; keep that release's objects.
Large files use four concurrent 8 MiB upload parts. The publisher checks each
part's MD5 and the completed object's size and ETag before publishing the
inventory. These ETag checks target the configured unencrypted Yandex S3
objects; SHA-256 remains the launcher manifest's integrity check. Repeating
`publish --dir <existing-package> --env-file .env` reuses completed objects
only when their content ETag and size match. Interrupted multipart files are
uploaded again; completed files are not transmitted again.
Files unchanged from the active release retain their verified immutable
`objectKey`, so publishing an executable or runtime update does not upload the
game data again. Keep all releases whose objects are referenced by an inventory.
The Laravel web app resolves a requested path through the active manifest's
`objectKey` and signs that S3 object. Its settings must match:
```dotenv
MOONWELL_LAUNCHER_BASE_PATH="World of Warcraft 1.12.1"
MOONWELL_LAUNCHER_MANIFEST_KEY=manifest-1.12.1.json
```
The Flutter launcher keeps requesting the existing Web API endpoints. It has
no S3 credentials. Inventories without `objectKey` retain the legacy
`<base-path>/<relative-path>` resolution. The old 3.3.5 `manifest.json` and
`World of Warcraft/` keys are reserved and cannot be publication targets.
Native macOS binaries must be built on a Mac and packaged with
[`package_macos.py`](../moonwell/package_macos.py). `stage --binary MoonWell.app`
can stage such a bundle, but publish it with separate `--manifest-key` and
`--prefix` settings for a launcher distribution targeting macOS. The current
API has one active inventory per configured distribution, not automatic OS
selection. This Windows pipeline does not build or sign macOS applications.
The current Flutter file downloader also does not restore Unix executable bits;
install the `.app` as a bundle with its permissions preserved when testing macOS.
## Checks
```powershell
cargo fmt -p moonwell-deploy -- --check
cargo clippy -p moonwell-deploy --all-targets -- -D warnings
cargo test -p moonwell-deploy
```
Tests cover actual client MPQ readability, excluded state and secrets, the
manifest contract, path validation, upload failures, dry runs and publication
ordering. Live client login remains a separate check.
+139
View File
@@ -0,0 +1,139 @@
mod manifest;
mod package;
mod storage;
mod vanilla_mpq;
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(about = "Build and publish MoonWell 1.12.1 client packages")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Build vanilla MPQ archives from <source>/Data/patch-? directories.
Pack {
#[arg(long)]
source: PathBuf,
#[arg(long)]
output: PathBuf,
},
/// Stage a player binary, read-only vanilla Data and optional patch sources.
Stage {
#[arg(long)]
binary: PathBuf,
#[arg(long)]
data: PathBuf,
#[arg(long)]
output: PathBuf,
#[arg(long)]
source: Option<PathBuf>,
#[arg(long)]
auth_host: String,
},
/// Generate the launcher inventory of an existing distribution directory.
Manifest {
#[arg(long)]
dir: PathBuf,
#[arg(long)]
output: PathBuf,
},
/// Inspect the remote manifest without writing anything.
Inspect {
#[arg(long)]
env_file: PathBuf,
#[arg(long, default_value = "manifest-1.12.1.json")]
manifest_key: String,
},
/// Verify S3 multipart upload/read using a temporary object, then remove it.
CheckStorage {
#[arg(long)]
env_file: PathBuf,
},
/// Upload a complete package to an immutable release; switch manifest last.
Publish {
#[arg(long)]
dir: PathBuf,
#[arg(long)]
env_file: PathBuf,
#[arg(long, default_value = "World of Warcraft 1.12.1")]
prefix: String,
#[arg(long, default_value = "manifest-1.12.1.json")]
manifest_key: String,
/// Read and report the upload plan without writing any S3 objects.
#[arg(long)]
dry_run: bool,
},
}
fn run() -> Result<()> {
match Cli::parse().command {
Command::Pack { source, output } => package::pack(&source, &output),
Command::Stage {
binary,
data,
output,
source,
auth_host,
} => package::stage(&binary, &data, &output, source.as_deref(), &auth_host),
Command::Manifest { dir, output } => {
package::validate_distribution(&dir)?;
let m = manifest::scan(&dir)?;
manifest::write(&m, &output)?;
println!(
"Manifest: {} files, buildHash={}",
m.files.len(),
m.build_hash
);
Ok(())
}
Command::Inspect {
env_file,
manifest_key,
} => {
let store = storage::S3Store::from_env(&env_file)?;
match storage::Store::get(&store, &manifest_key)? {
None => println!("No remote manifest at {manifest_key}"),
Some(bytes) => {
let m: manifest::Manifest =
serde_json::from_slice(&bytes).context("invalid remote manifest")?;
manifest::validate(&m)?;
println!(
"Remote manifest: {} files, buildHash={}",
m.files.len(),
m.build_hash
);
}
}
Ok(())
}
Command::CheckStorage { env_file } => {
storage::S3Store::from_env(&env_file)?.check_storage()
}
Command::Publish {
dir,
env_file,
prefix,
manifest_key,
dry_run,
} => {
package::validate_distribution(&dir)?;
let m = manifest::scan(&dir)?;
let store = storage::S3Store::from_env(&env_file)?;
storage::publish(&store, &dir, m, &prefix, &manifest_key, dry_run)
}
}
}
fn main() {
if let Err(error) = run() {
eprintln!("Error: {error:#}");
std::process::exit(1);
}
}
+219
View File
@@ -0,0 +1,219 @@
use std::collections::HashSet;
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::Path;
use anyhow::{bail, ensure, Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use walkdir::WalkDir;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Manifest {
pub build_hash: String,
pub files: Vec<ClientFile>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientFile {
pub path: String,
pub size: u64,
pub sha256: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub object_key: Option<String>,
}
pub fn valid_path(path: &str) -> bool {
!path.is_empty()
&& !path.contains(['\\', ':', '\r', '\n', '\0'])
&& path.split('/').all(|p| !matches!(p, "" | "." | ".."))
}
fn ignored(path: &Path) -> bool {
path.components().any(|part| {
let name = part.as_os_str().to_string_lossy().to_ascii_lowercase();
matches!(
name.as_str(),
"cache"
| "errors"
| "logs"
| "screenshots"
| "wtf"
| "benilla-config"
| ".moonwell_launcher"
| ".git"
| ".vscode"
| ".ds_store"
| ".probe-identity"
| "manifest.json"
) || name.starts_with(".env")
|| name.ends_with(".moonwell.part")
})
}
pub fn hash_file(path: &Path) -> Result<(u64, String)> {
let mut file = File::open(path).with_context(|| format!("reading {}", path.display()))?;
let mut hash = Sha256::new();
let mut buffer = vec![0; 1024 * 1024];
let mut size = 0;
loop {
let count = file.read(&mut buffer)?;
if count == 0 {
break;
}
hash.update(&buffer[..count]);
size += count as u64;
}
Ok((size, format!("{:x}", hash.finalize())))
}
pub fn build_hash(files: &[ClientFile]) -> String {
let mut entries: Vec<_> = files.iter().collect();
entries.sort_by(|a, b| a.path.cmp(&b.path));
let lines: Vec<_> = entries
.iter()
.map(|f| format!("{}:{}:{}", f.path, f.size, f.sha256))
.collect();
format!("{:x}", Sha256::digest(lines.join("\n").as_bytes()))
}
pub fn scan(root: &Path) -> Result<Manifest> {
ensure!(root.is_dir(), "package directory does not exist");
let mut files = Vec::new();
let mut names = HashSet::new();
for entry in WalkDir::new(root)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
e.depth() == 0 || !ignored(e.path().strip_prefix(root).expect("walk root"))
})
{
let entry = entry?;
ensure!(
!entry.path_is_symlink(),
"symlinks are not supported in packages: {}",
entry.path().display()
);
if !entry.file_type().is_file() {
continue;
}
let path = entry
.path()
.strip_prefix(root)?
.to_str()
.context("package path is not UTF-8")?
.replace('\\', "/");
ensure!(valid_path(&path), "invalid package path: {path}");
ensure!(
names.insert(path.to_ascii_lowercase()),
"case-insensitive duplicate path: {path}"
);
let (size, sha256) = hash_file(entry.path())?;
files.push(ClientFile {
path,
size,
sha256,
object_key: None,
});
}
files.sort_by(|a, b| a.path.cmp(&b.path));
ensure!(!files.is_empty(), "package is empty");
Ok(Manifest {
build_hash: build_hash(&files),
files,
})
}
pub fn validate(manifest: &Manifest) -> Result<()> {
ensure!(!manifest.files.is_empty(), "manifest is empty");
let mut paths = HashSet::new();
for f in &manifest.files {
ensure!(valid_path(&f.path), "invalid manifest path");
ensure!(
paths.insert(f.path.to_ascii_lowercase()),
"duplicate manifest path"
);
ensure!(
f.sha256.len() == 64
&& f.sha256
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()),
"invalid file SHA-256"
);
}
ensure!(
manifest.build_hash == build_hash(&manifest.files),
"manifest buildHash mismatch"
);
Ok(())
}
pub fn write(manifest: &Manifest, output: &Path) -> Result<()> {
let mut data = serde_json::to_vec_pretty(manifest)?;
data.push(b'\n');
if let Some(parent) = output.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
// Do not truncate an existing release inventory if writing fails.
let temp = output.with_extension("json.part");
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp)?;
file.write_all(&data)?;
file.sync_all()?;
drop(file);
if let Err(error) = fs::rename(&temp, output) {
let _ = fs::remove_file(&temp);
bail!("cannot write manifest: {error}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_matches_the_existing_python_and_launcher_contract() {
let f = ClientFile {
path: "a".into(),
size: 3,
sha256: "abc".into(),
object_key: None,
};
assert_eq!(
build_hash(&[f]),
format!("{:x}", Sha256::digest(b"a:3:abc"))
);
}
#[test]
fn scan_excludes_state_secrets_and_its_own_inventory() {
let dir = tempfile::tempdir().unwrap();
for name in [
"Data/patch-M.MPQ",
"benilla-config/config.toml",
"WTF/Config.wtf",
".env",
"manifest.json",
] {
let p = dir.path().join(name);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, b"test").unwrap();
}
let m = scan(dir.path()).unwrap();
assert_eq!(m.files.len(), 1);
assert_eq!(m.files[0].path, "Data/patch-M.MPQ");
validate(&m).unwrap();
}
#[test]
fn traversal_and_protocol_delimiters_are_rejected() {
for path in ["/a", "../a", "a/../b", "C:/a", "a\\b", "a\nb", "a//b"] {
assert!(!valid_path(path), "{path}");
}
}
}
+453
View File
@@ -0,0 +1,453 @@
use std::collections::HashSet;
use std::fs::{self, File};
use std::io::Read;
use std::path::Path;
use anyhow::{ensure, Context, Result};
use walkdir::WalkDir;
use wow_mpq::compression::flags;
use wow_mpq::{ArchiveBuilder, FormatVersion, ListfileOption};
const BASE_ARCHIVES: &[&str] = &[
"dbc.mpq",
"speech.mpq",
"fonts.mpq",
"interface.mpq",
"misc.mpq",
"sound.mpq",
"wmo.mpq",
"terrain.mpq",
"texture.mpq",
"model.mpq",
];
fn patch_name(name: &str) -> bool {
let name = name.to_ascii_lowercase();
name == "patch"
|| name
.strip_prefix("patch-")
.is_some_and(|s| s.len() == 1 && s.bytes().all(|b| b.is_ascii_alphanumeric()))
}
fn mounted_archive(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
BASE_ARCHIVES.contains(&lower.as_str())
|| lower == "speech2.mpq"
|| lower.strip_suffix(".mpq").is_some_and(patch_name)
}
fn valid_auth_host(host: &str) -> bool {
let (name, port) = host
.split_once(':')
.map_or((host, None), |(name, port)| (name, Some(port)));
name.as_bytes()
.first()
.is_some_and(u8::is_ascii_alphanumeric)
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'))
&& port.is_none_or(|p| {
p.len() <= 5
&& p.bytes().all(|b| b.is_ascii_digit())
&& p.parse::<u16>().is_ok_and(|p| p > 0)
})
}
fn validate_data(data: &Path) -> Result<Vec<std::path::PathBuf>> {
ensure!(data.is_dir(), "vanilla Data directory was not found");
let mut names = HashSet::new();
let mut files = Vec::new();
for entry in fs::read_dir(data)? {
let entry = entry?;
let name = entry
.file_name()
.into_string()
.map_err(|_| anyhow::anyhow!("non-UTF-8 archive name"))?;
if !mounted_archive(&name) {
continue;
}
ensure!(
entry.file_type()?.is_file(),
"archive must be a regular file: {name}"
);
ensure!(
names.insert(name.to_ascii_lowercase()),
"duplicate archive name: {name}"
);
let mut header = [0u8; 32];
File::open(entry.path())?.read_exact(&mut header)?;
ensure!(
&header[..4] == b"MPQ\x1a" && u16::from_le_bytes([header[12], header[13]]) <= 1,
"{name} is outside the client's MPQ v1/v2 envelope"
);
files.push(entry.path());
}
for base in BASE_ARCHIVES {
ensure!(
names.contains(*base),
"missing vanilla base archive: {base}"
);
}
files.sort();
// Modified installs can mimic vanilla archive names; check the winning 5875 Spell.dbc too.
let mut spell_archives: Vec<_> = files.iter().collect();
// Match benilla-formats' vanilla mount order, including speech2's final priority.
spell_archives.sort_by_key(|p| {
let name = p
.file_name()
.expect("archive name")
.to_string_lossy()
.to_ascii_lowercase();
let priority = BASE_ARCHIVES
.iter()
.position(|base| *base == name)
.unwrap_or_else(|| {
if name == "patch.mpq" {
10
} else if name == "speech2.mpq" {
12
} else {
11
}
});
(priority, name)
});
let mut found = false;
for path in spell_archives.into_iter().rev() {
let archive = benilla_mpq::Archive::open(path)?;
if !archive.contains(r"DBFilesClient\Spell.dbc") {
continue;
}
let mut stream = archive.open_file(r"DBFilesClient\Spell.dbc")?;
let mut header = [0u8; 20];
stream.read_exact(&mut header)?;
ensure!(
&header[..4] == b"WDBC"
&& u32::from_le_bytes(header[8..12].try_into().expect("DBC fields")) == 173,
"Spell.dbc is not the vanilla 5875 layout; 3.3.5 data cannot be deployed as 1.12.1"
);
found = true;
break;
}
ensure!(found, "vanilla Spell.dbc was not found");
Ok(files)
}
fn ensure_outside(input: &Path, output: &Path) -> Result<()> {
let input = input.canonicalize()?;
let absolute = if output.is_absolute() {
output.to_path_buf()
} else {
std::env::current_dir()?.join(output)
};
let parent = absolute
.parent()
.context("output needs a parent directory")?;
fs::create_dir_all(parent)?;
let output = if absolute.exists() {
absolute.canonicalize()?
} else {
parent
.canonicalize()?
.join(absolute.file_name().context("output needs a filename")?)
};
ensure!(
!output.starts_with(&input) && !input.starts_with(&output),
"source and output directories must be separate"
);
Ok(())
}
/// Same wow-mpq builder as MoonWellClientSources, using vanilla's v1 format.
pub fn pack(source: &Path, output: &Path) -> Result<()> {
ensure_outside(source, output)?;
let data = source.join("Data");
ensure!(
data.is_dir(),
"patch sources must contain Data/patch-? directories"
);
let mut patches: Vec<_> = fs::read_dir(&data)?.collect::<std::io::Result<_>>()?;
patches.sort_by_key(|e| e.file_name());
let mut count = 0;
for patch in patches {
let name = patch
.file_name()
.into_string()
.map_err(|_| anyhow::anyhow!("non-UTF-8 patch name"))?;
ensure!(patch.file_type()?.is_dir() && patch_name(&name),
"unsupported vanilla patch source: {name}; locale subdirectories are not supported by the client");
let target = output.join("Data").join(format!("{name}.MPQ"));
ensure!(
!target.exists(),
"patch output already exists: {}",
target.display()
);
let mut builder = ArchiveBuilder::new()
.version(FormatVersion::V1)
.block_size(3)
.default_compression(flags::ZLIB)
.listfile_option(ListfileOption::Generate);
let mut files = Vec::new();
let root = patch.path();
for entry in WalkDir::new(&root).follow_links(false) {
let entry = entry?;
ensure!(
!entry.path_is_symlink(),
"symlinks are not supported in patch sources"
);
ensure!(
entry.file_name() != "__mpqmeta__",
"unresolved MPQ blocks cannot be rebuilt safely"
);
if entry.file_type().is_file() {
let path = entry
.path()
.strip_prefix(&root)?
.to_str()
.context("non-UTF-8 patch path")?
.replace('\\', "/");
ensure!(crate::manifest::valid_path(&path), "invalid archive path");
ensure!(
!path.split('/').any(|p| p.starts_with(".env")),
"patch contains an environment file"
);
files.push((path, entry.into_path()));
}
}
ensure!(!files.is_empty(), "patch {name} is empty");
files.sort_by(|a, b| a.0.cmp(&b.0));
let mut names = HashSet::new();
for (path, source) in files {
ensure!(
names.insert(path.to_ascii_lowercase()),
"duplicate archive path: {path}"
);
builder = builder.add_file(source, &path);
}
fs::create_dir_all(target.parent().expect("Data parent"))?;
let temporary = target.with_extension("MPQ.part");
ensure!(
!temporary.exists(),
"unfinished patch output already exists"
);
builder.build(&temporary)?;
crate::vanilla_mpq::normalize(&temporary)?;
fs::rename(&temporary, &target)?;
println!("Built {}", target.display());
count += 1;
}
ensure!(count > 0, "no patch sources found");
Ok(())
}
pub fn validate_distribution(root: &Path) -> Result<()> {
let windows = root.join("MoonWell.exe");
let mac = root.join("MoonWell.app/Contents/MacOS/MoonWell");
let linux = root.join("MoonWell");
let binary = if windows.is_file() {
windows
} else if mac.is_file() {
ensure!(
root.join("MoonWell.app/Contents/MacOS/launch").is_file(),
"macOS app launcher is missing"
);
mac
} else {
linux
};
let mut magic = [0; 4];
File::open(&binary)
.context("MoonWell player binary is missing")?
.read_exact(&mut magic)?;
ensure!(
magic.starts_with(b"MZ")
|| magic == *b"\x7fELF"
|| [
0xfeedfaceu32,
0xfeedfacf,
0xcefaedfe,
0xcffaedfe,
0xcafebabe,
0xbebafeca
]
.contains(&u32::from_be_bytes(magic)),
"MoonWell binary is not a native executable"
);
validate_data(&root.join("Data"))?;
ensure!(
root.join("realmlist.wtf").is_file(),
"realmlist.wtf is missing"
);
Ok(())
}
pub fn stage(
binary: &Path,
data: &Path,
output: &Path,
source: Option<&Path>,
host: &str,
) -> Result<()> {
let host = host.trim();
ensure!(
valid_auth_host(host),
"auth host must be hostname or IPv4[:port]"
);
let archives = validate_data(data)?;
ensure_outside(data, output)?;
ensure_outside(binary, output)?;
if let Some(source) = source {
ensure_outside(source, output)?;
}
ensure!(
!output.exists(),
"staging output already exists; choose a new directory"
);
let name = binary.file_name().context("binary filename is missing")?;
ensure!(
matches!(
name.to_str(),
Some("MoonWell.exe" | "MoonWell" | "MoonWell.app")
),
"use a MoonWell player executable or .app"
);
fs::create_dir(output)?;
if binary.is_dir() {
for entry in WalkDir::new(binary).follow_links(false) {
let entry = entry?;
ensure!(
!entry.path_is_symlink(),
"bundle symlinks are not supported"
);
let dest = output.join(name).join(entry.path().strip_prefix(binary)?);
if entry.file_type().is_dir() {
fs::create_dir_all(dest)?;
} else {
fs::copy(entry.path(), dest)?;
}
}
} else {
fs::copy(binary, output.join(name))?;
}
let target_data = output.join("Data");
fs::create_dir(&target_data)?;
// Build custom patches first; an explicitly supplied source replaces that install's patch.
if let Some(source) = source {
pack(source, output)?;
}
let built_patches: HashSet<_> = fs::read_dir(&target_data)?
.map(|e| e.map(|e| e.file_name().to_string_lossy().to_ascii_lowercase()))
.collect::<std::io::Result<_>>()?;
for archive in archives {
let dest = target_data.join(archive.file_name().expect("archive name"));
if !built_patches.contains(
&archive
.file_name()
.expect("archive name")
.to_string_lossy()
.to_ascii_lowercase(),
) {
fs::copy(&archive, dest)?;
}
}
fs::write(
output.join("realmlist.wtf"),
format!("set realmlist {host}\n"),
)?;
validate_distribution(output)?;
println!("Staged {}", output.display());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn packed_archive_is_readable_by_the_actual_client_reader() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("src");
let patch = source.join("Data/patch-M/Interface");
fs::create_dir_all(&patch).unwrap();
let bytes = "-- MoonWell test: русский текст\n".repeat(500).into_bytes();
fs::write(patch.join("Test.lua"), &bytes).unwrap();
let small = b"-- compressed small file\n".repeat(50);
fs::write(patch.join("Small.lua"), &small).unwrap();
fs::write(patch.join("Tiny.lua"), b"hi").unwrap();
fs::write(patch.join("Empty.lua"), b"").unwrap();
let output = temp.path().join("dist");
pack(&source, &output).unwrap();
let archive = benilla_mpq::Archive::open(output.join("Data/patch-M.MPQ")).unwrap();
assert_eq!(archive.read_file(r"Interface\Test.lua").unwrap(), bytes);
assert_eq!(archive.read_file(r"Interface\Small.lua").unwrap(), small);
assert_eq!(archive.read_file(r"Interface\Tiny.lua").unwrap(), b"hi");
assert!(archive
.read_file(r"Interface\Empty.lua")
.unwrap()
.is_empty());
assert!(pack(&source, &output).is_err());
}
#[test]
fn wrath_locale_source_is_rejected() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("src");
fs::create_dir_all(source.join("Data/ruRU/patch-ruRU-5")).unwrap();
assert!(pack(&source, &temp.path().join("out")).is_err());
}
#[test]
fn staging_preserves_the_install_and_custom_sources_take_precedence() {
let temp = tempfile::tempdir().unwrap();
let data = temp.path().join("install/Data");
fs::create_dir_all(&data).unwrap();
for name in BASE_ARCHIVES.iter().chain([&"patch-m.mpq"]) {
let mut spell = [0u8; 20];
spell[..4].copy_from_slice(b"WDBC");
spell[8..12].copy_from_slice(&173u32.to_le_bytes());
ArchiveBuilder::new()
.version(FormatVersion::V1)
.add_file_data(b"old".to_vec(), "Test.txt")
.add_file_data(spell.to_vec(), r"DBFilesClient\Spell.dbc")
.build(data.join(name))
.unwrap();
crate::vanilla_mpq::normalize(&data.join(name)).unwrap();
}
let original = fs::read(data.join("patch-m.mpq")).unwrap();
let source = temp.path().join("src");
fs::create_dir_all(source.join("Data/patch-M")).unwrap();
fs::write(source.join("Data/patch-M/Test.txt"), b"custom").unwrap();
let binary = temp.path().join("MoonWell.exe");
fs::write(&binary, b"MZ\x00\x00 synthetic test executable").unwrap();
let out = temp.path().join("out");
stage(&binary, &data, &out, Some(&source), "127.0.0.1:3724").unwrap();
assert_eq!(fs::read(data.join("patch-m.mpq")).unwrap(), original);
let archive = benilla_mpq::Archive::open(out.join("Data/patch-M.MPQ")).unwrap();
assert_eq!(archive.read_file("Test.txt").unwrap(), b"custom");
assert_eq!(
fs::read_to_string(out.join("realmlist.wtf")).unwrap(),
"set realmlist 127.0.0.1:3724\n"
);
assert!(stage(&binary, &data, &out, None, "127.0.0.1").is_err());
}
#[test]
fn invalid_auth_ports_and_wrath_archives_are_rejected() {
for host in [
"host:0",
"host:65536",
"host:abc",
"host/path",
"host\nset realm x",
] {
assert!(!valid_auth_host(host));
}
let temp = tempfile::tempdir().unwrap();
ArchiveBuilder::new()
.version(FormatVersion::V2)
.add_file_data(b"test".to_vec(), "Test.txt")
.build(temp.path().join("dbc.MPQ"))
.unwrap();
assert!(validate_data(temp.path()).is_err());
}
}
+609
View File
@@ -0,0 +1,609 @@
use std::collections::HashMap;
use std::fs::File;
use std::io::Read;
use std::path::Path;
use anyhow::{ensure, Context, Result};
use s3::creds::Credentials;
use s3::{Bucket, Region};
use crate::manifest::{self, Manifest};
pub trait Store {
fn get(&self, key: &str) -> Result<Option<Vec<u8>>>;
fn matches(&self, _key: &str, _file: &Path) -> Result<bool> {
Ok(false)
}
fn upload(&self, key: &str, file: &Path) -> Result<()>;
fn put_manifest(&self, key: &str, data: &[u8]) -> Result<()>;
}
pub struct S3Store {
bucket: Box<Bucket>,
}
const PART_BYTES: usize = s3::bucket::CHUNK_SIZE;
const UPLOAD_WORKERS: usize = 4;
fn read_part(reader: &mut impl Read) -> Result<Vec<u8>> {
let mut bytes = Vec::with_capacity(PART_BYTES);
reader.take(PART_BYTES as u64).read_to_end(&mut bytes)?;
Ok(bytes)
}
// Yandex's unencrypted S3 objects expose MD5 or MD5-of-parts ETags. SHA-256
// remains the manifest's integrity contract; ETags allow verified upload reuse.
fn file_etag(path: &Path) -> Result<(u64, String)> {
let mut reader = File::open(path)?;
let mut size = 0;
let mut part_hashes = Vec::new();
loop {
let bytes = read_part(&mut reader)?;
size += bytes.len() as u64;
part_hashes.push(md5::compute(&bytes));
if bytes.len() < PART_BYTES {
break;
}
}
let etag = if part_hashes.len() == 1 {
format!("{:x}", part_hashes[0])
} else {
let mut digest = md5::Context::new();
for part in &part_hashes {
digest.consume(part.0);
}
format!("{:x}-{}", digest.finalize(), part_hashes.len())
};
Ok((size, etag))
}
fn read_aws_env(path: &Path) -> Result<HashMap<String, String>> {
// The source project's .env permits unquoted Windows paths containing spaces.
// Read only S3 settings with its existing literal-value semantics; do not interpolate secrets.
let text = std::fs::read_to_string(path).context("cannot read deployment .env")?;
let mut values = HashMap::new();
for line in text.trim_start_matches('\u{feff}').lines() {
let line = line.trim().strip_prefix("export ").unwrap_or(line.trim());
let Some((name, value)) = line.split_once('=') else {
continue;
};
let name = name.trim();
if !name.starts_with("AWS_") {
continue;
}
ensure!(
name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_'),
"invalid AWS environment variable name"
);
let value = value.trim();
let value = if value.len() >= 2
&& ((value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\'')))
{
&value[1..value.len() - 1]
} else {
value
};
values.insert(name.into(), value.into());
}
Ok(values)
}
impl S3Store {
fn object_matches(&self, key: &str, size: u64, etag: &str) -> Result<bool> {
let (head, status) = self
.bucket
.head_object(key)
.map_err(|_| anyhow::anyhow!("S3 HEAD failed for {key}"))?;
if status == 404 {
return Ok(false);
}
ensure!(status == 200, "S3 HEAD {key}: HTTP {status}");
Ok(head.content_length == i64::try_from(size).ok()
&& head.e_tag.as_deref().map(|s| s.trim_matches('"')) == Some(etag))
}
fn upload_parts(&self, key: &str, path: &Path, size: u64) -> Result<()> {
let mut reader = File::open(path)?;
if size < PART_BYTES as u64 {
let bytes = read_part(&mut reader)?;
let response = self
.bucket
.put_object(key, &bytes)
.map_err(|_| anyhow::anyhow!("S3 upload failed for {key}"))?;
ensure!(
(200..300).contains(&response.status_code()),
"S3 upload {key}: HTTP {}",
response.status_code()
);
return Ok(());
}
let content_type = "application/octet-stream";
let upload = self
.bucket
.initiate_multipart_upload(key, content_type)
.map_err(|_| anyhow::anyhow!("S3 multipart start failed for {key}"))?;
let result = (|| {
let mut parts = Vec::new();
let mut finished = false;
let mut sent = 0_u64;
while !finished {
let batch = std::thread::scope(|scope| -> Result<Vec<s3::serde_types::Part>> {
let mut handles = Vec::new();
for _ in 0..UPLOAD_WORKERS {
if finished {
break;
}
let bytes = read_part(&mut reader)?;
finished = bytes.len() < PART_BYTES;
sent += bytes.len() as u64;
let number = (parts.len() + handles.len() + 1) as u32;
let upload_id = &upload.upload_id;
handles.push(scope.spawn(move || {
let expected = format!("{:x}", md5::compute(&bytes));
let part = self
.bucket
.put_multipart_chunk(&bytes, key, number, upload_id, content_type)
.map_err(|_| {
anyhow::anyhow!(
"S3 multipart upload failed for {key}, part {number}"
)
})?;
ensure!(
part.etag.trim_matches('"') == expected,
"S3 part checksum mismatch for {key}, part {number}"
);
Ok(part)
}));
}
handles
.into_iter()
.map(|handle| {
handle
.join()
.map_err(|_| anyhow::anyhow!("S3 upload worker failed for {key}"))?
})
.collect()
})?;
parts.extend(batch);
println!("Uploading {key}: {sent}/{size} bytes");
}
let response = self
.bucket
.complete_multipart_upload(key, &upload.upload_id, parts)
.map_err(|_| anyhow::anyhow!("S3 multipart completion failed for {key}"))?;
ensure!(
(200..300).contains(&response.status_code()),
"S3 multipart completion {key}: HTTP {}",
response.status_code()
);
Ok(())
})();
if result.is_err() {
let _ = self.bucket.abort_upload(key, &upload.upload_id);
}
result
}
pub fn check_storage(&self) -> Result<()> {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_nanos();
let key = format!("moonwell-deploy-check/{nonce}/multipart test.bin");
let manifest_key = format!("moonwell-deploy-check/{nonce}/manifest.json");
let path = std::env::temp_dir().join(format!("moonwell-s3-check-{nonce}.bin"));
let bytes: Vec<u8> = (0..10 * 1024 * 1024).map(|n| (n % 251) as u8).collect();
std::fs::write(&path, &bytes)?;
let result = (|| {
self.upload(&key, &path)?;
self.upload(&key, &path)?;
let downloaded = self.get(&key)?.context("S3 check object disappeared")?;
ensure!(downloaded == bytes, "S3 round-trip checksum mismatch");
let mut changed = bytes.clone();
changed[0] ^= 1;
std::fs::write(&path, &changed)?;
self.upload(&key, &path)?;
ensure!(
self.get(&key)?.as_deref() == Some(changed.as_slice()),
"S3 same-size replacement checksum mismatch"
);
let inventory = b"{\"probe\":true}\n";
self.put_manifest(&manifest_key, inventory)?;
ensure!(
self.get(&manifest_key)?.as_deref() == Some(inventory),
"S3 JSON manifest round-trip mismatch"
);
Ok(())
})();
let cleanup = self
.bucket
.delete_object(&key)
.map_err(|_| anyhow::anyhow!("cannot clean up S3 check object: {key}"))
.and_then(|r| {
ensure!(
(200..300).contains(&r.status_code()),
"cannot clean up S3 check object: {key}"
);
Ok(())
});
let _ = std::fs::remove_file(&path);
let manifest_cleanup = self
.bucket
.delete_object(&manifest_key)
.map_err(|_| anyhow::anyhow!("cannot clean up S3 check inventory: {manifest_key}"))
.and_then(|r| {
ensure!(
(200..300).contains(&r.status_code()),
"cannot clean up S3 check inventory: {manifest_key}"
);
Ok(())
});
result?;
cleanup?;
manifest_cleanup?;
println!("S3 check passed: 10 MiB multipart round-trip verified; temporary object removed");
Ok(())
}
pub fn from_env(path: &Path) -> Result<Self> {
let values = read_aws_env(path)?;
let get = |key: &str| {
std::env::var(key)
.ok()
.filter(|v| !v.is_empty())
.or_else(|| values.get(key).cloned().filter(|v| !v.is_empty()))
};
let required = |key: &str| get(key).with_context(|| format!("{key} is not set"));
let credentials = Credentials::new(
Some(&required("AWS_ACCESS_KEY_ID")?),
Some(&required("AWS_SECRET_ACCESS_KEY")?),
None,
get("AWS_SESSION_TOKEN").as_deref(),
None,
)
.map_err(|_| anyhow::anyhow!("cannot initialize S3 credentials"))?;
let endpoint = required("AWS_ENDPOINT")?;
ensure!(
endpoint.starts_with("https://"),
"AWS_ENDPOINT must use HTTPS"
);
let bucket = Bucket::new(
&required("AWS_BUCKET")?,
Region::Custom {
region: required("AWS_DEFAULT_REGION")?,
endpoint,
},
credentials,
)
.map_err(|_| anyhow::anyhow!("cannot initialize S3 client"))?;
let bucket = match get("AWS_USE_PATH_STYLE_ENDPOINT").as_deref() {
Some("true" | "1") => bucket.with_path_style(),
None | Some("false" | "0") => bucket,
_ => anyhow::bail!("AWS_USE_PATH_STYLE_ENDPOINT must be true or false"),
};
Ok(Self { bucket })
}
}
impl Store for S3Store {
fn matches(&self, key: &str, file: &Path) -> Result<bool> {
let (size, etag) = file_etag(file)?;
self.object_matches(key, size, &etag)
}
fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
ensure!(manifest::valid_path(key), "invalid S3 key");
let response = self
.bucket
.get_object(key)
.map_err(|_| anyhow::anyhow!("S3 GET failed for {key}"))?;
match response.status_code() {
404 => Ok(None),
200 => Ok(Some(response.to_vec())),
code => anyhow::bail!("S3 GET {key}: HTTP {code}"),
}
}
fn upload(&self, key: &str, file: &Path) -> Result<()> {
let (size, etag) = file_etag(file)?;
if self.object_matches(key, size, &etag)? {
println!("Reusing verified S3 object: {key}");
return Ok(());
}
self.upload_parts(key, file, size)?;
ensure!(
self.object_matches(key, size, &etag)?,
"S3 object checksum/size mismatch for {key}"
);
Ok(())
}
fn put_manifest(&self, key: &str, data: &[u8]) -> Result<()> {
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::CACHE_CONTROL,
"no-cache, no-store, must-revalidate".parse()?,
);
let response = self
.bucket
.put_object_with_content_type_and_headers(key, data, "application/json", Some(headers))
.map_err(|_| anyhow::anyhow!("S3 manifest write failed for {key}"))?;
ensure!(
(200..300).contains(&response.status_code()),
"S3 manifest {key}: HTTP {}",
response.status_code()
);
Ok(())
}
}
pub fn publish(
store: &impl Store,
dir: &Path,
mut m: Manifest,
prefix: &str,
manifest_key: &str,
dry_run: bool,
) -> Result<()> {
manifest::validate(&m)?;
ensure!(
manifest::valid_path(prefix) && manifest::valid_path(manifest_key),
"invalid publication prefix or manifest key"
);
ensure!(
prefix != "World of Warcraft" && manifest_key != "manifest.json",
"the legacy 3.3.5 publication keys are reserved"
);
let release = format!("{prefix}/releases/{}", m.build_hash);
for file in &mut m.files {
file.object_key = Some(format!("{release}/{}", file.path));
}
println!(
"Release: {release}; {} files; active manifest: {manifest_key}",
m.files.len()
);
// Check credentials/read access before uploading anything, and refuse malformed active inventories.
let previous = if let Some(previous) = store.get(manifest_key)? {
let previous: Manifest = serde_json::from_slice(&previous)
.context("invalid active manifest; publication stopped")?;
manifest::validate(&previous)?;
Some(previous)
} else {
None
};
for file in &mut m.files {
let path = dir.join(&file.path);
let (size, hash) = manifest::hash_file(&path)?;
ensure!(
size == file.size && hash == file.sha256,
"package changed after hashing: {}",
file.path
);
let reusable = previous
.as_ref()
.and_then(|previous| {
previous.files.iter().find(|old| {
old.path == file.path && old.size == file.size && old.sha256 == file.sha256
})
})
.and_then(|old| old.object_key.as_deref())
.filter(|key| key.starts_with(&format!("{prefix}/releases/")));
let reused = if let Some(key) = reusable {
if store.matches(key, &path)? {
file.object_key = Some(key.into());
true
} else {
false
}
} else {
false
};
if !dry_run && !reused {
store.upload(file.object_key.as_deref().expect("release key"), &path)?;
}
println!(
"{} {} ({} bytes)",
match (dry_run, reused) {
(true, true) => "Would reuse",
(true, false) => "Would upload",
(false, true) => "Reused",
(false, false) => "Uploaded",
},
file.path,
size
);
}
if dry_run {
return Ok(());
}
// A concurrent editor or build must not produce a manifest describing different bytes.
for file in &m.files {
let (size, hash) = manifest::hash_file(&dir.join(&file.path))?;
ensure!(
size == file.size && hash == file.sha256,
"package changed during upload: {}",
file.path
);
}
let mut bytes = serde_json::to_vec_pretty(&m)?;
bytes.push(b'\n');
store.put_manifest(&format!("{release}/manifest.json"), &bytes)?;
store.put_manifest(manifest_key, &bytes)?;
println!("Published {}", m.build_hash);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn etags_match_small_and_multipart_s3_golden_vectors() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("object");
std::fs::write(&path, b"abc").unwrap();
assert_eq!(
file_etag(&path).unwrap(),
(3, "900150983cd24fb0d6963f7d28e17f72".into())
);
std::fs::write(&path, []).unwrap();
assert_eq!(
file_etag(&path).unwrap(),
(0, "d41d8cd98f00b204e9800998ecf8427e".into())
);
let mut bytes = vec![b'a'; PART_BYTES];
std::fs::write(&path, &bytes).unwrap();
assert_eq!(
file_etag(&path).unwrap(),
(8388608, "49bcf0ebc7b48b92488324fed7d003f9-2".into())
);
bytes.extend_from_slice(b"bc");
std::fs::write(&path, &bytes).unwrap();
assert_eq!(
file_etag(&path).unwrap(),
(8388610, "097020083a0e59701fcdfbc5edb191ba-2".into())
);
}
#[test]
fn source_dotenv_windows_paths_do_not_break_literal_credentials() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join(".env");
std::fs::write(&path, "\u{feff}WOW_HOME=C:\\Games\\World of Warcraft 3.3.5\nexport AWS_SECRET_ACCESS_KEY='literal$not-expanded'\nAWS_BUCKET=client\n").unwrap();
let values = read_aws_env(&path).unwrap();
assert_eq!(values.len(), 2);
assert_eq!(values["AWS_SECRET_ACCESS_KEY"], "literal$not-expanded");
}
use std::cell::RefCell;
struct FakeStore {
operations: RefCell<Vec<String>>,
fail_upload: bool,
}
impl Store for FakeStore {
fn get(&self, _: &str) -> Result<Option<Vec<u8>>> {
Ok(None)
}
fn upload(&self, key: &str, _: &Path) -> Result<()> {
ensure!(!self.fail_upload, "simulated upload failure");
self.operations.borrow_mut().push(key.into());
Ok(())
}
fn put_manifest(&self, key: &str, _: &[u8]) -> Result<()> {
self.operations.borrow_mut().push(key.into());
Ok(())
}
}
#[test]
fn failed_upload_does_not_switch_manifest_and_dry_run_never_writes() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("MoonWell.exe"), b"test").unwrap();
let m = manifest::scan(dir.path()).unwrap();
let store = FakeStore {
operations: RefCell::new(vec![]),
fail_upload: true,
};
assert!(publish(
&store,
dir.path(),
m.clone(),
"MoonWell",
"active.json",
false
)
.is_err());
assert!(store.operations.borrow().is_empty());
publish(&store, dir.path(), m, "MoonWell", "active.json", true).unwrap();
assert!(store.operations.borrow().is_empty());
}
#[test]
fn manifest_switch_follows_all_file_uploads_and_release_inventory() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("MoonWell.exe"), b"test").unwrap();
let m = manifest::scan(dir.path()).unwrap();
let store = FakeStore {
operations: RefCell::new(vec![]),
fail_upload: false,
};
publish(&store, dir.path(), m, "MoonWell", "active.json", false).unwrap();
let ops = store.operations.borrow();
assert!(ops[0].ends_with("/MoonWell.exe"));
assert!(ops[1].ends_with("/manifest.json"));
assert_eq!(ops[2], "active.json");
}
#[test]
fn unchanged_files_reuse_verified_objects_inside_the_release_prefix() {
struct ReuseStore {
previous: Vec<u8>,
uploads: RefCell<Vec<String>>,
published: RefCell<Option<Manifest>>,
}
impl Store for ReuseStore {
fn get(&self, _: &str) -> Result<Option<Vec<u8>>> {
Ok(Some(self.previous.clone()))
}
fn matches(&self, key: &str, _: &Path) -> Result<bool> {
ensure!(
key == "MoonWell/releases/old/MoonWell.exe",
"unexpected source prefix"
);
Ok(true)
}
fn upload(&self, key: &str, _: &Path) -> Result<()> {
self.uploads.borrow_mut().push(key.into());
Ok(())
}
fn put_manifest(&self, _: &str, bytes: &[u8]) -> Result<()> {
*self.published.borrow_mut() = Some(serde_json::from_slice(bytes)?);
Ok(())
}
}
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("MoonWell.exe"), b"game").unwrap();
let mut old = manifest::scan(dir.path()).unwrap();
old.files[0].object_key = Some("MoonWell/releases/old/MoonWell.exe".into());
std::fs::write(dir.path().join("vcruntime140.dll"), b"runtime").unwrap();
let new = manifest::scan(dir.path()).unwrap();
for source in [
"MoonWell/releases/old/MoonWell.exe",
"Other/releases/old/MoonWell.exe",
] {
old.files[0].object_key = Some(source.into());
let store = ReuseStore {
previous: serde_json::to_vec(&old).unwrap(),
uploads: RefCell::new(vec![]),
published: RefCell::new(None),
};
publish(
&store,
dir.path(),
new.clone(),
"MoonWell",
"active.json",
false,
)
.unwrap();
let result = store.published.borrow();
let result = result.as_ref().unwrap();
assert_eq!(result.build_hash, new.build_hash);
let game = result
.files
.iter()
.find(|file| file.path == "MoonWell.exe")
.unwrap();
if source.starts_with("MoonWell/") {
assert_eq!(store.uploads.borrow().len(), 1);
assert_eq!(game.object_key.as_deref(), Some(source));
} else {
assert_eq!(store.uploads.borrow().len(), 2);
assert!(game
.object_key
.as_deref()
.unwrap()
.starts_with(&format!("MoonWell/releases/{}/", new.build_hash)));
}
}
}
}
+79
View File
@@ -0,0 +1,79 @@
//! Normalize wow-mpq's small-file blocks to the stored/sectored envelope read by benilla-mpq.
use std::fs::OpenOptions;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::Path;
use anyhow::{ensure, Result};
use wow_mpq::crypto::{decrypt_block, encrypt_block, hash_string, hash_type};
pub fn normalize(path: &Path) -> Result<()> {
let mut file = OpenOptions::new().read(true).write(true).open(path)?;
let original_size = file.metadata()?.len();
let mut header = [0u8; 32];
file.read_exact(&mut header)?;
ensure!(
&header[..4] == b"MPQ\x1a" && header[12..14] == [0, 0],
"expected generated MPQ v1"
);
let word =
|offset| u32::from_le_bytes(header[offset..offset + 4].try_into().expect("header word"));
let table_pos = word(20) as u64;
let count = word(28) as usize;
ensure!(
table_pos <= original_size && count as u64 <= (original_size - table_pos) / 16,
"invalid generated block table"
);
let mut table_bytes = vec![0; count * 16];
file.seek(SeekFrom::Start(table_pos))?;
file.read_exact(&mut table_bytes)?;
let mut table: Vec<u32> = table_bytes
.as_chunks::<4>()
.0
.iter()
.map(|c| u32::from_le_bytes(*c))
.collect();
let key = hash_string("(block table)", hash_type::FILE_KEY);
decrypt_block(&mut table, key);
const SINGLE_UNIT: u32 = 0x0100_0000;
const COMPRESS: u32 = 0x0000_0200;
for block in table.as_chunks_mut::<4>().0 {
if block[3] & SINGLE_UNIT == 0 {
continue;
}
ensure!(
block[3] & (0x0001_0000 | 0x0400_0000) == 0,
"unexpected encrypted or CRC block"
);
if block[3] & COMPRESS != 0 {
// wow-mpq chooses single-unit only for <= one sector. Preserve compressed bytes,
// prepend the one-sector offset table, and redirect this block to the appended copy.
ensure!(
block[2] <= 4096 && u64::from(block[0]) + u64::from(block[1]) <= original_size,
"invalid small-file block"
);
let mut bytes = vec![0; block[1] as usize];
file.seek(SeekFrom::Start(block[0] as u64))?;
file.read_exact(&mut bytes)?;
let pos = file.seek(SeekFrom::End(0))?;
block[0] = u32::try_from(pos)?;
block[1] = block[1]
.checked_add(8)
.ok_or_else(|| anyhow::anyhow!("MPQ size overflow"))?;
file.write_all(&8u32.to_le_bytes())?;
file.write_all(&block[1].to_le_bytes())?;
file.write_all(&bytes)?;
}
block[3] &= !SINGLE_UNIT;
}
encrypt_block(&mut table, key);
file.seek(SeekFrom::Start(table_pos))?;
for word in table {
file.write_all(&word.to_le_bytes())?;
}
let size = u32::try_from(file.metadata()?.len())?;
file.seek(SeekFrom::Start(8))?;
file.write_all(&size.to_le_bytes())?;
file.sync_all()?;
Ok(())
}
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "moonwell"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "The MoonWell launcher: platform branding on top of benilla"
[lints]
workspace = true
[features]
default = ["dev"]
dev = ["benilla-app/dev"]
tracy = ["benilla-app/tracy"]
trace_chrome = ["benilla-app/trace_chrome"]
[dependencies]
benilla-app = { path = "../benilla-app", default-features = false }
bevy.workspace = true
[target.'cfg(windows)'.dependencies]
image = { version = "0.25", default-features = false, features = ["ico"] }
winit = { version = "0.30", default-features = false }
[build-dependencies]
benilla-buildstamp = { path = "../benilla-buildstamp" }
winresource = "0.1.31"
[[bin]]
name = "MoonWell"
path = "src/main.rs"
test = false
+43
View File
@@ -0,0 +1,43 @@
# MoonWell
MoonWell starts benilla through `run_with` and adds its own window title and platform
branding. The client build banner identifies it as `extended`.
For player package staging, Rust MPQ builds, launcher manifests and S3 publication,
see [MoonWell deployment](../moonwell-deploy/README.md).
Build the player executable on Windows:
```powershell
cargo build -p moonwell --release --no-default-features
```
The executable is `target/release/MoonWell.exe`. Provide your own WoW 1.12.1
`Data/` or `WoW/Data/` beside it, or set `WOW_DATA` to your install's Data folder.
Local client state is in `benilla-config/` beside the executable.
For development, run `cargo run -p moonwell --profile play`. The `dev` feature
forwards benilla's instruments; disable it for a player build.
Replace `assets/moonwell.ico` with your own multi-resolution Windows icon and
rebuild to update both the executable's icon and the running window's icons.
The icon is embedded; the player does not need the source `.ico` file.
On macOS, build and package a player app from the repository root (Python 3.11+):
```sh
cargo build -p moonwell --release --no-default-features
python3 crates/moonwell/package_macos.py \
--binary target/release/MoonWell \
--icon /path/to/MoonWell.icns
```
This creates `target/release/bundle/MoonWell.app` with the MoonWell name and app
icon. Packaging requires a macOS executable and an ICNS icon; it does not convert a
Windows EXE into a Mac application. Build natively on each target Mac architecture.
Pass `--output` to choose a different destination and `--bundle-id` to set your
application identifier. An existing destination is never overwritten.
Launch the `.app` from Finder. Its launcher finds your own `Data/` or `WoW/Data/`
beside `MoonWell.app` and keeps `benilla-config/` there too, outside the bundle.
Explicit `WOW_DATA` and `BENILLA_HOME` values take priority.
+17
View File
@@ -0,0 +1,17 @@
//! Stamp the build and embed MoonWell's Windows icon and product metadata.
fn main() {
benilla_buildstamp::emit();
println!("cargo::rerun-if-changed=assets/moonwell.ico");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
winresource::WindowsResource::new()
.set_icon("assets/moonwell.ico")
.set("ProductName", "MoonWell")
.set("FileDescription", "MoonWell")
.set("InternalName", "MoonWell")
.set("OriginalFilename", "MoonWell.exe")
.compile()
.expect("compile MoonWell's Windows resources");
}
}
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Package a macOS player build and an ICNS icon as MoonWell.app."""
import argparse
import os
from pathlib import Path
import plistlib
import shutil
import struct
import tomllib
# Keep the install and writable client state beside the app, outside the bundle.
LAUNCHER = '''#!/bin/sh
set -eu
macos_dir=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
player_dir=$(CDPATH= cd -- "$macos_dir/../../.." && pwd)
if [ "${BENILLA_HOME+x}" != x ]; then
export BENILLA_HOME="$player_dir/benilla-config"
fi
if [ "${WOW_DATA+x}" != x ]; then
for data_dir in "$player_dir/Data" "$player_dir/WoW/Data"; do
if [ -d "$data_dir" ]; then
export WOW_DATA="$data_dir"
break
fi
done
fi
exec "$macos_dir/MoonWell" "$@"
'''
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--binary", type=Path, required=True,
help="MoonWell binary built on macOS without default features")
parser.add_argument("--icon", type=Path, required=True, help="application icon in .icns format")
parser.add_argument("--output", type=Path, default=Path("target/release/bundle/MoonWell.app"))
parser.add_argument("--bundle-id", default="org.moonwell.client")
args = parser.parse_args()
with args.binary.open("rb") as binary:
magic = binary.read(4)
if magic not in (b"\xfe\xed\xfa\xce", b"\xce\xfa\xed\xfe",
b"\xfe\xed\xfa\xcf", b"\xcf\xfa\xed\xfe",
b"\xca\xfe\xba\xbe", b"\xbe\xba\xfe\xca",
b"\xca\xfe\xba\xbf", b"\xbf\xba\xfe\xca"):
parser.error("--binary must be a macOS Mach-O executable, not a Windows EXE")
with args.icon.open("rb") as icon:
header = icon.read(8)
if len(header) != 8 or header[:4] != b"icns" or struct.unpack(">I", header[4:])[0] != args.icon.stat().st_size:
parser.error("--icon must be an ICNS file with a valid container header")
if args.output.suffix != ".app":
parser.error("--output must end in .app")
if args.output.exists():
parser.error("--output already exists; choose a new path or remove the old bundle first")
root = Path(__file__).resolve().parents[2]
with (root / "Cargo.toml").open("rb") as manifest:
version = tomllib.load(manifest)["workspace"]["package"]["version"]
contents = args.output / "Contents"
macos = contents / "MacOS"
resources = contents / "Resources"
macos.mkdir(parents=True)
resources.mkdir()
shutil.copyfile(args.binary, macos / "MoonWell")
(macos / "MoonWell").chmod(0o755)
(macos / "launch").write_text(LAUNCHER, encoding="utf-8", newline="\n")
(macos / "launch").chmod(0o755)
shutil.copyfile(args.icon, resources / "MoonWell.icns")
with (contents / "Info.plist").open("wb") as plist:
plistlib.dump({
"CFBundleName": "MoonWell",
"CFBundleDisplayName": "MoonWell",
"CFBundleExecutable": "launch",
"CFBundleIconFile": "MoonWell.icns",
"CFBundleIdentifier": args.bundle_id,
"CFBundleInfoDictionaryVersion": "6.0",
"CFBundlePackageType": "APPL",
"CFBundleShortVersionString": version,
"CFBundleVersion": version,
"NSHighResolutionCapable": True,
}, plist)
print(os.fspath(args.output.resolve()))
if __name__ == "__main__":
main()
+69
View File
@@ -0,0 +1,69 @@
//! MoonWell's branding lives in this launcher, on top of the stock client.
use benilla_app::BuildId;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
struct MoonWellPlugin;
impl Plugin for MoonWellPlugin {
fn build(&self, app: &mut App) {
let world = app.world_mut();
for mut window in world
.query_filtered::<&mut Window, With<PrimaryWindow>>()
.iter_mut(world)
{
window.title = "MoonWell".into();
window.name = Some("MoonWell".into());
}
#[cfg(windows)]
app.add_systems(Startup, set_window_icon);
}
}
/// An exclusive system keeps winit's thread-local window access on the main thread.
#[cfg(windows)]
fn set_window_icon(world: &mut World) {
use winit::platform::windows::WindowExtWindows;
let image = image::load_from_memory_with_format(
include_bytes!("../assets/moonwell.ico"),
image::ImageFormat::Ico,
)
.expect("decode MoonWell's embedded icon")
.into_rgba8();
let (width, height) = image.dimensions();
let icon = winit::window::Icon::from_rgba(image.into_raw(), width, height)
.expect("create MoonWell's window icon");
let entity = world
.query_filtered::<Entity, With<PrimaryWindow>>()
.single(world)
.expect("the client has one primary window");
bevy::winit::WINIT_WINDOWS.with_borrow(|windows| {
let window = windows
.get_window(entity)
.expect("the primary window exists before Startup");
window.set_window_icon(Some(icon.clone()));
window.set_taskbar_icon(Some(icon));
});
}
fn main() -> benilla_app::AppExit {
benilla_app::run_with(
BuildId {
version: env!("CARGO_PKG_VERSION"),
describe: env!("BENILLA_GIT_DESCRIBE"),
sha: env!("BENILLA_GIT_SHA"),
short: env!("BENILLA_GIT_SHORT"),
date: env!("BENILLA_GIT_DATE"),
profile: env!("BENILLA_PROFILE"),
project_dir: env!("BENILLA_PROJECT_DIR"),
..Default::default()
},
|app| {
app.add_plugins(MoonWellPlugin);
},
)
}
+94
View File
@@ -0,0 +1,94 @@
[CmdletBinding()]
param(
[string]$DataPath = '',
[string]$IconPath = '',
[string]$PatchSource = '',
[string]$AuthHost = '',
[string]$EnvFile = '',
[string]$PackagePath = '',
[string]$BinaryPath = '',
[string]$VcRuntimePath = '',
[string]$Prefix = 'World of Warcraft 1.12.1',
[string]$ManifestKey = 'manifest-1.12.1.json',
[switch]$Publish,
[switch]$DryRun,
[switch]$SkipClientBuild
)
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path $PSScriptRoot -Parent
if (-not $EnvFile) {
$EnvFile = Join-Path (Split-Path $repoRoot -Parent) 'MoonWellClientSources\.env'
}
if (-not $PackagePath) {
$PackagePath = Join-Path $repoRoot ('dist\moonwell-' + (Get-Date -Format 'yyyyMMdd-HHmmss'))
}
if (-not $BinaryPath) { $BinaryPath = Join-Path $repoRoot 'target\release\MoonWell.exe' }
if ($BinaryPath.EndsWith('.exe', [StringComparison]::OrdinalIgnoreCase)) {
if (-not $VcRuntimePath) {
$vswherePath = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
$vsInstall = (& $vswherePath -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath).Trim()
if ($LASTEXITCODE -ne 0 -or -not $vsInstall) { throw 'Visual Studio C++ runtime was not found; pass -VcRuntimePath.' }
$redistVersion = Get-ChildItem -LiteralPath (Join-Path $vsInstall 'VC\Redist\MSVC') -Directory |
Where-Object { $_.Name -match '^\d+\.\d+\.\d+$' } |
Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1
$VcRuntimePath = Join-Path $redistVersion.FullName 'x64\Microsoft.VC143.CRT'
}
if (-not (Test-Path -LiteralPath (Join-Path $VcRuntimePath 'vcruntime140.dll') -PathType Leaf)) {
throw 'The x64 Visual C++ redistributable vcruntime140.dll was not found.'
}
}
if (-not $DataPath -or -not $AuthHost) {
throw 'Pass -DataPath pointing to WoW 1.12.1 Data and -AuthHost host[:port]. WOW_HOME from the old .env is deliberately not used.'
}
if ($Publish -and $DryRun) { throw 'Choose -Publish or -DryRun.' }
if (Test-Path -LiteralPath $PackagePath) { throw 'PackagePath already exists; choose a new staging directory.' }
if (($Publish -or $DryRun) -and -not (Test-Path -LiteralPath $EnvFile -PathType Leaf)) {
throw 'Deployment .env was not found.'
}
Push-Location $repoRoot
try {
if (-not $SkipClientBuild) {
$embeddedIcon = Join-Path $repoRoot 'crates\moonwell\assets\moonwell.ico'
if ($IconPath) {
$sourceIcon = (Resolve-Path -LiteralPath $IconPath).Path
New-Item -ItemType Directory -Path (Split-Path $embeddedIcon -Parent) -Force | Out-Null
if ($sourceIcon -ne $embeddedIcon) { Copy-Item -LiteralPath $sourceIcon -Destination $embeddedIcon }
}
if (-not (Test-Path -LiteralPath $embeddedIcon -PathType Leaf)) {
throw 'Provide the MoonWell icon with -IconPath or place it in crates/moonwell/assets/moonwell.ico.'
}
& cargo build -p moonwell --release --no-default-features --locked
if ($LASTEXITCODE -ne 0) { throw 'MoonWell player build failed.' }
}
& cargo build -p moonwell-deploy --release --locked
if ($LASTEXITCODE -ne 0) { throw 'Rust deployment tool build failed.' }
$toolPath = Join-Path $repoRoot 'target\release\moonwell-deploy.exe'
$stageArguments = @('stage', '--binary', $BinaryPath, '--data', $DataPath,
'--output', $PackagePath, '--auth-host', $AuthHost)
if ($PatchSource) { $stageArguments += @('--source', $PatchSource) }
& $toolPath @stageArguments
if ($LASTEXITCODE -ne 0) { throw 'Package staging failed.' }
if ($BinaryPath.EndsWith('.exe', [StringComparison]::OrdinalIgnoreCase)) {
Copy-Item -LiteralPath (Join-Path $VcRuntimePath 'vcruntime140.dll') -Destination (Join-Path $PackagePath 'vcruntime140.dll')
}
# The local inventory lives outside the package, so it cannot list itself.
$inventoryPath = $PackagePath + '.manifest.json'
& $toolPath manifest --dir $PackagePath --output $inventoryPath
if ($LASTEXITCODE -ne 0) { throw 'Manifest generation failed.' }
if ($Publish -or $DryRun) {
$publishArguments = @('publish', '--dir', $PackagePath, '--env-file', $EnvFile,
'--prefix', $Prefix, '--manifest-key', $ManifestKey)
if ($DryRun) { $publishArguments += '--dry-run' }
& $toolPath @publishArguments
if ($LASTEXITCODE -ne 0) { throw 'S3 publication failed.' }
}
Write-Host "Package: $PackagePath"
Write-Host "Local manifest: $inventoryPath"
} finally {
Pop-Location
}