This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.
Today's work, which is the reason a baseline exists at all:
- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
characters were entering the sun's shadow cascades, so every skinned-rig
shadow specialization compiled live mid-frame; the hitch detector logged
"main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
frame-cost reading no longer pays 2.7 ms a frame for it.
Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
389 lines
16 KiB
Rust
389 lines
16 KiB
Rust
//! The create-time challenge modes a modified realm offers, read off the player's own glue chain.
|
|
//!
|
|
//! Not a 1.12 feature: `GlueXML.toc` on OctoWoW lists a `ChallengeList.xml` after
|
|
//! `CreditsFrame.xml`, and the `ChallengeList.lua` beside it builds the whole offer as one Lua
|
|
//! table —
|
|
//!
|
|
//! ```lua
|
|
//! Challenges[1] = { selected = false, id = 2, name = CHALLENGE_SLOW_AND_STEADY,
|
|
//! text = CHALLENGE_SLOW_AND_STEADY_TEXT, icon = "Spell_Nature_TimeStop", }
|
|
//! ```
|
|
//!
|
|
//! — where `id` is the bit the create packet carries, `name`/`text` are GlueStrings keys and
|
|
//! `icon` is an `Interface\Icons` file. That table is the realm's own statement of what it offers,
|
|
//! so it is what benilla reads: no list is carried here, and a chain without the file gets no
|
|
//! Challenges button and no challenge bytes on the wire.
|
|
//!
|
|
//! Read through the chain, never from one archive: this install carries the file twice, and the
|
|
//! copy that wins (`patch-A.mpq`, eleven entries) is not the newest-looking one (`patch-5.mpq`
|
|
//! adds a twelfth, True Hardcore, but loses the mount order to `A`). A reader that opened the
|
|
//! "obvious" archive would offer a challenge the running client does not.
|
|
//!
|
|
//! Parsed without a Lua VM, as `glue_strings.rs` parses `GlueStrings.lua`: the file's own shape is
|
|
//! one entry per line and the glue VM does not exist at this point in the boot.
|
|
|
|
use bevy::prelude::*;
|
|
|
|
use benilla_assets::{LockRecover, WorldAssets};
|
|
|
|
const CHALLENGE_LIST: &str = "Interface\\GlueXML\\ChallengeList.lua";
|
|
|
|
/// The `name` key of the entries whose selection the realm's own Lua gates. Keyed on the key
|
|
/// rather than the English text, because that is the identifier `ChallengeList.lua` itself
|
|
/// compares (`Challenges[i].name == CHALLENGE_SAMURAI`) and it survives a localized GlueStrings.
|
|
pub(crate) const SAMURAI: &str = "CHALLENGE_SAMURAI";
|
|
pub(crate) const TOGETHER_FOREVER: &str = "CHALLENGE_TOGETHER_FOREVER";
|
|
pub(crate) const LUNATIC: &str = "CHALLENGE_LUNATIC";
|
|
pub(crate) const TRUE_HARDCORE: &str = "CHALLENGE_TRUE_HARDCORE";
|
|
|
|
/// `SAMURAI_ALLOWED_CLASSES` (`ChallengeList.lua`), mirroring the server's own refusal: its
|
|
/// `CharacterHandler.cpp` rejects a create packet carrying the samurai bit for any other class, so
|
|
/// the picker refuses the click first and says why.
|
|
const SAMURAI_CLASSES: [&str; 4] = ["WARRIOR", "PALADIN", "HUNTER", "ROGUE"];
|
|
|
|
/// One row of the realm's `Challenges` table.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct Challenge {
|
|
/// `id`: the bit this challenge sets in `CMSG_CHAR_CREATE`'s trailing mask.
|
|
pub(crate) bit: u32,
|
|
/// `name`: a GlueStrings key, resolved for display and compared for the gates above.
|
|
pub(crate) name_key: String,
|
|
/// `text`: a GlueStrings key holding the tooltip paragraph.
|
|
pub(crate) text_key: String,
|
|
/// `icon`: a file under `Interface\Icons`, without extension.
|
|
pub(crate) icon: String,
|
|
}
|
|
|
|
/// The realm's challenge offer, in `ChallengeList.lua`'s own index order — which is the order
|
|
/// `ChallengeList_OnLoad` lays its buttons out in. Absent entirely on a chain with no such file,
|
|
/// which is every stock 1.12 install.
|
|
#[derive(Resource, Debug, Clone)]
|
|
pub(crate) struct Challenges(pub(crate) Vec<Challenge>);
|
|
|
|
impl Challenges {
|
|
/// Why entry `i` may not be selected right now, as a GlueStrings key for the red line the
|
|
/// reference appends to the tooltip, or `None` when it may.
|
|
///
|
|
/// Both rules are `ChallengeList.lua`'s: `ChallengeListEntry_OnMouseUp` refuses the Samurai
|
|
/// click off a Warrior/Paladin/Hunter/Rogue, and refuses *selecting* either of Together
|
|
/// Forever and Level One Lunatic while the other is held.
|
|
pub(crate) fn blocked(&self, i: usize, class_file: &str, staged: u32) -> Option<&'static str> {
|
|
let entry = self.0.get(i)?;
|
|
if entry.name_key == SAMURAI && !SAMURAI_CLASSES.contains(&class_file) {
|
|
return Some("CHALLENGE_SAMURAI_RESTRICTED");
|
|
}
|
|
let held = |key: &str| {
|
|
self.0
|
|
.iter()
|
|
.any(|c| c.name_key == key && staged & c.bit != 0)
|
|
};
|
|
let conflict = match entry.name_key.as_str() {
|
|
TOGETHER_FOREVER => held(LUNATIC),
|
|
LUNATIC => held(TOGETHER_FOREVER),
|
|
_ => false,
|
|
};
|
|
conflict.then_some("CHALLENGE_TF_LUNATIC_EXCLUSIVE")
|
|
}
|
|
|
|
/// Whether entry `i` is offered on this realm at all. True Hardcore is a Hardcore-realm vow —
|
|
/// `ChallengeList_ApplyRealmGate` hides it unless `GetServerName()`'s `isRP` is set, because
|
|
/// the realm routing puts Hardcore on the RP-flagged realm and the server strips the bit
|
|
/// anywhere else.
|
|
pub(crate) fn offered(&self, i: usize, realm_is_rp: bool) -> bool {
|
|
self.0
|
|
.get(i)
|
|
.is_some_and(|c| realm_is_rp || c.name_key != TRUE_HARDCORE)
|
|
}
|
|
|
|
/// The mask with every bit this realm cannot honour cleared: `ChallengeList_Save`'s
|
|
/// belt-and-suspenders pass, run again at submit so a class switch after a pick cannot send a
|
|
/// packet the server would reject.
|
|
pub(crate) fn sanitize(&self, mut mask: u32, class_file: &str, realm_is_rp: bool) -> u32 {
|
|
for (i, c) in self.0.iter().enumerate() {
|
|
if mask & c.bit == 0 {
|
|
continue;
|
|
}
|
|
// The conflict pair is tested against the mask without this entry, so a mask holding
|
|
// both loses the earlier of the two rather than both. In the realm's own order that
|
|
// is Level One Lunatic, which is exactly the one `ChallengeList_Save` hides.
|
|
if !self.offered(i, realm_is_rp) || self.blocked(i, class_file, mask & !c.bit).is_some()
|
|
{
|
|
mask &= !c.bit;
|
|
}
|
|
}
|
|
mask
|
|
}
|
|
}
|
|
|
|
/// Startup: read the realm's table, or leave the resource absent.
|
|
pub(crate) fn load_challenges(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
|
|
let Some(assets) = assets else { return };
|
|
let bytes = {
|
|
let mut chain = assets.chain.lock_recover();
|
|
chain.read_file(CHALLENGE_LIST)
|
|
};
|
|
let Ok(bytes) = bytes else {
|
|
// Not a warning: a stock 1.12 chain has no such file and wants no Challenges button.
|
|
debug!("challenges: no {CHALLENGE_LIST} in the chain — the screen shows no Challenges");
|
|
return;
|
|
};
|
|
let list = parse_challenges(&String::from_utf8_lossy(&bytes));
|
|
if list.is_empty() {
|
|
warn!("challenges: {CHALLENGE_LIST} parsed to no entries — the screen shows no Challenges");
|
|
return;
|
|
}
|
|
info!("challenges: {} offered by the realm's glue", list.len());
|
|
commands.insert_resource(Challenges(list));
|
|
}
|
|
|
|
/// The `Challenges[<n>] = { … }` assignments, ascending by `<n>`. A line the realm commented out
|
|
/// is skipped, as Lua skips it; a row missing any of the four fields is skipped rather than
|
|
/// half-built, because a challenge with no bit cannot be sent and one with no key has no caption.
|
|
fn parse_challenges(src: &str) -> Vec<Challenge> {
|
|
let mut rows: Vec<(u32, Challenge)> = Vec::new();
|
|
for line in src.lines() {
|
|
let line = line.trim_start();
|
|
let Some(rest) = line.strip_prefix("Challenges[") else {
|
|
continue;
|
|
};
|
|
let Some((index, rest)) = rest.split_once(']') else {
|
|
continue;
|
|
};
|
|
let Ok(index) = index.trim().parse::<u32>() else {
|
|
continue;
|
|
};
|
|
let Some(body) = rest.split_once('{').and_then(|(_, b)| b.split_once('}')) else {
|
|
continue;
|
|
};
|
|
let fields = body.0;
|
|
let bit = field(fields, "id").and_then(|v| v.parse::<u32>().ok());
|
|
let (Some(bit), Some(name_key), Some(text_key), Some(icon)) = (
|
|
bit,
|
|
field(fields, "name"),
|
|
field(fields, "text"),
|
|
field(fields, "icon").map(|v| v.trim_matches('"').to_string()),
|
|
) else {
|
|
continue;
|
|
};
|
|
rows.push((
|
|
index,
|
|
Challenge {
|
|
bit,
|
|
name_key: name_key.to_string(),
|
|
text_key: text_key.to_string(),
|
|
icon,
|
|
},
|
|
));
|
|
}
|
|
rows.sort_by_key(|(index, _)| *index);
|
|
rows.into_iter().map(|(_, c)| c).collect()
|
|
}
|
|
|
|
/// One `<key> = <value>` out of a table body, the value ending at the next comma. Matches the key
|
|
/// as a whole word, so `name` does not match inside another key.
|
|
fn field<'a>(body: &'a str, key: &str) -> Option<&'a str> {
|
|
body.split(',').find_map(|part| {
|
|
let (k, v) = part.split_once('=')?;
|
|
(k.trim() == key).then(|| v.trim())
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The realm's own file, abridged to the shapes it actually uses: a comment line, the
|
|
/// out-of-order tail (`Challenges[7]` is listed before `[8]` but carries the bigger bit), and
|
|
/// the `= {` spacing with a trailing comma.
|
|
const SRC: &str = r#"
|
|
local Challenges = {}
|
|
-- Challenges[0] = { selected = false, id = 1, name = CHALLENGE_HARDCORE, text = CHALLENGE_HARDCORE_TEXT, icon = "Ability_Rogue_FeignDeath", }
|
|
Challenges[1] = { selected = false, id = 2, name = CHALLENGE_SLOW_AND_STEADY, text = CHALLENGE_SLOW_AND_STEADY_TEXT, icon = "Spell_Nature_TimeStop", }
|
|
Challenges[2] = { selected = false, id = 4, name = CHALLENGE_WAR_MODE, text = CHALLENGE_WAR_MODE_TEXT, icon = "Ability_DualWield", }
|
|
Challenges[7] = { selected = false, id = 512, name = CHALLENGE_HEROIC, text = CHALLENGE_HEROIC_TEXT, icon = "Ability_Warlock_DemonicEmpowerment", }
|
|
Challenges[8] = { selected = false, id = 256, name = CHALLENGE_BREWMASTER, text = CHALLENGE_BREWMASTER_TEXT, icon = "INV_Cask_03", }
|
|
local NUM_CHALLENGES = getn(Challenges)
|
|
"#;
|
|
|
|
#[test]
|
|
fn the_realms_table_is_read_in_its_own_index_order_with_the_commented_row_skipped() {
|
|
let list = parse_challenges(SRC);
|
|
assert_eq!(
|
|
list.iter().map(|c| c.bit).collect::<Vec<_>>(),
|
|
[2, 4, 512, 256],
|
|
"index order, not bit order; Hardcore's row is commented out"
|
|
);
|
|
assert_eq!(list[0].name_key, "CHALLENGE_SLOW_AND_STEADY");
|
|
assert_eq!(list[0].text_key, "CHALLENGE_SLOW_AND_STEADY_TEXT");
|
|
assert_eq!(list[0].icon, "Spell_Nature_TimeStop", "quotes stripped");
|
|
assert_eq!(list[3].icon, "INV_Cask_03");
|
|
}
|
|
|
|
/// Against the player's own chain: the offer is whatever the winning `ChallengeList.lua`
|
|
/// says, every bit is a distinct single bit of the mask the create packet carries, and every
|
|
/// key it names resolves in the winning `GlueStrings.lua` with an icon on disk. This is the
|
|
/// test that says "all the challenges this realm offers" rather than a list we chose. Skips
|
|
/// without an install.
|
|
#[test]
|
|
fn the_offer_is_the_chains_own_list_with_every_caption_and_icon_present() {
|
|
let data = benilla_formats::wow_data_or_skip!();
|
|
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
|
let bytes = chain.read_file(CHALLENGE_LIST).expect("ChallengeList.lua");
|
|
let list = parse_challenges(&String::from_utf8_lossy(&bytes));
|
|
assert!(!list.is_empty(), "the chain carries a challenge list");
|
|
|
|
let mut seen = 0u32;
|
|
for c in &list {
|
|
assert!(
|
|
c.bit.is_power_of_two(),
|
|
"{} is {:#x}, not one bit of the mask",
|
|
c.name_key,
|
|
c.bit
|
|
);
|
|
assert_eq!(seen & c.bit, 0, "{} repeats a bit", c.name_key);
|
|
seen |= c.bit;
|
|
}
|
|
|
|
let strings = crate::glue_strings::table_from_chain(&mut chain);
|
|
for c in &list {
|
|
assert!(
|
|
strings.get(&c.name_key).is_some(),
|
|
"no name for {}",
|
|
c.name_key
|
|
);
|
|
assert!(
|
|
strings.get(&c.text_key).is_some(),
|
|
"no text for {}",
|
|
c.text_key
|
|
);
|
|
let icon = format!("Interface\\Icons\\{}.blp", c.icon);
|
|
assert!(chain.contains(&icon), "{} has no {icon}", c.name_key);
|
|
}
|
|
// The picker's own chrome strings, and the two refusal lines the gates show.
|
|
for key in [
|
|
"CHALLENGES",
|
|
"CHALLENGES_TEXT",
|
|
"CHALLENGE_SAMURAI_RESTRICTED",
|
|
"CHALLENGE_TF_LUNATIC_EXCLUSIVE",
|
|
] {
|
|
assert!(strings.get(key).is_some(), "the chain has no {key}");
|
|
}
|
|
eprintln!(
|
|
"challenges this chain offers: {}",
|
|
list.iter()
|
|
.map(|c| format!("{} ({:#x})", c.name_key, c.bit))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_row_missing_a_field_is_no_row() {
|
|
let list = parse_challenges(
|
|
"Challenges[1] = { selected = false, name = C_A, text = C_A_TEXT, icon = \"i\", }\n\
|
|
Challenges[2] = { selected = false, id = 4, text = C_B_TEXT, icon = \"i\", }\n\
|
|
Challenges[3] = { selected = false, id = 8, name = C_C, text = C_C_TEXT, icon = \"j\", }\n",
|
|
);
|
|
assert_eq!(list.len(), 1, "only the whole row survives");
|
|
assert_eq!(list[0].bit, 8);
|
|
}
|
|
|
|
fn catalog() -> Challenges {
|
|
Challenges(vec![
|
|
Challenge {
|
|
bit: 32,
|
|
name_key: LUNATIC.into(),
|
|
text_key: "T".into(),
|
|
icon: "i".into(),
|
|
},
|
|
Challenge {
|
|
bit: 1024,
|
|
name_key: SAMURAI.into(),
|
|
text_key: "T".into(),
|
|
icon: "i".into(),
|
|
},
|
|
Challenge {
|
|
bit: 2048,
|
|
name_key: TOGETHER_FOREVER.into(),
|
|
text_key: "T".into(),
|
|
icon: "i".into(),
|
|
},
|
|
Challenge {
|
|
bit: 4096,
|
|
name_key: TRUE_HARDCORE.into(),
|
|
text_key: "T".into(),
|
|
icon: "i".into(),
|
|
},
|
|
])
|
|
}
|
|
|
|
/// The Samurai gate is the server's own class list; the realm's Lua mirrors it so the refusal
|
|
/// happens before a packet the server would reject.
|
|
#[test]
|
|
fn samurai_is_refused_off_the_four_classes_the_server_allows() {
|
|
let c = catalog();
|
|
for class in ["WARRIOR", "PALADIN", "HUNTER", "ROGUE"] {
|
|
assert_eq!(c.blocked(1, class, 0), None, "{class} may take Samurai");
|
|
}
|
|
for class in ["MAGE", "PRIEST", "DRUID", "WARLOCK", "SHAMAN"] {
|
|
assert_eq!(
|
|
c.blocked(1, class, 0),
|
|
Some("CHALLENGE_SAMURAI_RESTRICTED"),
|
|
"{class} may not"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Together Forever and Level One Lunatic block each other, in both directions, and only
|
|
/// while the other is actually held.
|
|
#[test]
|
|
fn together_forever_and_lunatic_block_each_other_both_ways() {
|
|
let c = catalog();
|
|
assert_eq!(c.blocked(0, "MAGE", 0), None, "nothing held");
|
|
assert_eq!(c.blocked(2, "MAGE", 0), None, "nothing held");
|
|
assert_eq!(
|
|
c.blocked(0, "MAGE", 2048),
|
|
Some("CHALLENGE_TF_LUNATIC_EXCLUSIVE"),
|
|
"Lunatic while Together Forever is held"
|
|
);
|
|
assert_eq!(
|
|
c.blocked(2, "MAGE", 32),
|
|
Some("CHALLENGE_TF_LUNATIC_EXCLUSIVE"),
|
|
"Together Forever while Lunatic is held"
|
|
);
|
|
}
|
|
|
|
/// True Hardcore is offered only on the RP-flagged realm the Hardcore routing points at.
|
|
#[test]
|
|
fn true_hardcore_is_offered_only_on_the_rp_flagged_realm() {
|
|
let c = catalog();
|
|
assert!(!c.offered(3, false), "hidden off the Hardcore realm");
|
|
assert!(c.offered(3, true));
|
|
assert!(c.offered(0, false), "every other entry is always offered");
|
|
}
|
|
|
|
/// Submit-time cleanup drops a bit the live class or realm no longer allows, and keeps the
|
|
/// first of a conflicting pair rather than throwing both away.
|
|
#[test]
|
|
fn sanitize_drops_only_the_bits_the_realm_would_reject() {
|
|
let c = catalog();
|
|
assert_eq!(
|
|
c.sanitize(1024, "WARRIOR", true),
|
|
1024,
|
|
"a legal pick rides"
|
|
);
|
|
assert_eq!(
|
|
c.sanitize(1024, "MAGE", true),
|
|
0,
|
|
"the class changed under it"
|
|
);
|
|
assert_eq!(c.sanitize(4096, "MAGE", false), 0, "not the Hardcore realm");
|
|
assert_eq!(
|
|
c.sanitize(32 | 2048, "MAGE", true),
|
|
2048,
|
|
"the earlier of the pair goes: Lunatic, the one `ChallengeList_Save` hides"
|
|
);
|
|
assert_eq!(c.sanitize(0, "MAGE", false), 0);
|
|
}
|
|
}
|