Files
benilla-octo/crates/benilla-app/src/challenges.rs
T
gromandClaude Opus 5 81d96af35d Everwood: the HD/OctoWoW line of benilla, imported onto upstream
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
2026-10-15 15:01:14 -06:00

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);
}
}