Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95a9a7cdd5 | |||
| f1c362f17f | |||
| 622e1c3e7a | |||
| aa103d5a65 | |||
| 4a389add47 | |||
| 9bc7276dd8 | |||
| cf891dc375 | |||
| 6693fb5306 |
@@ -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
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
//! Deviation: the reference watches the whole `PLAYER_BYTES_3` dword, so a drunkenness change also
|
||||
//! fires `PLAYER_PVP_RANK_CHANGED`; we watch byte 3 alone, because that repaint is identical.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_ui::script::{HonorState, InspectHonorData, ScriptValue, UiScript};
|
||||
@@ -64,10 +66,43 @@ impl Plugin for UiHonorPlugin {
|
||||
net::register(app);
|
||||
app.init_resource::<InspectHonor>()
|
||||
.init_resource::<HonorFeedState>()
|
||||
.add_systems(Update, feed_honor.in_set(UiFeed));
|
||||
.init_resource::<crate::nameplates::PvpNameStrings>()
|
||||
.add_systems(Update, feed_honor.in_set(UiFeed))
|
||||
// A VM holder, so after the tick; before the driver, so a new VM's strings reach the
|
||||
// lines the frame it arrives.
|
||||
.add_systems(
|
||||
Update,
|
||||
feed_pvp_name_strings
|
||||
.after(crate::ui_script::UiInput)
|
||||
.before(crate::nameplates::drive_nameplates),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The overhead name's rank strings ([`crate::nameplates::PvpNameStrings`]), read off a VM the
|
||||
/// first frame it is in the world. Every VM is installed whole, outside `Update` (the boot VM with
|
||||
/// `GlobalStrings.lua` run, a world VM after its FrameXML and addon load), so the read sees what
|
||||
/// the load defined; any other frame pays one compare.
|
||||
fn feed_pvp_name_strings(
|
||||
script: Option<NonSend<UiScript>>,
|
||||
mut strings: ResMut<crate::nameplates::PvpNameStrings>,
|
||||
) {
|
||||
let Some(script) = script else {
|
||||
return;
|
||||
};
|
||||
if strings.session() != Some(script.session()) {
|
||||
strings.set(script.session(), pvp_name_strings(&script));
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`benilla_ui::script::pvp_name_global_keys`] that `script` defines, with their values.
|
||||
fn pvp_name_strings(script: &UiScript) -> HashMap<String, String> {
|
||||
benilla_ui::script::pvp_name_global_keys()
|
||||
.into_iter()
|
||||
.filter_map(|key| benilla_ui::strings::global(script.lua(), &key).map(|value| (key, value)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read the honor block off our own descriptor, `None` until it streams. That decides only when
|
||||
/// the first push and its events happen: the reference reads a field it was never sent as 0
|
||||
/// (`0x51a4b0`-`0x51a7c0`), and so do our bindings.
|
||||
@@ -264,4 +299,72 @@ mod tests {
|
||||
fn the_first_push_fires_both() {
|
||||
assert_eq!(events_for(None, &state()), (true, true));
|
||||
}
|
||||
|
||||
fn vm(src: &str) -> UiScript {
|
||||
let script = UiScript::new().expect("a VM");
|
||||
script.run(src).expect("globals set");
|
||||
script
|
||||
}
|
||||
|
||||
/// The read takes the overhead name's keys a VM defines, `_FEMALE` twins included, and
|
||||
/// nothing else.
|
||||
#[test]
|
||||
fn the_rank_strings_read_takes_only_the_title_keys() {
|
||||
let strings = pvp_name_strings(&vm(r#"UNIT_PVP_NAME = "%s %s"
|
||||
PVP_RANK_7_1 = "Sergeant"
|
||||
PVP_RANK_7_1_FEMALE = "Sergeant (f)"
|
||||
GARBAGE_KEY = "x""#));
|
||||
assert_eq!(
|
||||
strings.get("UNIT_PVP_NAME").map(String::as_str),
|
||||
Some("%s %s")
|
||||
);
|
||||
assert_eq!(
|
||||
strings.get("PVP_RANK_7_1_FEMALE").map(String::as_str),
|
||||
Some("Sergeant (f)")
|
||||
);
|
||||
assert!(!strings.contains_key("GARBAGE_KEY"), "only the title keys");
|
||||
}
|
||||
|
||||
/// Each VM is read once, the first frame it is in the world, so a world VM whose addon renames
|
||||
/// a title reaches the overhead line.
|
||||
#[test]
|
||||
fn each_new_vm_is_read_once() {
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
let mut world = World::new();
|
||||
world.init_resource::<crate::nameplates::PvpNameStrings>();
|
||||
world.run_system_once(feed_pvp_name_strings).unwrap();
|
||||
assert_eq!(
|
||||
world
|
||||
.resource::<crate::nameplates::PvpNameStrings>()
|
||||
.session(),
|
||||
None,
|
||||
"no VM, no read"
|
||||
);
|
||||
let stock = r#"UNIT_PVP_NAME = "%s %s"
|
||||
PVP_RANK_7_1 = "Sergeant""#;
|
||||
let boot = vm(stock);
|
||||
let boot_session = boot.session();
|
||||
world.insert_non_send_resource(boot);
|
||||
world.run_system_once(feed_pvp_name_strings).unwrap();
|
||||
let held = world.resource::<crate::nameplates::PvpNameStrings>();
|
||||
assert_eq!(held.session(), Some(boot_session));
|
||||
assert_eq!(held.get("PVP_RANK_7_1"), Some("Sergeant"));
|
||||
|
||||
// The same VM is not read again: a global it sets later stays out.
|
||||
world
|
||||
.non_send_resource::<UiScript>()
|
||||
.run(r#"PVP_RANK_7_1 = "Later""#)
|
||||
.expect("global set");
|
||||
world.run_system_once(feed_pvp_name_strings).unwrap();
|
||||
let held = world.resource::<crate::nameplates::PvpNameStrings>();
|
||||
assert_eq!(held.get("PVP_RANK_7_1"), Some("Sergeant"));
|
||||
|
||||
let world_vm = vm(&format!("{stock}\nPVP_RANK_7_1 = \"Feldwebel\""));
|
||||
let world_session = world_vm.session();
|
||||
world.insert_non_send_resource(world_vm);
|
||||
world.run_system_once(feed_pvp_name_strings).unwrap();
|
||||
let held = world.resource::<crate::nameplates::PvpNameStrings>();
|
||||
assert_eq!(held.session(), Some(world_session));
|
||||
assert_eq!(held.get("PVP_RANK_7_1"), Some("Feldwebel"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ fn spell_desc_text(
|
||||
// The player's skill in a spell's line, the `$`-tokens' level (`TokenContext::skill`).
|
||||
skill: &dyn Fn(u32) -> u32,
|
||||
home_area: Option<&str>,
|
||||
// The active player's `UNIT_FIELD_BYTES_0` byte 2, the `$g`/`$G` branch's input (`0x508214`).
|
||||
gender: &dyn Fn() -> u8,
|
||||
mods: Option<&crate::spell::SpellModifiers>,
|
||||
// The VM's strings for the keyed `$d`/`$s` tokens.
|
||||
global: &dyn Fn(&str) -> Option<String>,
|
||||
@@ -77,6 +79,7 @@ fn spell_desc_text(
|
||||
lookup: &|i| sp.catalog.get(i),
|
||||
mods: mods.map(|m| m as &dyn benilla_formats::SpellMods),
|
||||
unmodified_points: false,
|
||||
gender,
|
||||
home_area: &|| home_area,
|
||||
global,
|
||||
printf: &crate::ui_script::token_printf,
|
||||
@@ -87,6 +90,15 @@ fn spell_desc_text(
|
||||
}
|
||||
}
|
||||
|
||||
/// What the `$`-tokens read of the player beside the modifiers: the skills the per-level terms
|
||||
/// scale by, and the gender byte `$g`/`$G` branch on (`0x508214`).
|
||||
type TokenReads = (crate::spell::SkillSnapshot, u8);
|
||||
|
||||
fn token_reads(me: Option<&ObjectStore>) -> TokenReads {
|
||||
let gender = me.and_then(|s| s.0.unit_gender()).unwrap_or(0);
|
||||
(crate::spell::skill_snapshot(me), gender)
|
||||
}
|
||||
|
||||
/// Only descriptions with substitution tokens can change when the caster's spell mods change.
|
||||
fn spell_has_mod_tokens(spells: Option<&crate::ui_action::Spells>, id: u32) -> bool {
|
||||
spells
|
||||
@@ -137,6 +149,8 @@ fn template_view(
|
||||
skill: &dyn Fn(u32) -> u32,
|
||||
skill_lines: Option<&benilla_formats::SkillLineCatalog>,
|
||||
home_area: Option<&str>,
|
||||
// The active player's gender byte, the trigger text's `$g`/`$G` branch (`0x508214`).
|
||||
gender: &dyn Fn() -> u8,
|
||||
factions: Option<&benilla_formats::FactionCatalog>,
|
||||
sub_classes: Option<&benilla_formats::ItemSubClassCatalog>,
|
||||
classes: Option<&benilla_formats::ItemClassCatalog>,
|
||||
@@ -150,7 +164,7 @@ fn template_view(
|
||||
.and_then(|s| s.catalog.get(id))
|
||||
.map(|sd| sd.name.clone())
|
||||
};
|
||||
let spell_text = |id: u32| spell_desc_text(spells, id, skill, home_area, mods, get);
|
||||
let spell_text = |id: u32| spell_desc_text(spells, id, skill, home_area, gender, mods, get);
|
||||
benilla_ui::script::ItemTemplateView {
|
||||
name: t.name.clone(),
|
||||
quality: t.quality,
|
||||
@@ -254,7 +268,7 @@ pub(super) fn feed_item_sets(
|
||||
crate::ui_script::VmMemo<std::collections::HashMap<u32, benilla_ui::script::ItemSetView>>,
|
||||
>,
|
||||
mut mod_sensitive: Local<crate::ui_script::VmMemo<HashSet<u32>>>,
|
||||
mut last_skills: Local<crate::ui_script::VmMemo<Option<crate::spell::SkillSnapshot>>>,
|
||||
mut last_reads: Local<crate::ui_script::VmMemo<Option<TokenReads>>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -265,11 +279,12 @@ pub(super) fn feed_item_sets(
|
||||
for id in script.take_item_set_asks() {
|
||||
pending.entry(id).or_default();
|
||||
}
|
||||
// The `$`-tokens' per-level terms follow the player's skills.
|
||||
let skills = Some(crate::spell::skill_snapshot(me));
|
||||
let skills_changed = *last_skills.get(&script) != skills;
|
||||
*last_skills.get(&script) = skills;
|
||||
if spell_mods.is_changed() || skills_changed {
|
||||
// The `$`-tokens follow the player's skills and gender.
|
||||
let reads = token_reads(me);
|
||||
let gender = reads.1;
|
||||
let reads_changed = last_reads.get(&script).as_ref() != Some(&reads);
|
||||
*last_reads.get(&script) = Some(reads);
|
||||
if spell_mods.is_changed() || reads_changed {
|
||||
for &id in mod_sensitive.iter() {
|
||||
pending.entry(id).or_default();
|
||||
}
|
||||
@@ -308,8 +323,16 @@ pub(super) fn feed_item_sets(
|
||||
.bonuses
|
||||
.iter()
|
||||
.filter_map(|&(n, spell)| {
|
||||
spell_desc_text(spell_res, spell, &skill, None, Some(&spell_mods), &global)
|
||||
.map(|desc| (n, desc))
|
||||
spell_desc_text(
|
||||
spell_res,
|
||||
spell,
|
||||
&skill,
|
||||
None,
|
||||
&|| gender,
|
||||
Some(&spell_mods),
|
||||
&global,
|
||||
)
|
||||
.map(|desc| (n, desc))
|
||||
})
|
||||
.collect(),
|
||||
required_skill: row.required_skill,
|
||||
@@ -392,7 +415,7 @@ pub(super) fn feed_item_stats(
|
||||
mut pending: Local<crate::ui_script::VmMemo<std::collections::HashSet<u32>>>,
|
||||
mut mod_sensitive: Local<crate::ui_script::VmMemo<HashSet<u32>>>,
|
||||
mut last_home: Local<crate::ui_script::VmMemo<Option<String>>>,
|
||||
mut last_skills: Local<crate::ui_script::VmMemo<Option<crate::spell::SkillSnapshot>>>,
|
||||
mut last_reads: Local<crate::ui_script::VmMemo<Option<TokenReads>>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -401,10 +424,11 @@ pub(super) fn feed_item_stats(
|
||||
let mod_sensitive = mod_sensitive.get(&script);
|
||||
let last_home = last_home.get(&script);
|
||||
let me = self_q.single().ok();
|
||||
// The `$`-tokens' per-level terms follow the player's skills.
|
||||
let skills = Some(crate::spell::skill_snapshot(me));
|
||||
let skills_changed = *last_skills.get(&script) != skills;
|
||||
*last_skills.get(&script) = skills;
|
||||
// The `$`-tokens follow the player's skills and gender.
|
||||
let reads = token_reads(me);
|
||||
let gender = reads.1;
|
||||
let reads_changed = last_reads.get(&script).as_ref() != Some(&reads);
|
||||
*last_reads.get(&script) = Some(reads);
|
||||
// `GetBindLocation()`'s push, here so it and the `$z` token share one name, and ahead of the
|
||||
// pending gate below: the bind point can arrive while the feed idles.
|
||||
let home_area: Option<&str> = home_bind
|
||||
@@ -421,7 +445,7 @@ pub(super) fn feed_item_stats(
|
||||
|
||||
pending.extend(items.take_fresh());
|
||||
pending.extend(script.take_item_stat_asks());
|
||||
if spell_mods.is_changed() || skills_changed {
|
||||
if spell_mods.is_changed() || reads_changed {
|
||||
pending.extend(mod_sensitive.iter().copied());
|
||||
}
|
||||
if pending.is_empty() {
|
||||
@@ -464,6 +488,7 @@ pub(super) fn feed_item_stats(
|
||||
&skill,
|
||||
skill_catalog,
|
||||
home_area,
|
||||
&|| gender,
|
||||
factions.as_deref().map(|f| f.catalog()),
|
||||
sub_classes.as_deref().map(|s| &s.0),
|
||||
classes.as_deref().map(|c| &c.0),
|
||||
@@ -1692,6 +1717,7 @@ mod tests {
|
||||
this return"
|
||||
);
|
||||
}
|
||||
use crate::ui_action::real_spells;
|
||||
use benilla_protocol::messages::ItemSpellEntry;
|
||||
use benilla_ui::script::{ContainerState, UiScript};
|
||||
use std::collections::HashMap;
|
||||
@@ -1911,19 +1937,7 @@ mod tests {
|
||||
/// line (`0x52da29`-`0x52da31`), and a described one does.
|
||||
#[test]
|
||||
fn an_undescribed_spell_prints_no_trigger_line_on_real_data() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let spells = crate::ui_action::Spells {
|
||||
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
|
||||
forms: benilla_formats::load_shapeshift_forms(&mut chain)
|
||||
.expect("SpellShapeshiftForm.dbc"),
|
||||
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("SpellRange.dbc"),
|
||||
cast_times: benilla_formats::load_spell_cast_times(&mut chain)
|
||||
.expect("SpellCastTimes.dbc"),
|
||||
durations: benilla_formats::load_spell_durations(&mut chain)
|
||||
.expect("SpellDuration.dbc"),
|
||||
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
|
||||
};
|
||||
let Some(spells) = real_spells() else { return };
|
||||
|
||||
// No string table: Fireball's description reaches no keyed token.
|
||||
let no_strings = |_: &str| None;
|
||||
@@ -1936,7 +1950,7 @@ mod tests {
|
||||
"spell {id} has a name — which is exactly what must NOT leak into the tooltip"
|
||||
);
|
||||
assert_eq!(
|
||||
super::spell_desc_text(Some(&spells), id, &|_| 0, None, None, &no_strings),
|
||||
super::spell_desc_text(Some(&spells), id, &|_| 0, None, &|| 0, None, &no_strings),
|
||||
None,
|
||||
"spell {id} ({:?}) has no description, so the reference prints no trigger line",
|
||||
d.name
|
||||
@@ -1944,11 +1958,46 @@ mod tests {
|
||||
}
|
||||
|
||||
// The control: Fireball (133), described, still yields its line.
|
||||
let fireball = super::spell_desc_text(Some(&spells), 133, &|_| 0, None, None, &no_strings)
|
||||
.expect("a described spell still yields its line");
|
||||
let fireball =
|
||||
super::spell_desc_text(Some(&spells), 133, &|_| 0, None, &|| 0, None, &no_strings)
|
||||
.expect("a described spell still yields its line");
|
||||
assert!(
|
||||
fireball.contains("damage"),
|
||||
"expected the substituted Fireball description, got {fireball:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The trigger text's `$g` branch reads the gender handed in (`0x508214`): Conjure Food 587's
|
||||
/// "providing the mage and `$ghis:her;` allies" takes "her" on 1, "his" on 0.
|
||||
#[test]
|
||||
fn a_trigger_texts_gender_branch_follows_the_player() {
|
||||
let Some(spells) = real_spells() else { return };
|
||||
let no_strings = |_: &str| None;
|
||||
let line = |gender: u8| {
|
||||
super::spell_desc_text(
|
||||
Some(&spells),
|
||||
587,
|
||||
&|_| 0,
|
||||
None,
|
||||
&move || gender,
|
||||
None,
|
||||
&no_strings,
|
||||
)
|
||||
.expect("a described spell")
|
||||
};
|
||||
assert!(line(1).contains("the mage and her allies"), "{}", line(1));
|
||||
assert!(line(0).contains("the mage and his allies"), "{}", line(0));
|
||||
}
|
||||
|
||||
/// The item feeds re-substitute a held `$`-text when what the tokens read moves: the gender
|
||||
/// byte `$g`/`$G` branch on (`0x508214`), and no other byte of `UNIT_FIELD_BYTES_0`.
|
||||
#[test]
|
||||
fn the_token_reads_move_with_the_gender_byte_alone() {
|
||||
let store = |bytes: u32| {
|
||||
crate::net::ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(36, bytes)]))
|
||||
};
|
||||
let male = super::token_reads(Some(&store(4 | 1 << 8)));
|
||||
assert_ne!(super::token_reads(Some(&store(4 | 1 << 8 | 1 << 16))), male);
|
||||
assert_eq!(super::token_reads(Some(&store(1 | 2 << 8 | 1 << 24))), male);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,7 +363,8 @@ impl LootLatch {
|
||||
/// A movement start that closes the loot: the reference's guard `0x60e990`, called by every
|
||||
/// movement-start emitter, turning included, but not by mouse-look facing. No loot target reaches
|
||||
/// the range gate `0x493230`, so there is no distance leash. vmangos also releases on movement
|
||||
/// (`MovementHandler.cpp:1104`), but the close is the client's.
|
||||
/// (`MovementHandler.cpp:1104`), but the close is the client's. A loot response that lands while
|
||||
/// we move sets it too (`0x5ebc3a`).
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct LootMoveStart(pub(crate) bool);
|
||||
|
||||
@@ -674,6 +675,7 @@ fn feed_loot(
|
||||
group: Res<GroupState>,
|
||||
names: Res<NameCache>,
|
||||
objects: LootSourceObjects,
|
||||
mut moving: OpenWhileMoving,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -755,6 +757,19 @@ fn feed_loot(
|
||||
}
|
||||
}
|
||||
}
|
||||
// The response's tail (`0x5ebc3a`-`0x5ebc51`) closes a window that opened while we
|
||||
// move, after the copier's open and sweep and in the same handler: `LOOT_CLOSED` fires
|
||||
// in this pass, so the window never paints.
|
||||
if std::mem::take(&mut moving.move_start.0) {
|
||||
let closed = close_on_move_start(&mut loot, &mut moving.latch, &commands);
|
||||
if closed.is_some() {
|
||||
moving.dead.after_close(closed);
|
||||
script.set_loot(None);
|
||||
script.fire_event("LOOT_CLOSED", vec![]);
|
||||
*last = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// One `LOOT_SLOT_CLEARED` per row that went, with its 1-based row: the stock handler hides
|
||||
// that button and pages down when the page empties (`LootFrame.lua:22-50`).
|
||||
@@ -862,6 +877,14 @@ fn close_on_move_start(
|
||||
close_interaction(loot, latch, Some(commands))
|
||||
}
|
||||
|
||||
/// What [`feed_loot`] needs to take the move-start close on a window it has just opened.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
struct OpenWhileMoving<'w, 's> {
|
||||
move_start: ResMut<'w, LootMoveStart>,
|
||||
latch: ResMut<'w, LootLatch>,
|
||||
dead: DeadUnitDeselect<'w, 's>,
|
||||
}
|
||||
|
||||
/// Sends what the Lua asked for, as the take dispatcher `0x4c2790(slot, flag)`: a row click
|
||||
/// (a `LootButton`'s own, flag 0) and the `LOOT_BIND` confirm (`LootSlot`, flag 1) stay apart,
|
||||
/// so a second click on a bind-on-pickup row asks again.
|
||||
@@ -1170,6 +1193,11 @@ mod tests {
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
app.add_message::<crate::sound::LootPickupSound>()
|
||||
// What the feed's move-start close reads (`OpenWhileMoving`).
|
||||
.add_message::<crate::target::DeselectGuid>()
|
||||
.init_resource::<LootMoveStart>()
|
||||
.init_resource::<LootLatch>()
|
||||
.init_resource::<crate::net::GuidIndex>()
|
||||
.init_resource::<LootState>()
|
||||
.init_resource::<crate::ui_chat::ChatLog>()
|
||||
.init_resource::<GroupState>()
|
||||
@@ -1227,6 +1255,11 @@ mod tests {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
app.add_message::<crate::sound::LootPickupSound>()
|
||||
// What the feed's move-start close reads (`OpenWhileMoving`).
|
||||
.add_message::<crate::target::DeselectGuid>()
|
||||
.init_resource::<LootMoveStart>()
|
||||
.init_resource::<LootLatch>()
|
||||
.init_resource::<crate::net::GuidIndex>()
|
||||
.init_resource::<LootState>()
|
||||
.init_resource::<crate::ui_chat::ChatLog>()
|
||||
.init_resource::<GroupState>()
|
||||
@@ -1460,6 +1493,11 @@ mod tests {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
app.add_message::<crate::sound::LootPickupSound>()
|
||||
// What the feed's move-start close reads (`OpenWhileMoving`).
|
||||
.add_message::<crate::target::DeselectGuid>()
|
||||
.init_resource::<LootMoveStart>()
|
||||
.init_resource::<LootLatch>()
|
||||
.init_resource::<crate::net::GuidIndex>()
|
||||
.init_resource::<LootState>()
|
||||
.init_resource::<crate::ui_chat::ChatLog>()
|
||||
.init_resource::<GroupState>()
|
||||
|
||||
@@ -5,7 +5,7 @@ use benilla_protocol::messages::ItemPushResult;
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::{LootLatch, LootState};
|
||||
use super::{LootLatch, LootMoveStart, LootState};
|
||||
use crate::net::{ClientCommand, NetCommands, NetHandlerApp, SelfGuid};
|
||||
use crate::pending_item_ops::{LockTransitions, PendingItemOps};
|
||||
use crate::ui_action::{UiError, UiErrorKeys};
|
||||
@@ -26,8 +26,15 @@ pub(super) fn register(app: &mut App) {
|
||||
}
|
||||
|
||||
/// An admitted response selects a looted unit (`0x5ebc35` → `0x48f3a0`) before the window opens
|
||||
/// (`0x4c1cb0`), so the outgoing target's teardown closes a window still open on it.
|
||||
fn on_response(In(ev): In<SessionEvent>, mut select: crate::target::SelectCommit) {
|
||||
/// (`0x4c1cb0`), so the outgoing target's teardown closes a window still open on it. One that lands
|
||||
/// while our own body moves takes the move-start close (`0x5ebc3a`-`0x5ebc51`), which the feed runs
|
||||
/// after it fires `LOOT_OPENED`, or the drain when the window waits on an item template.
|
||||
fn on_response(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut select: crate::target::SelectCommit,
|
||||
player: Option<Res<crate::player::Player>>,
|
||||
mut move_start: ResMut<LootMoveStart>,
|
||||
) {
|
||||
let SessionEvent::LootResponse {
|
||||
guid,
|
||||
loot_type,
|
||||
@@ -47,6 +54,12 @@ fn on_response(In(ev): In<SessionEvent>, mut select: crate::target::SelectCommit
|
||||
);
|
||||
select.select_unit(guid);
|
||||
select.seam.loot.open(guid, loot_type, gold, items);
|
||||
if player.is_some_and(|p| {
|
||||
p.foreign_mover.is_none()
|
||||
&& p.move_flags() & crate::creature_anim::move_flags::STEERING != 0
|
||||
}) {
|
||||
move_start.0 = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn on_error(
|
||||
@@ -763,6 +776,8 @@ mod tests {
|
||||
world.init_resource::<LockTransitions>();
|
||||
world.init_resource::<bevy::ecs::message::Messages<crate::target::DeselectGuid>>();
|
||||
world.init_resource::<bevy::ecs::message::Messages<crate::player::StandStateRequest>>();
|
||||
world.init_resource::<crate::player::Player>();
|
||||
world.init_resource::<super::super::LootMoveStart>();
|
||||
world.spawn((
|
||||
SelfPlayer,
|
||||
Guid(1),
|
||||
@@ -988,34 +1003,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The issue's symptom on the stock frames: the Tab target is on `TargetFrame` when the body's
|
||||
/// window opens, and the unit feed then names the body there (`TargetFrame.lua:63`,
|
||||
/// `UnitFrame.lua:25`).
|
||||
#[test]
|
||||
fn the_stock_target_frame_names_the_looted_body() {
|
||||
/// The stock unit frames over `TargetFrame`, for [`stock_app`].
|
||||
const TARGET_UI: &[&str] = &[
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"Interface\\FrameXML\\UIParent.xml",
|
||||
"Interface\\FrameXML\\BasicControls.xml",
|
||||
"Interface\\FrameXML\\MoneyFrame.lua",
|
||||
"Interface\\FrameXML\\MoneyFrame.xml",
|
||||
"Interface\\FrameXML\\GameTooltip.xml",
|
||||
"Interface\\FrameXML\\UIDropDownMenu.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
"Interface\\FrameXML\\BuffFrame.xml",
|
||||
"Interface\\FrameXML\\CombatFeedback.xml",
|
||||
"Interface\\FrameXML\\UnitPopup.xml",
|
||||
"Interface\\FrameXML\\UnitFrame.xml",
|
||||
"Interface\\FrameXML\\PlayerFrame.xml",
|
||||
"Interface\\FrameXML\\PartyFrame.xml",
|
||||
"Interface\\FrameXML\\TargetFrame.xml",
|
||||
];
|
||||
|
||||
/// [`tabbed_world`] under the stock `ui` files, with the unit feed and both creatures named.
|
||||
fn stock_app(ui: &[&[&str]]) -> App {
|
||||
use benilla_ui::script::UiScript;
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
let mut s = UiScript::new().expect("VM");
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
for f in crate::ui_script::test_ui::production_order(&[&[
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"Interface\\FrameXML\\UIParent.xml",
|
||||
"Interface\\FrameXML\\BasicControls.xml",
|
||||
"Interface\\FrameXML\\MoneyFrame.lua",
|
||||
"Interface\\FrameXML\\MoneyFrame.xml",
|
||||
"Interface\\FrameXML\\GameTooltip.xml",
|
||||
"Interface\\FrameXML\\UIDropDownMenu.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
"Interface\\FrameXML\\BuffFrame.xml",
|
||||
"Interface\\FrameXML\\CombatFeedback.xml",
|
||||
"Interface\\FrameXML\\UnitPopup.xml",
|
||||
"Interface\\FrameXML\\UnitFrame.xml",
|
||||
"Interface\\FrameXML\\PlayerFrame.xml",
|
||||
"Interface\\FrameXML\\PartyFrame.xml",
|
||||
"Interface\\FrameXML\\TargetFrame.xml",
|
||||
]]) {
|
||||
for f in crate::ui_script::test_ui::production_order(ui) {
|
||||
crate::ui_script::test_ui::load_ui(&s, f);
|
||||
}
|
||||
|
||||
@@ -1048,20 +1062,257 @@ mod tests {
|
||||
.init_resource::<crate::ui_guild::GuildState>()
|
||||
.insert_non_send_resource(s);
|
||||
crate::ui_unit::add_unit_feed(&mut app);
|
||||
let shown = |app: &mut App| -> String {
|
||||
let s = app.world().non_send_resource::<UiScript>();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
s.eval("return TargetFrame:IsShown() and TargetFrame.name:GetText() or ''")
|
||||
.expect("the target frame's name")
|
||||
};
|
||||
app
|
||||
}
|
||||
|
||||
/// The name on the stock `TargetFrame`, empty while it is hidden.
|
||||
fn target_frame_name(app: &App) -> String {
|
||||
let s = app
|
||||
.world()
|
||||
.non_send_resource::<benilla_ui::script::UiScript>();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
s.eval("return TargetFrame:IsShown() and TargetFrame.name:GetText() or ''")
|
||||
.expect("the target frame's name")
|
||||
}
|
||||
|
||||
/// The issue's symptom on the stock frames: the Tab target is on `TargetFrame` when the body's
|
||||
/// window opens, and the unit feed then names the body there (`TargetFrame.lua:63`,
|
||||
/// `UnitFrame.lua:25`).
|
||||
#[test]
|
||||
fn the_stock_target_frame_names_the_looted_body() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
let mut app = stock_app(&[TARGET_UI]);
|
||||
|
||||
app.update();
|
||||
assert_eq!(shown(&mut app), "Kobold Vermin", "the Tab target");
|
||||
assert_eq!(target_frame_name(&app), "Kobold Vermin", "the Tab target");
|
||||
|
||||
app.world_mut().resource_mut::<LootLatch>().0 = Some(CORPSE);
|
||||
respond(app.world_mut(), CORPSE, 1);
|
||||
app.update();
|
||||
assert_eq!(shown(&mut app), "Kobold Worker", "the looted body");
|
||||
assert_eq!(target_frame_name(&app), "Kobold Worker", "the looted body");
|
||||
}
|
||||
|
||||
/// What [`super::super::drain_loot`] reads beside [`seat_tabbed`]'s, with our body's movement
|
||||
/// word at `player`'s.
|
||||
fn seat_drain(world: &mut World, player: crate::player::Player) {
|
||||
world.insert_resource(player);
|
||||
world.init_resource::<crate::ui_party::GroupState>();
|
||||
world.init_resource::<crate::items::Items>();
|
||||
world.init_resource::<bevy::ecs::message::Messages<crate::sound::LootPickupSound>>();
|
||||
}
|
||||
|
||||
/// The deselects asked for since the last call.
|
||||
fn deselects(world: &mut World) -> Vec<u64> {
|
||||
world
|
||||
.resource_mut::<bevy::ecs::message::Messages<crate::target::DeselectGuid>>()
|
||||
.drain()
|
||||
.map(|d| d.0)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A response on the corpse with `player` seated, then one drain.
|
||||
fn open_while(
|
||||
player: crate::player::Player,
|
||||
) -> (World, crossbeam_channel::Receiver<ClientCommand>) {
|
||||
let (mut world, rx) = tabbed_world();
|
||||
seat_drain(&mut world, player);
|
||||
world.resource_mut::<LootLatch>().0 = Some(CORPSE);
|
||||
respond(&mut world, CORPSE, 1);
|
||||
world
|
||||
.run_system_once(super::super::drain_loot)
|
||||
.expect("the drain runs as a one-shot system");
|
||||
(world, rx)
|
||||
}
|
||||
|
||||
/// The response's tail (`0x5ebc3a`-`0x5ebc51`): a bit of our movement word's low byte, forward,
|
||||
/// back, strafe, turn or pitch, takes the move-start close on the window it just opened, the
|
||||
/// release after the selection and the dead body's deselect last (`0x48f369`).
|
||||
#[test]
|
||||
fn a_window_that_opens_while_moving_closes_as_a_movement_start_does() {
|
||||
for bit in (0..8).map(|b| 1u32 << b) {
|
||||
let (mut world, rx) = open_while(crate::player::Player::with_move_flags(bit));
|
||||
assert_eq!(world.resource::<LootState>().source(), None, "{bit:#x}");
|
||||
assert_eq!(world.resource::<LootLatch>().0, None, "{bit:#x}");
|
||||
let sent: Vec<_> = rx.try_iter().collect();
|
||||
assert!(
|
||||
matches!(
|
||||
sent[..],
|
||||
[ClientCommand::SetSelection { guid: a }, ClientCommand::LootRelease { guid: b }]
|
||||
if a == CORPSE && b == CORPSE
|
||||
),
|
||||
"{bit:#x}: {sent:?}"
|
||||
);
|
||||
assert_eq!(deselects(&mut world), [CORPSE], "{bit:#x}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Only that byte, and only our body's: walking, falling or swimming alone leaves the window
|
||||
/// open, as does a step of the creature we possess.
|
||||
#[test]
|
||||
fn a_window_opened_falling_swimming_or_possessing_stays_open() {
|
||||
use crate::creature_anim::move_flags as f;
|
||||
use crate::player::Player;
|
||||
let mut possessing = Player::with_move_flags(f::FORWARD);
|
||||
possessing.foreign_mover = Some(KOBOLD);
|
||||
for (what, player) in [
|
||||
("still", Player::with_move_flags(0)),
|
||||
("walk mode", Player::with_move_flags(f::WALK_MODE)),
|
||||
("falling", Player::with_move_flags(f::FALLING)),
|
||||
("swimming", Player::with_move_flags(f::SWIMMING)),
|
||||
("possessing", possessing),
|
||||
] {
|
||||
let (mut world, rx) = open_while(player);
|
||||
assert_eq!(
|
||||
world.resource::<LootState>().source(),
|
||||
Some(CORPSE),
|
||||
"{what}"
|
||||
);
|
||||
let sent: Vec<_> = rx.try_iter().collect();
|
||||
assert!(
|
||||
matches!(sent[..], [ClientCommand::SetSelection { guid }] if guid == CORPSE),
|
||||
"{what}: {sent:?}"
|
||||
);
|
||||
assert!(deselects(&mut world).is_empty(), "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The copier `0x4c1cb0` sweeps an auto-loot window (`0x4c1fa0`) before the response's
|
||||
/// movement close (`0x5ebc51`), so a body auto-looted on the run is taken, then released.
|
||||
#[test]
|
||||
fn auto_loot_on_the_run_takes_the_rows_before_the_release() {
|
||||
const TOUGH_JERKY: u32 = 117;
|
||||
let mut app = App::new();
|
||||
let rx = seat_tabbed(app.world_mut());
|
||||
seat_drain(
|
||||
app.world_mut(),
|
||||
crate::player::Player::with_move_flags(crate::creature_anim::move_flags::FORWARD),
|
||||
);
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::items::Items>()
|
||||
.insert_template(
|
||||
TOUGH_JERKY,
|
||||
Some(crate::items::test_template("Tough Jerky")),
|
||||
);
|
||||
app.insert_resource(super::super::LootConfig {
|
||||
auto_loot: true,
|
||||
..Default::default()
|
||||
})
|
||||
.init_resource::<crate::ui_chat::ChatLog>()
|
||||
.init_resource::<crate::names::NameCache>()
|
||||
.init_resource::<ButtonInput<KeyCode>>()
|
||||
.insert_non_send_resource(benilla_ui::script::UiScript::new().expect("VM"))
|
||||
.add_systems(
|
||||
Update,
|
||||
(super::super::feed_loot, super::super::drain_loot).chain(),
|
||||
);
|
||||
|
||||
app.world_mut().resource_mut::<LootLatch>().0 = Some(CORPSE);
|
||||
app.world_mut()
|
||||
.run_system_once_with(
|
||||
on_response,
|
||||
SessionEvent::LootResponse {
|
||||
guid: CORPSE,
|
||||
loot_type: 1,
|
||||
gold: 0,
|
||||
items: vec![benilla_protocol::messages::LootItem {
|
||||
slot: 0,
|
||||
item_id: TOUGH_JERKY,
|
||||
count: 1,
|
||||
display_info_id: 1117,
|
||||
random_property_id: 0,
|
||||
slot_type: 0,
|
||||
}],
|
||||
},
|
||||
)
|
||||
.expect("the handler runs as a one-shot system");
|
||||
app.update();
|
||||
let sent: Vec<_> = rx.try_iter().collect();
|
||||
assert!(
|
||||
matches!(
|
||||
sent[..],
|
||||
[
|
||||
ClientCommand::SetSelection { .. },
|
||||
ClientCommand::AutostoreLootItem { slot: 0 },
|
||||
ClientCommand::LootRelease { guid },
|
||||
] if guid == CORPSE
|
||||
),
|
||||
"{sent:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The issue's spot on the stock frames: a body's window that opens on the run is shown by
|
||||
/// `LOOT_OPENED` and hidden by `LOOT_CLOSED` in the same frame (`LootFrame.lua:13-16`,
|
||||
/// `:53-55`), its one release
|
||||
/// sent, and `TargetFrame` lets the body go; standing, the window and the target stay.
|
||||
#[test]
|
||||
fn the_stock_loot_frame_closes_on_a_body_looted_on_the_run() {
|
||||
use crate::creature_anim::move_flags as f;
|
||||
use benilla_ui::script::UiScript;
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
for (flags, stays) in [(f::FORWARD, false), (0, true)] {
|
||||
let mut app = stock_app(&[
|
||||
crate::ui_script::test_ui::LOOT_UI,
|
||||
TARGET_UI,
|
||||
&["Interface\\FrameXML\\LootFrame.xml"],
|
||||
]);
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
app.insert_resource(crate::net::NetCommands(tx));
|
||||
seat_drain(
|
||||
app.world_mut(),
|
||||
crate::player::Player::with_move_flags(flags),
|
||||
);
|
||||
app.init_resource::<super::super::LootConfig>()
|
||||
.init_resource::<ButtonInput<KeyCode>>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
super::super::feed_loot,
|
||||
super::super::drain_loot,
|
||||
crate::target::click::clear_target_requests,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
app.world()
|
||||
.non_send_resource::<UiScript>()
|
||||
.run(
|
||||
"LOOT_EVENTS = {} local f = CreateFrame('Frame') \
|
||||
f:RegisterEvent('LOOT_OPENED') f:RegisterEvent('LOOT_CLOSED') \
|
||||
f:SetScript('OnEvent', function() tinsert(LOOT_EVENTS, event) end)",
|
||||
)
|
||||
.unwrap();
|
||||
app.update();
|
||||
|
||||
app.world_mut().resource_mut::<LootLatch>().0 = Some(CORPSE);
|
||||
respond(app.world_mut(), CORPSE, 1);
|
||||
// One frame: the reference opens and closes in the response's handler.
|
||||
app.update();
|
||||
let (events, shown): (String, bool) = app
|
||||
.world()
|
||||
.non_send_resource::<UiScript>()
|
||||
.eval("return table.concat(LOOT_EVENTS, ','), LootFrame:IsShown() == 1")
|
||||
.unwrap();
|
||||
for _ in 0..2 {
|
||||
app.update();
|
||||
}
|
||||
let releases = rx
|
||||
.try_iter()
|
||||
.filter(|c| matches!(c, ClientCommand::LootRelease { guid } if *guid == CORPSE))
|
||||
.count();
|
||||
if stays {
|
||||
assert_eq!(events, "LOOT_OPENED");
|
||||
assert!(shown, "standing, the window stays");
|
||||
assert_eq!(releases, 0);
|
||||
assert_eq!(target_frame_name(&app), "Kobold Worker");
|
||||
} else {
|
||||
assert_eq!(events, "LOOT_OPENED,LOOT_CLOSED");
|
||||
assert!(
|
||||
!shown,
|
||||
"on the run, the window closes in the frame it opens"
|
||||
);
|
||||
assert_eq!(releases, 1, "released once");
|
||||
assert_eq!(target_frame_name(&app), "", "and the body is let go");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Only a releasing arm reaches `UnlockItem 0x495420`, through the tail `0x5ebac2`.
|
||||
|
||||
@@ -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()")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user