With realms in more than one category, the realm list shows the stock category tabs, so every realm can be reached (#467)
With realms in more than one category, the realm list shows the stock category tabs, so every realm can be reached. Before, it listed only the first category's realms and had no way to the rest. - **The Region** is `Wow.ini`'s `[WoW Config] Region`, read through the archive chain as `0x40249d` loads it (the stock install ships it in `interface.MPQ`), with the section and key compared case-insensitively (`0x445356`, `0x4453cb`) and the value parsed as `0x445470` does. It is 0 when the file or the key is missing. - **The categories** are the `Cfg_Categories.dbc` rows whose Region column equals it, in file order (`0x46e300`): id from column 0, name from the row. - **Placement** (`0x46e510`): a realm's category byte 0 takes the first category's id (`0x46e5b8`), then joins every category with that id (`0x46e60b`). A byte matching no category puts the realm on no tab, as in 1.12.1. Tab ordinals count the non-empty categories only (`0x46edf0`), and the load-band statistics still run over every realm. - **The tabs** are built from `RealmList.xml`: hidden for one category, `RealmListTab1`..`N` with the category names for two to eight, the front one raised and disabled. A click fronts the tab and re-lists (`RealmListTab_OnClick`), the highlight falls back to the current realm or none, and the scroll clamps to the new tab. Change Realm and `OnShow` keep the stored category (`0x46f188`, `0x46f400`). - **No Region rows**: every realm lists on one untabbed page. The reference reads a NULL category there (`0x46f26a`, `0x46e5b8`), and benilla does not copy the fault. - **Logging in** by name, the remembered or current realm, still searches every realm, as the reference resolves by name. - A dev capture, `WOW_CAPTURE=glue-realmlist`, shows two tabs over five invented realms. Checked: tests for the Region read, the category rows for a US and an EU Region from the install's DBC, placement (byte 0 folded, an unmatched byte on no tab), the strip hidden for one category and named for two, a tab click through the real click path, the highlight and scroll on a switch, the stored category across Change Realm, the statistics over every realm, and the no-rows fallback. Each fails with its piece undone. Gates and the smoke run green on macOS; no platform seam touched. Fixes #362 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d45847c5b4
commit
db3be64158
@@ -76,6 +76,7 @@ mod probe_stone;
|
||||
mod probe_taxi;
|
||||
mod probe_vendor_swap;
|
||||
mod probes;
|
||||
mod realm_list;
|
||||
mod scenarios;
|
||||
use crate::run_mode::CaptureMode;
|
||||
pub(crate) use depth_probe::DepthProbePlugin;
|
||||
@@ -138,7 +139,7 @@ pub(crate) use probe_pitch::ProbePitchPlugin;
|
||||
pub(crate) fn start_state() -> crate::char_select::ClientState {
|
||||
match glue_screen() {
|
||||
Some(GlueScreen::CharCreate) => crate::char_select::ClientState::CharCreate,
|
||||
Some(GlueScreen::Login) => crate::char_select::ClientState::Login,
|
||||
Some(GlueScreen::Login | GlueScreen::RealmList) => crate::char_select::ClientState::Login,
|
||||
None if crate::run_mode::scenario_active() => crate::char_select::ClientState::InWorld,
|
||||
None => crate::char_select::ClientState::Login,
|
||||
}
|
||||
@@ -673,6 +674,7 @@ impl Plugin for CapturePlugin {
|
||||
// Before `UnitFeed`: the seed stands in for wire data live play delivers on earlier
|
||||
// frames, so the same frame's feeds and the one-shot `MERCHANT_SHOW` paint must see it.
|
||||
.add_systems(Update, seed_ui_fixture.before(crate::ui_unit::UnitFeed))
|
||||
.add_systems(Update, realm_list::seed_realm_list)
|
||||
.add_systems(Last, drive_capture);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! `glue-realmlist`: the realm list over the login screen with realms in two categories, which the
|
||||
//! live server cannot show (it lists one). The list is published as the realm park publishes one,
|
||||
//! then the dialog is raised as Change Realm raises it; the tabs are named from the install's own
|
||||
//! Region, so bytes 1 and 2 are "English" and "German" under Region 3 and 1 alone under Region 1.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_protocol::RealmInfo;
|
||||
|
||||
use super::CaptureCtx;
|
||||
|
||||
/// The scenario's name in [`super::scenarios::GLUE_SCENARIOS`].
|
||||
pub(super) const NAME: &str = "glue-realmlist";
|
||||
|
||||
/// Invented realms: three on category byte 1, two on byte 2, a mix of types, loads and counts.
|
||||
fn fixture() -> Vec<RealmInfo> {
|
||||
let realm =
|
||||
|name: &str, category: u8, realm_type: u32, population: f32, characters: u8| RealmInfo {
|
||||
name: name.into(),
|
||||
address: "127.0.0.1:8085".into(),
|
||||
population,
|
||||
characters,
|
||||
realm_type,
|
||||
flags: 0,
|
||||
category,
|
||||
id: 0,
|
||||
};
|
||||
vec![
|
||||
realm("Amberfield", 1, 0, 0.5, 2),
|
||||
realm("Brightwater", 1, 1, 1.5, 0),
|
||||
realm("Copperdale", 1, 6, 1.0, 0),
|
||||
realm("Dunmoor", 2, 1, 0.8, 0),
|
||||
realm("Eisenwald", 2, 0, 1.2, 0),
|
||||
]
|
||||
}
|
||||
|
||||
/// Publish the list, let the policy take it, then raise the dialog.
|
||||
pub(super) fn seed_realm_list(
|
||||
ctx: Res<CaptureCtx>,
|
||||
mut step: Local<u8>,
|
||||
mut lists: MessageWriter<crate::net::RealmListMessage>,
|
||||
mut realms: ResMut<crate::realm_select::Realms>,
|
||||
choice: Res<crate::net::RealmChoice>,
|
||||
) {
|
||||
if ctx.name != NAME {
|
||||
return;
|
||||
}
|
||||
match *step {
|
||||
0 => {
|
||||
lists.write(crate::net::RealmListMessage { realms: fixture() });
|
||||
}
|
||||
2 => crate::realm_select::open_over_char_select(&mut realms, &choice),
|
||||
3.. => return,
|
||||
_ => {}
|
||||
}
|
||||
*step += 1;
|
||||
}
|
||||
@@ -167,10 +167,13 @@ pub(crate) enum GlueScreen {
|
||||
/// The login screen, `UI_MainMenu` behind the account form. Its backdrop is the narrowest of
|
||||
/// the seven scenes, so a wide window (`WOW_WIN=2560x1440`) reaches its edges first.
|
||||
Login,
|
||||
/// The realm list over the login screen, fed a list in two categories
|
||||
/// (`super::realm_list`).
|
||||
RealmList,
|
||||
}
|
||||
|
||||
/// The glue scenarios: character creation as a human male warrior, the race the reference's own
|
||||
/// screenshots use, and the login screen.
|
||||
/// screenshots use, the login screen, and the realm list's category tabs over it.
|
||||
pub(super) const GLUE_SCENARIOS: &[GlueScenario] = &[
|
||||
GlueScenario {
|
||||
name: "glue-charcreate",
|
||||
@@ -182,6 +185,11 @@ pub(super) const GLUE_SCENARIOS: &[GlueScenario] = &[
|
||||
screen: GlueScreen::Login,
|
||||
pick: None,
|
||||
},
|
||||
GlueScenario {
|
||||
name: super::realm_list::NAME,
|
||||
screen: GlueScreen::RealmList,
|
||||
pick: None,
|
||||
},
|
||||
];
|
||||
|
||||
/// The golden baseline: two spots at noon and at night, Elwynn water and a Felwood hollow on
|
||||
|
||||
@@ -144,6 +144,10 @@ pub(crate) struct GlueArt {
|
||||
pub(crate) column_tabs: Option<(Handle<Image>, Vec2)>,
|
||||
pub(crate) tab_highlight: Option<Handle<AddUiMaterial>>,
|
||||
pub(crate) sort_arrow: Option<(Handle<Image>, Vec2)>,
|
||||
/// The realm list's category tabs (`RealmListTabButtonTemplate`): the front tab's
|
||||
/// `UI-Character-ActiveTab` and the others' `UI-Character-InActiveTab`.
|
||||
pub(crate) tab_active: Option<(Handle<Image>, Vec2)>,
|
||||
pub(crate) tab_inactive: Option<(Handle<Image>, Vec2)>,
|
||||
}
|
||||
|
||||
/// `RealmSortButtonTemplate`'s `WhoFrame-ColumnTabs` slices, `[left, right, top, bottom]` each:
|
||||
@@ -155,6 +159,13 @@ pub(crate) const COLUMN_TAB_TC: [[f32; 4]; 3] = [
|
||||
];
|
||||
/// `$parentArrow`'s texcoords into `UI-SortArrow`, the down-pointing half.
|
||||
pub(crate) const SORT_ARROW_TC: [f32; 4] = [0.0, 0.5625, 0.0, 1.0];
|
||||
/// `RealmListTabButtonTemplate`'s `UI-Character-(In)ActiveTab` slices, `[left, right, top,
|
||||
/// bottom]` each: the left end (20 wide), the middle stretched to the label, the right end.
|
||||
pub(crate) const REALM_TAB_TC: [[f32; 4]; 3] = [
|
||||
[0.0, 0.156_25, 0.0, 1.0],
|
||||
[0.156_25, 0.843_75, 0.0, 1.0],
|
||||
[0.843_75, 1.0, 0.0, 1.0],
|
||||
];
|
||||
|
||||
/// The `HelpFrame-*` plate: six pieces tiling a 640×512 panel (TopLeft/Top 256², TopRight
|
||||
/// 128×256 on top; BotLeft/Bottom/BotRight below).
|
||||
@@ -427,10 +438,20 @@ impl GlueArt {
|
||||
add_mats,
|
||||
);
|
||||
self.sort_arrow = sized(assets, "Interface\\Buttons\\UI-SortArrow", images);
|
||||
self.tab_active = sized(
|
||||
assets,
|
||||
"Interface\\PaperDollInfoFrame\\UI-Character-ActiveTab",
|
||||
images,
|
||||
);
|
||||
self.tab_inactive = sized(
|
||||
assets,
|
||||
"Interface\\PaperDollInfoFrame\\UI-Character-InActiveTab",
|
||||
images,
|
||||
);
|
||||
debug!(
|
||||
"glue art: addonlist set — helpframe {} header {} close {} droparrow {}/{} \
|
||||
questhl {} titlehl {} greycheck {} tipborder {} scrolltrack {} \
|
||||
coltabs {} tabhl {} sortarrow {}",
|
||||
coltabs {} tabhl {} sortarrow {} realmtabs {}/{}",
|
||||
self.help_frame.is_some(),
|
||||
self.dialog_header.is_some(),
|
||||
self.close_btn.is_some(),
|
||||
@@ -444,6 +465,8 @@ impl GlueArt {
|
||||
self.column_tabs.is_some(),
|
||||
self.tab_highlight.is_some(),
|
||||
self.sort_arrow.is_some(),
|
||||
self.tab_active.is_some(),
|
||||
self.tab_inactive.is_some(),
|
||||
);
|
||||
debug!(
|
||||
"glue art: races {} classes {} gender {} factions {} banners {} hilight {} logo {} \
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
//! The realm categories behind the list's tabs.
|
||||
//!
|
||||
//! The category set is the client Region's `Cfg_Categories.dbc` rows, built once per process
|
||||
//! (`0x46e300`). Each list rebuild (`0x46e510`) places every realm by its wire category byte: a 0
|
||||
//! byte takes the first category's id (`0x46e5b8`), then the byte joins every category whose id
|
||||
//! equals it (`0x46e60b`), and a byte no category carries leaves the realm on no tab at all.
|
||||
//!
|
||||
//! A category the Lua names crosses as a 1-based ordinal over the non-empty categories only; the
|
||||
//! map to the stored index (`0x46edf0`) has no sentinel, so an ordinal past the last one reads
|
||||
//! the first stored category, empty or not.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_formats::RealmCategory;
|
||||
use benilla_protocol::RealmInfo;
|
||||
|
||||
use super::Realms;
|
||||
|
||||
/// `MAX_REALM_CATEGORY_TABS` (`RealmList.lua:4`).
|
||||
pub(super) const MAX_TABS: usize = 8;
|
||||
|
||||
/// Every category's realms, as indices into the list in wire order. Before the category set is
|
||||
/// read, or when the Region has none, there is one category holding every realm.
|
||||
fn place(categories: Option<&[RealmCategory]>, realms: &[RealmInfo]) -> Vec<Vec<usize>> {
|
||||
// Deviation: no Region rows faults the reference on a NULL category (`0x46e5b8`); list all.
|
||||
let Some(categories) = categories.filter(|c| !c.is_empty()) else {
|
||||
return vec![(0..realms.len()).collect()];
|
||||
};
|
||||
let mut buckets = vec![Vec::new(); categories.len()];
|
||||
for (i, realm) in realms.iter().enumerate() {
|
||||
let byte = match realm.category {
|
||||
0 => match categories.first() {
|
||||
Some(first) => first.id,
|
||||
None => continue,
|
||||
},
|
||||
b => u32::from(b),
|
||||
};
|
||||
// No break: a realm joins every category carrying its id.
|
||||
for (bucket, category) in buckets.iter_mut().zip(categories) {
|
||||
if category.id == byte {
|
||||
bucket.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
buckets
|
||||
}
|
||||
|
||||
/// `0x46edf0`: the stored index of the `visible`-th non-empty category (1-based), or 0.
|
||||
fn stored_index(buckets: &[Vec<usize>], visible: usize) -> usize {
|
||||
buckets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, b)| !b.is_empty())
|
||||
.nth(visible.wrapping_sub(1))
|
||||
.map_or(0, |(i, _)| i)
|
||||
}
|
||||
|
||||
impl Realms {
|
||||
/// Every category's realms; see [`place`].
|
||||
fn buckets(&self) -> Vec<Vec<usize>> {
|
||||
place(self.categories.as_deref(), &self.realms)
|
||||
}
|
||||
|
||||
/// The realms of the tab in front, unsorted: `GetNumRealms` and `GetRealmInfo` with
|
||||
/// `RealmList.selectedCategory`.
|
||||
pub(super) fn category_realms(&self) -> Vec<usize> {
|
||||
let mut buckets = self.buckets();
|
||||
let at = stored_index(&buckets, self.category());
|
||||
if at < buckets.len() {
|
||||
std::mem::take(&mut buckets[at])
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// `RealmList.selectedCategory`: nil until the first `RealmListUpdate` makes it 1.
|
||||
pub(super) fn category(&self) -> usize {
|
||||
self.category.unwrap_or(1)
|
||||
}
|
||||
|
||||
/// The tab labels, `GetRealmCategories` (`0x46f1f0`): the names of the categories holding a
|
||||
/// realm. `RealmList_UpdateTabs` hides the strip unless there are two or more, so the one name
|
||||
/// the reference still pushes when none hold a realm (`0x46f255`) is left out.
|
||||
pub(super) fn tabs(&self) -> Vec<&str> {
|
||||
let Some(categories) = &self.categories else {
|
||||
return Vec::new();
|
||||
};
|
||||
self.buckets()
|
||||
.iter()
|
||||
.zip(categories)
|
||||
.filter(|(b, _)| !b.is_empty())
|
||||
.map(|(_, c)| c.name.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `GetSelectedCategory` (`0x46f400`): the ordinal of the first non-empty category at or after
|
||||
/// the stored one, else 1.
|
||||
pub(super) fn selected_category(&self) -> usize {
|
||||
let from = self.chosen_category.unwrap_or(0);
|
||||
self.buckets()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, b)| !b.is_empty())
|
||||
.enumerate()
|
||||
.find(|(_, (i, _))| *i >= from)
|
||||
.map_or(1, |(ordinal, _)| ordinal + 1)
|
||||
}
|
||||
|
||||
/// `RealmList_OnShow`: the tab `GetSelectedCategory` names comes to the front, when a tab
|
||||
/// button carries that ordinal.
|
||||
pub(super) fn front_selected_category(&mut self) {
|
||||
let ordinal = self.selected_category();
|
||||
if ordinal <= MAX_TABS {
|
||||
self.category = Some(ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// `RealmListTab_OnClick`: the tab comes to the front and the list redraws. The redraw has no
|
||||
/// clicked name to keep (`RealmListUpdate` clears `selectedName`), so the highlight falls back
|
||||
/// to the realm this session is on, when that realm is on the new tab.
|
||||
pub(super) fn click_tab(&mut self, ordinal: usize) {
|
||||
self.category = Some(ordinal);
|
||||
self.refresh_in = super::REFRESH_SECS;
|
||||
self.highlight_current();
|
||||
self.clamp_offset();
|
||||
}
|
||||
|
||||
/// `ChangeRealm` stores the front tab's category (`0x46f188`), unconditionally, before it
|
||||
/// dials; `GetSelectedCategory` reads it when the list next opens.
|
||||
pub(super) fn store_front_category(&mut self) {
|
||||
self.chosen_category = Some(stored_index(&self.buckets(), self.category()));
|
||||
}
|
||||
|
||||
/// The rebuild's tail (`0x46e6f0`): the category holding the realm this session names becomes
|
||||
/// the stored one; with no such realm, or one on no tab, it is left as it was.
|
||||
pub(super) fn store_current_category(&mut self) {
|
||||
let (Some(categories), Some(want)) = (&self.categories, &self.current) else {
|
||||
return;
|
||||
};
|
||||
// The walk visits every realm, so the last one of the name decides.
|
||||
let Some(realm) = self
|
||||
.realms
|
||||
.iter()
|
||||
.rfind(|r| r.name.eq_ignore_ascii_case(want))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let byte = match realm.category {
|
||||
0 => match categories.first() {
|
||||
Some(first) => first.id,
|
||||
None => return,
|
||||
},
|
||||
b => u32::from(b),
|
||||
};
|
||||
if let Some(at) = categories.iter().position(|c| c.id == byte) {
|
||||
self.chosen_category = Some(at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the client Region's category set once, after the archive chain opens.
|
||||
pub(super) fn load_categories(
|
||||
mut realms: ResMut<Realms>,
|
||||
assets: Option<Res<benilla_assets::WorldAssets>>,
|
||||
) {
|
||||
use benilla_assets::LockRecover;
|
||||
let Some(assets) = assets else {
|
||||
return;
|
||||
};
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
let region = benilla_formats::client_region(&chain);
|
||||
match benilla_formats::load_realm_categories(&mut chain, region) {
|
||||
Ok(categories) => {
|
||||
info!(
|
||||
"realm list: Region {region}, categories {:?}",
|
||||
categories
|
||||
.iter()
|
||||
.map(|c| (c.id, c.name.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
if categories.is_empty() {
|
||||
warn!(
|
||||
"realm list: Region {region} names no Cfg_Categories.dbc row, so every realm \
|
||||
lists on one untabbed page (the reference's Region is Wow.ini's [WoW Config] \
|
||||
Region)"
|
||||
);
|
||||
}
|
||||
realms.categories = Some(categories);
|
||||
}
|
||||
Err(e) => warn!(
|
||||
"realm list: Cfg_Categories.dbc failed to load, so every realm lists on one untabbed \
|
||||
page: {e:#}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cat(id: u32, name: &str) -> RealmCategory {
|
||||
RealmCategory {
|
||||
id,
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Region 1's rows, `Cfg_Categories.dbc` in file order.
|
||||
pub(in crate::realm_select) fn us() -> Vec<RealmCategory> {
|
||||
vec![cat(1, "United States"), cat(5, "Oceanic")]
|
||||
}
|
||||
|
||||
fn realm(name: &str, category: u8) -> RealmInfo {
|
||||
RealmInfo {
|
||||
name: name.into(),
|
||||
address: "127.0.0.1:8085".into(),
|
||||
population: 1.0,
|
||||
characters: 0,
|
||||
realm_type: 0,
|
||||
flags: 0,
|
||||
category,
|
||||
id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn realms(list: &[(&str, u8)]) -> Realms {
|
||||
Realms {
|
||||
realms: list.iter().map(|&(n, c)| realm(n, c)).collect(),
|
||||
categories: Some(us()),
|
||||
..Realms::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn names(r: &Realms) -> Vec<String> {
|
||||
r.rows().iter().map(|&i| r.realms[i].name.clone()).collect()
|
||||
}
|
||||
|
||||
/// The US client reads its two categories from the install's own `Cfg_Categories.dbc`.
|
||||
#[test]
|
||||
fn the_us_categories_come_from_the_install() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let loaded = benilla_formats::load_realm_categories(&mut chain, 1).expect("DBC");
|
||||
assert_eq!(loaded, us());
|
||||
}
|
||||
|
||||
/// A category byte is an id, matched for equality: 1 and 5 are the two US tabs.
|
||||
#[test]
|
||||
fn a_realm_lands_on_the_tab_whose_id_is_its_category_byte() {
|
||||
let mut r = realms(&[("Alpha", 1), ("Down Under", 5), ("Beta", 1)]);
|
||||
assert_eq!(r.tabs(), ["United States", "Oceanic"]);
|
||||
assert_eq!(names(&r), ["Alpha", "Beta"]);
|
||||
r.category = Some(2);
|
||||
assert_eq!(names(&r), ["Down Under"]);
|
||||
}
|
||||
|
||||
/// Byte 0 (a vmangos development-zone realm) folds into the first category.
|
||||
#[test]
|
||||
fn a_zero_byte_joins_the_first_category() {
|
||||
let r = realms(&[("Dev", 0), ("Alpha", 1)]);
|
||||
assert_eq!(r.tabs(), ["United States"], "one tab: the strip hides");
|
||||
assert_eq!(names(&r), ["Alpha", "Dev"]);
|
||||
}
|
||||
|
||||
/// A byte no category of the Region carries lists nowhere.
|
||||
#[test]
|
||||
fn a_byte_outside_the_region_is_on_no_tab() {
|
||||
let r = realms(&[("Alpha", 1), ("Berlin", 2)]);
|
||||
assert_eq!(r.tabs(), ["United States"]);
|
||||
assert_eq!(names(&r), ["Alpha"]);
|
||||
assert_eq!(r.category_realms(), [0]);
|
||||
}
|
||||
|
||||
/// Only a category holding a realm gets a tab, and ordinals count those alone.
|
||||
#[test]
|
||||
fn an_empty_category_has_no_tab_and_no_ordinal() {
|
||||
let mut r = realms(&[("Down Under", 5)]);
|
||||
assert_eq!(r.tabs(), ["Oceanic"], "United States holds nothing");
|
||||
assert_eq!(
|
||||
names(&r),
|
||||
["Down Under"],
|
||||
"ordinal 1 is the Oceanic category"
|
||||
);
|
||||
// Past the last ordinal the map reads stored category 0, the empty United States.
|
||||
r.category = Some(2);
|
||||
assert!(names(&r).is_empty());
|
||||
}
|
||||
|
||||
/// Before the category set is read every realm lists, untabbed.
|
||||
#[test]
|
||||
fn with_no_category_set_every_realm_lists_untabbed() {
|
||||
let mut r = realms(&[("Alpha", 1), ("Berlin", 2), ("Dev", 0)]);
|
||||
r.categories = None;
|
||||
assert!(r.tabs().is_empty());
|
||||
assert_eq!(names(&r), ["Alpha", "Berlin", "Dev"]);
|
||||
}
|
||||
|
||||
/// A Region no row names (Region 0, no `Wow.ini` Region) lists every realm untabbed too,
|
||||
/// where the reference faults; the list opens on it and a realm on it can be entered.
|
||||
#[test]
|
||||
fn a_region_with_no_categories_lists_every_realm_untabbed() {
|
||||
let mut r = realms(&[("Alpha", 1), ("Berlin", 2), ("Dev", 0)]);
|
||||
r.categories = Some(Vec::new());
|
||||
assert!(r.tabs().is_empty());
|
||||
assert_eq!(names(&r), ["Alpha", "Berlin", "Dev"]);
|
||||
r.current = Some("Berlin".into());
|
||||
r.store_current_category();
|
||||
r.open();
|
||||
assert_eq!(r.selected().map(|r| r.name.as_str()), Some("Berlin"));
|
||||
assert!(r.can_enter());
|
||||
}
|
||||
|
||||
/// A tab click fronts its category, and the highlight moves to the session's realm if it is
|
||||
/// there, else nowhere, so Okay cannot enter a realm from another tab.
|
||||
#[test]
|
||||
fn a_tab_click_relists_and_resets_the_highlight() {
|
||||
let mut r = realms(&[("Alpha", 1), ("Down Under", 5)]);
|
||||
r.current = Some("Alpha".into());
|
||||
r.select("Alpha");
|
||||
r.click_tab(2);
|
||||
assert_eq!(names(&r), ["Down Under"]);
|
||||
assert!(r.selected().is_none() && !r.can_enter());
|
||||
r.click_tab(1);
|
||||
assert_eq!(names(&r), ["Alpha"]);
|
||||
assert_eq!(r.selected().map(|r| r.name.as_str()), Some("Alpha"));
|
||||
}
|
||||
|
||||
/// A tab with fewer realms than the scroll offset shows from its top.
|
||||
#[test]
|
||||
fn a_tab_click_clamps_the_scroll_to_the_new_tab() {
|
||||
let mut list: Vec<(String, u8)> = (0..30).map(|i| (format!("R{i:02}"), 1)).collect();
|
||||
list.push(("Down Under".into(), 5));
|
||||
let refs: Vec<(&str, u8)> = list.iter().map(|(n, c)| (n.as_str(), *c)).collect();
|
||||
let mut r = realms(&refs);
|
||||
r.offset = 12;
|
||||
r.click_tab(2);
|
||||
assert_eq!(r.offset, 0);
|
||||
}
|
||||
|
||||
/// The list opens on the tab of the realm this session is on, and a realm entered from a tab
|
||||
/// brings that tab back next time.
|
||||
#[test]
|
||||
fn the_list_opens_on_the_current_realms_tab() {
|
||||
let mut r = realms(&[("Alpha", 1), ("Down Under", 5)]);
|
||||
assert_eq!(r.selected_category(), 1, "nothing stored: the first tab");
|
||||
r.current = Some("down under".into());
|
||||
r.store_current_category();
|
||||
assert_eq!(r.selected_category(), 2);
|
||||
r.front_selected_category();
|
||||
assert_eq!(names(&r), ["Down Under"]);
|
||||
|
||||
let mut r = realms(&[("Alpha", 1), ("Down Under", 5)]);
|
||||
r.category = Some(2);
|
||||
r.store_front_category();
|
||||
r.category = Some(1);
|
||||
r.front_selected_category();
|
||||
assert_eq!(r.category(), 2, "ChangeRealm's stored tab");
|
||||
}
|
||||
|
||||
/// A stored category that has emptied gives the next non-empty one's ordinal.
|
||||
#[test]
|
||||
fn a_stored_category_that_emptied_reads_the_next_tab_on() {
|
||||
let mut r = realms(&[("Alpha", 1), ("Down Under", 5)]);
|
||||
r.chosen_category = Some(0);
|
||||
r.realms.remove(0);
|
||||
assert_eq!(r.selected_category(), 1, "Oceanic, now the only tab");
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@ use super::{is_down, Realms};
|
||||
/// The double-click window, the interval the character select screen uses.
|
||||
const DOUBLE_CLICK_SECS: f32 = 0.4;
|
||||
|
||||
/// Clicks: a row selects (a second one enters), the column headers sort, Okay enters, Cancel and
|
||||
/// the close X leave.
|
||||
/// Clicks: a row selects (a second one enters), a tab fronts its category, the column headers
|
||||
/// sort, Okay enters, Cancel and the close X leave.
|
||||
pub(super) fn clicks(
|
||||
buttons: Query<(Entity, &RealmAction)>,
|
||||
hits: Res<crate::glue::GlueClicks>,
|
||||
@@ -49,6 +49,9 @@ pub(super) fn clicks(
|
||||
enter = true;
|
||||
}
|
||||
}
|
||||
// The front tab is `Disable()`d (`GlueTemplates_SelectTab`) and takes no click.
|
||||
RealmAction::Tab(ordinal) if ordinal != realms.category() => realms.click_tab(ordinal),
|
||||
RealmAction::Tab(_) => {}
|
||||
RealmAction::Ok => enter = true,
|
||||
RealmAction::Cancel => leave = Some(true),
|
||||
RealmAction::Close => leave = Some(false),
|
||||
@@ -177,6 +180,7 @@ fn try_enter(realms: &mut Realms, choice: &RealmChoice, sounds: &mut MessageWrit
|
||||
let name = realm.name.clone();
|
||||
sounds.write(GlueSound("gsLoginChangeRealmOK"));
|
||||
realms.hide();
|
||||
realms.store_front_category();
|
||||
realms.enter(choice, name);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
//! names no screen; only `ConnectToRealm` (`0x46b210`) drops the world connection, once a realm is
|
||||
//! chosen. So the character park serves the list in place and Cancel there does nothing.
|
||||
|
||||
mod category;
|
||||
mod input;
|
||||
mod load;
|
||||
mod screen;
|
||||
@@ -29,6 +30,7 @@ pub(crate) use load::pvp_rp;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::net::{RealmChoice, RealmListMessage, RealmRequest};
|
||||
use benilla_formats::RealmCategory;
|
||||
use benilla_protocol::RealmInfo;
|
||||
|
||||
/// The persisted 1.12 CVar naming the last realm connected to (`0x83f2d0`); the SavedVariables
|
||||
@@ -46,8 +48,15 @@ pub(crate) struct Realms {
|
||||
pub(super) realms: Vec<RealmInfo>,
|
||||
/// The highlighted row by realm name, so a refresh that adds or drops a realm cannot move it.
|
||||
selected: Option<String>,
|
||||
/// The category tab in front (`RealmList.selectedCategory`), by the wire's category byte.
|
||||
pub(super) category: Option<u8>,
|
||||
/// The client Region's categories ([`category`]), read once at startup; `None` until then,
|
||||
/// and every realm lists untabbed.
|
||||
pub(super) categories: Option<Vec<RealmCategory>>,
|
||||
/// The tab in front, `RealmList.selectedCategory`: a 1-based ordinal over the categories
|
||||
/// holding a realm.
|
||||
pub(super) category: Option<usize>,
|
||||
/// `[0x837e98]`, the stored category `GetSelectedCategory` opens the list on, by index into
|
||||
/// [`Self::categories`]; `None` is its `-1` load value.
|
||||
chosen_category: Option<usize>,
|
||||
/// First visible row within the selected category (`RealmList.offset`).
|
||||
pub(super) offset: usize,
|
||||
/// Which column the list is sorted on, and which way.
|
||||
@@ -117,17 +126,11 @@ impl Sort {
|
||||
}
|
||||
|
||||
impl Realms {
|
||||
/// The rows the screen draws: indices into [`Self::realms`] for the selected category, sorted.
|
||||
/// The rows the screen draws: indices into [`Self::realms`] for the tab in front, sorted per
|
||||
/// category (`0x46e750`).
|
||||
pub(super) fn rows(&self) -> Vec<usize> {
|
||||
let category = self.category;
|
||||
let (mean, stddev) = self.stats();
|
||||
let mut rows: Vec<usize> = self
|
||||
.realms
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, r)| category.is_none_or(|c| r.category == c))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
let mut rows = self.category_realms();
|
||||
rows.sort_by(|&a, &b| {
|
||||
let (ra, rb) = (&self.realms[a], &self.realms[b]);
|
||||
for (key, descending) in self.sort.0 {
|
||||
@@ -153,15 +156,6 @@ impl Realms {
|
||||
rows
|
||||
}
|
||||
|
||||
/// The category bytes present, ascending, one tab each; `RealmList_UpdateTabs` hides the strip
|
||||
/// when there is only one.
|
||||
pub(super) fn categories(&self) -> Vec<u8> {
|
||||
let mut cats: Vec<u8> = self.realms.iter().map(|r| r.category).collect();
|
||||
cats.sort_unstable();
|
||||
cats.dedup();
|
||||
cats
|
||||
}
|
||||
|
||||
/// The load distribution, over every realm, not the selected category: `0x46e510` walks the
|
||||
/// flat all-categories array, so switching tabs moves no band.
|
||||
pub(super) fn stats(&self) -> (f32, f32) {
|
||||
@@ -186,22 +180,35 @@ impl Realms {
|
||||
self.selected().is_some_and(|r| !is_down(r))
|
||||
}
|
||||
|
||||
/// `RealmList:Show()`: reset the scroll, highlight [`Self::current`], start the refresh timer.
|
||||
/// `RealmList:Show()`: front the stored category's tab, reset the scroll, highlight
|
||||
/// [`Self::current`], start the refresh timer.
|
||||
fn open(&mut self) {
|
||||
self.shown = true;
|
||||
self.front_selected_category();
|
||||
self.offset = 0;
|
||||
self.selected = self
|
||||
.current
|
||||
.as_deref()
|
||||
.and_then(|want| {
|
||||
self.realms
|
||||
.iter()
|
||||
.find(|r| r.name.eq_ignore_ascii_case(want))
|
||||
})
|
||||
.map(|r| r.name.clone());
|
||||
self.highlight_current();
|
||||
self.refresh_in = REFRESH_SECS;
|
||||
}
|
||||
|
||||
/// `RealmListUpdate` with no clicked name: the highlight is the row `GetRealmInfo` flags
|
||||
/// `currentRealm`, [`Self::current`] case-folded, on the tab in front, or none.
|
||||
fn highlight_current(&mut self) {
|
||||
self.selected = self.current.as_deref().and_then(|want| {
|
||||
self.rows()
|
||||
.into_iter()
|
||||
.map(|i| &self.realms[i])
|
||||
.find(|r| r.name.eq_ignore_ascii_case(want))
|
||||
.map(|r| r.name.clone())
|
||||
});
|
||||
}
|
||||
|
||||
/// `GlueScrollFrame_Update` clamps the scroll bar to the tab's row count, and the bar's
|
||||
/// change handler writes `RealmList.offset` back.
|
||||
fn clamp_offset(&mut self) {
|
||||
let max = self.rows().len().saturating_sub(screen::MAX_ROWS);
|
||||
self.offset = self.offset.min(max);
|
||||
}
|
||||
|
||||
/// Answer a park with a realm and remember it as [`Self::current`].
|
||||
pub(super) fn enter(&mut self, choice: &RealmChoice, name: String) {
|
||||
self.current = Some(name.clone());
|
||||
@@ -230,27 +237,32 @@ pub(crate) struct RealmSelectPlugin;
|
||||
|
||||
impl Plugin for RealmSelectPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<Realms>().add_systems(
|
||||
Update,
|
||||
(
|
||||
apply_realm_policy,
|
||||
// Ungated by state: the dialog stands over login and character select alike.
|
||||
screen::drive_screen,
|
||||
// Input before the row refresh, so a click shows on the frame it landed.
|
||||
smoke::debug_realm_smoke,
|
||||
app.init_resource::<Realms>()
|
||||
.add_systems(
|
||||
Startup,
|
||||
category::load_categories.after(benilla_assets::AssetSet::Open),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
input::clicks,
|
||||
input::keys,
|
||||
tick_refresh,
|
||||
screen::refresh_rows,
|
||||
apply_realm_policy,
|
||||
// Ungated by state: the dialog stands over login and character select alike.
|
||||
screen::drive_screen,
|
||||
// Input before the row refresh, so a click shows on the frame it landed.
|
||||
smoke::debug_realm_smoke,
|
||||
(
|
||||
input::clicks,
|
||||
input::keys,
|
||||
tick_refresh,
|
||||
screen::refresh_list,
|
||||
)
|
||||
.chain()
|
||||
.run_if(|realms: Res<Realms>| realms.shown),
|
||||
)
|
||||
.chain()
|
||||
.run_if(|realms: Res<Realms>| realms.shown),
|
||||
)
|
||||
.chain()
|
||||
.after(benilla_world::schedule::WorldStage::Net)
|
||||
.before(crate::glue::GlueVisuals),
|
||||
);
|
||||
.after(benilla_world::schedule::WorldStage::Net)
|
||||
.before(crate::glue::GlueVisuals),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,16 +280,6 @@ fn apply_realm_policy(
|
||||
for msg in msgs.read() {
|
||||
realms.realms = msg.realms.clone();
|
||||
realms.refresh_in = REFRESH_SECS;
|
||||
// Keep the tab across a refresh while it still exists, else the first.
|
||||
let cats = realms.categories();
|
||||
if !realms.category.is_some_and(|c| cats.contains(&c)) {
|
||||
realms.category = cats.first().copied();
|
||||
}
|
||||
|
||||
// A list published while the dialog is up is a refresh, never a question.
|
||||
if realms.shown {
|
||||
continue;
|
||||
}
|
||||
// The registered default is empty: a client that has never connected.
|
||||
let remembered = cvars
|
||||
.get(CVAR_REALM_NAME)
|
||||
@@ -286,6 +288,14 @@ fn apply_realm_policy(
|
||||
if realms.current.is_none() {
|
||||
realms.current = remembered.clone();
|
||||
}
|
||||
// The rebuild (`0x46e510`): the tab in front is kept as its ordinal, as the Lua keeps it.
|
||||
realms.store_current_category();
|
||||
realms.clamp_offset();
|
||||
|
||||
// A list published while the dialog is up is a refresh, never a question.
|
||||
if realms.shown {
|
||||
continue;
|
||||
}
|
||||
match auto_answer(
|
||||
&realms,
|
||||
remembered.as_deref(),
|
||||
@@ -383,11 +393,11 @@ mod tests {
|
||||
r.rows().iter().map(|&i| r.realms[i].name.clone()).collect()
|
||||
}
|
||||
|
||||
/// A list under the US client's categories.
|
||||
fn list(realms: Vec<RealmInfo>) -> Realms {
|
||||
let category = realms.first().map(|r| r.category);
|
||||
Realms {
|
||||
realms,
|
||||
category,
|
||||
categories: Some(category::tests::us()),
|
||||
..Realms::default()
|
||||
}
|
||||
}
|
||||
@@ -407,13 +417,15 @@ mod tests {
|
||||
assert!(!is_down(&sentinel) && !is_invalid(&sentinel));
|
||||
}
|
||||
|
||||
/// The load distribution spans every realm, so switching tabs relabels no band.
|
||||
/// The load distribution spans every realm, a realm on no tab included, so switching tabs
|
||||
/// relabels no band.
|
||||
#[test]
|
||||
fn a_category_tab_scopes_the_rows_but_not_the_load_distribution() {
|
||||
let mut r = list(vec![
|
||||
realm("Alpha", 1, 0, 0, 0, 1.0),
|
||||
realm("Beta", 1, 0, 0, 0, 1.0),
|
||||
realm("Gamma", 2, 0, 0, 0, 400.0),
|
||||
realm("Gamma", 5, 0, 0, 0, 400.0),
|
||||
realm("Berlin", 2, 0, 0, 0, 7.0),
|
||||
]);
|
||||
r.category = Some(1);
|
||||
assert_eq!(r.rows().len(), 2, "the tab scopes the rows");
|
||||
@@ -426,8 +438,8 @@ mod tests {
|
||||
mean_on_tab_one, mean_on_tab_two,
|
||||
"the distribution is global — the tab must not move it"
|
||||
);
|
||||
// (1 + 1 + 400) / 3.
|
||||
assert!((mean_on_tab_one - 134.0).abs() < 1e-3, "{mean_on_tab_one}");
|
||||
// (1 + 1 + 400 + 7) / 4: the category-2 realm is on no US tab and still counts.
|
||||
assert!((mean_on_tab_one - 102.25).abs() < 1e-3, "{mean_on_tab_one}");
|
||||
}
|
||||
|
||||
/// The reference hides the tab strip for a single category.
|
||||
@@ -437,7 +449,7 @@ mod tests {
|
||||
realm("Alpha", 1, 0, 0, 0, 1.0),
|
||||
realm("Beta", 1, 0, 0, 0, 1.0),
|
||||
]);
|
||||
assert_eq!(r.categories(), vec![1]);
|
||||
assert_eq!(r.tabs(), ["United States"]);
|
||||
}
|
||||
|
||||
/// The default order is characters, load, name, mode, all ascending; more characters first.
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
//! The realm list's layout: `GlueXML/RealmList.xml` in Bevy UI, on the 1024×768 glue scale.
|
||||
//!
|
||||
//! A full-screen dim, then the 640×512 `HelpFrame` plate (the AddOns list's) with the title plate,
|
||||
//! four sort headers, eighteen rows, the selection band, the close X, and Okay and Cancel. Rows are
|
||||
//! spawned empty and [`refresh_rows`] writes them every frame from [`super::Realms`].
|
||||
//! four sort headers, the eight category tabs under the plate, eighteen rows, the selection band,
|
||||
//! the close X, and Okay and Cancel. Rows and tabs are spawned empty and [`refresh_list`] writes
|
||||
//! them every frame from [`super::Realms`].
|
||||
//!
|
||||
//! The category tab strip is not built: `RealmList_UpdateTabs` hides it for a single category, and
|
||||
//! with several, only the first category's realms show.
|
||||
//! The tab strip shows only with two or more categories holding a realm (`RealmList_UpdateTabs`),
|
||||
//! one tab per category, and the rows are the front tab's.
|
||||
|
||||
use bevy::ecs::system::SystemParam;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
|
||||
use crate::glue::art::{tc_rect, GlueArt, COLUMN_TAB_TC, GOLD, SORT_ARROW_TC};
|
||||
use crate::glue::art::{tc_rect, GlueArt, COLUMN_TAB_TC, GOLD, REALM_TAB_TC, SORT_ARROW_TC};
|
||||
use crate::glue::widgets::{
|
||||
abs, glue_button, outlined_text, overlay, ArtSwap, GlueBtnKind, GlueText, Hilight,
|
||||
abs, glue_button, outlined_text, overlay, ArtSwap, GlueBtnKind, GlueDisabled, GlueText, Hilight,
|
||||
};
|
||||
use crate::glue_strings::GlueStrings;
|
||||
|
||||
use super::category::MAX_TABS;
|
||||
use super::load;
|
||||
use super::{Realms, SortKey};
|
||||
|
||||
@@ -67,6 +70,23 @@ const COL_TYPE: (f32, f32) = (235.0, 50.0);
|
||||
const COL_PLAYERS: (f32, f32) = (336.0, 32.0);
|
||||
const COL_LOAD: (f32, f32) = (418.0, 115.0);
|
||||
|
||||
/// `RealmListTab1`'s BOTTOMLEFT sits at the plate's BOTTOMLEFT (11, −15), so the 32-tall tabs hang
|
||||
/// below the plate's lower edge.
|
||||
const TAB_LEFT: f32 = 11.0;
|
||||
const TAB_H: f32 = 32.0;
|
||||
const TAB_TOP: f32 = BG_H + 15.0 - TAB_H;
|
||||
/// Each next tab's LEFT at the one before's RIGHT −15.
|
||||
const TAB_OVERLAP: f32 = 15.0;
|
||||
/// `$parentLeft` and `$parentRight`, 20 wide; `GlueTemplates_TabResize(0)` makes the middle the
|
||||
/// label's own width.
|
||||
const TAB_END: f32 = 20.0;
|
||||
/// `$parentLeftDisabled` at TOPLEFT (0, 3): the front tab's art stands 3 higher.
|
||||
const TAB_FRONT_RISE: f32 = 3.0;
|
||||
/// `$parentText` at CENTER (0, 2), and the highlight's two anchors at y 2.
|
||||
const TAB_TEXT_RISE: f32 = 2.0;
|
||||
/// `$parentHighlightTexture`, anchored 10 in from each side; its height is its art's, 32.
|
||||
const TAB_HILIGHT_INSET: f32 = 10.0;
|
||||
|
||||
/// Root of the realm list; `with_art` and `s` trigger a rebuild when the art lands or the scale
|
||||
/// changes.
|
||||
#[derive(Component)]
|
||||
@@ -87,6 +107,8 @@ pub(super) enum RealmAction {
|
||||
/// declares no sound) where Cancel plays `gsLoginChangeRealmCancel`.
|
||||
Close,
|
||||
Sort(SortKey),
|
||||
/// `RealmListTab<n>`: the category tab with that 1-based ordinal.
|
||||
Tab(usize),
|
||||
}
|
||||
|
||||
/// The screen row of a row button and of each of its four texts.
|
||||
@@ -107,6 +129,14 @@ pub(super) struct RowHighlight;
|
||||
/// The Okay button, so the refresh can grey it out.
|
||||
#[derive(Component)]
|
||||
pub(super) struct OkButton;
|
||||
/// A category tab's button, label and art pieces, by the tab's 1-based ordinal.
|
||||
#[derive(Component, Clone, Copy)]
|
||||
pub(super) struct TabOf(pub(super) usize);
|
||||
/// One of a tab's six art pieces: the front tab's `ActiveTab` set, or the others' `InActiveTab`.
|
||||
#[derive(Component, Clone, Copy)]
|
||||
pub(super) struct TabArt {
|
||||
front: bool,
|
||||
}
|
||||
|
||||
/// Spawn, rebuild and despawn the dialog as `shown` says, over whatever glue screen is current.
|
||||
pub(super) fn drive_screen(
|
||||
@@ -205,6 +235,7 @@ fn spawn_screen(
|
||||
spawn_header(b, art, &font, &text("SERVER_SELECTION"), s);
|
||||
spawn_close(b, art, &font, s);
|
||||
spawn_sort_headers(b, art, &font, strings, s);
|
||||
spawn_tabs(b, art, &font, s);
|
||||
spawn_highlight(b, art, s);
|
||||
for row in 0..MAX_ROWS {
|
||||
spawn_row(b, &font, row, s);
|
||||
@@ -442,6 +473,108 @@ fn spawn_sort_headers(
|
||||
}
|
||||
}
|
||||
|
||||
/// `RealmListTab1`..`8` (`RealmListTabButtonTemplate`), spawned hidden in a row that chains each
|
||||
/// tab 15 into the one before. A tab is its two 20-wide ends round a middle as wide as the label,
|
||||
/// in both art sets; [`paint_tabs`] labels them and picks the set.
|
||||
fn spawn_tabs(b: &mut ChildSpawnerCommands, art: &GlueArt, font: &Handle<Font>, s: f32) {
|
||||
let px = |v: f32| Val::Px(v * s);
|
||||
let mut strip = b.spawn(Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: px(TAB_LEFT),
|
||||
top: px(TAB_TOP),
|
||||
height: px(TAB_H),
|
||||
flex_direction: FlexDirection::Row,
|
||||
..default()
|
||||
});
|
||||
strip.with_children(|strip| {
|
||||
for ordinal in 1..=MAX_TABS {
|
||||
let overlap = if ordinal == 1 { 0.0 } else { -TAB_OVERLAP };
|
||||
let mut tab = strip.spawn((
|
||||
RealmAction::Tab(ordinal),
|
||||
TabOf(ordinal),
|
||||
Button,
|
||||
GlueDisabled(false),
|
||||
Visibility::Hidden,
|
||||
Node {
|
||||
height: px(TAB_H),
|
||||
flex_shrink: 0.0,
|
||||
margin: UiRect::left(px(overlap)),
|
||||
padding: UiRect::horizontal(px(TAB_END)),
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
));
|
||||
tab.with_children(|t| {
|
||||
for (front, sheet) in [(false, &art.tab_inactive), (true, &art.tab_active)] {
|
||||
let Some((tex, size)) = sheet else {
|
||||
continue;
|
||||
};
|
||||
let top = if front { -TAB_FRONT_RISE } else { 0.0 };
|
||||
for (piece, tc) in REALM_TAB_TC.iter().enumerate() {
|
||||
let (left, right, width) = match piece {
|
||||
0 => (px(0.0), Val::Auto, px(TAB_END)),
|
||||
1 => (px(TAB_END), px(TAB_END), Val::Auto),
|
||||
_ => (Val::Auto, px(0.0), px(TAB_END)),
|
||||
};
|
||||
t.spawn((
|
||||
TabOf(ordinal),
|
||||
TabArt { front },
|
||||
Visibility::Hidden,
|
||||
ImageNode {
|
||||
image: tex.clone(),
|
||||
rect: Some(tc_rect(*size, *tc)),
|
||||
..default()
|
||||
},
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left,
|
||||
right,
|
||||
width,
|
||||
top: px(top),
|
||||
height: px(TAB_H),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(hi) = &art.tab_highlight {
|
||||
t.spawn((
|
||||
Hilight,
|
||||
Visibility::Hidden,
|
||||
MaterialNode(hi.clone()),
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: px(TAB_HILIGHT_INSET),
|
||||
right: px(TAB_HILIGHT_INSET),
|
||||
top: px(-TAB_TEXT_RISE),
|
||||
height: px(TAB_H),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
}
|
||||
let label = outlined_text(
|
||||
t,
|
||||
Node {
|
||||
top: px(-TAB_TEXT_RISE),
|
||||
..default()
|
||||
},
|
||||
(),
|
||||
(),
|
||||
GlueText {
|
||||
text: "",
|
||||
size: 12.0, // GlueFontNormalSmall
|
||||
color: GOLD,
|
||||
wrap: false,
|
||||
},
|
||||
font,
|
||||
s,
|
||||
);
|
||||
t.commands().entity(label).insert(TabOf(ordinal));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `RealmListHighlight`: one band, moved to the selected row and vertex-coloured to match it.
|
||||
fn spawn_highlight(b: &mut ChildSpawnerCommands, art: &GlueArt, s: f32) {
|
||||
let mut band = b.spawn((
|
||||
@@ -503,10 +636,40 @@ fn spawn_row(b: &mut ChildSpawnerCommands, font: &Handle<Font>, row: usize, s: f
|
||||
});
|
||||
}
|
||||
|
||||
/// `RealmListUpdate`: write every visible row, each frame, since any realm's population moves
|
||||
/// every band.
|
||||
/// The tab strip's buttons, art pieces and labels, kept apart from the rows' by their markers.
|
||||
#[derive(SystemParam)]
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn refresh_rows(
|
||||
pub(super) struct TabNodes<'w, 's> {
|
||||
tabs: Query<
|
||||
'w,
|
||||
's,
|
||||
(
|
||||
&'static TabOf,
|
||||
&'static Interaction,
|
||||
&'static mut Visibility,
|
||||
&'static mut GlueDisabled,
|
||||
),
|
||||
(
|
||||
With<RealmAction>,
|
||||
Without<RowOf>,
|
||||
Without<OkButton>,
|
||||
Without<TabArt>,
|
||||
),
|
||||
>,
|
||||
arts: Query<
|
||||
'w,
|
||||
's,
|
||||
(&'static TabOf, &'static TabArt, &'static mut Visibility),
|
||||
(Without<RealmAction>, Without<RowHighlight>),
|
||||
>,
|
||||
labels:
|
||||
Query<'w, 's, (&'static TabOf, &'static mut Text, &'static mut TextColor), Without<RowOf>>,
|
||||
}
|
||||
|
||||
/// `RealmListUpdate`: the tab strip (`RealmList_UpdateTabs`), then every visible row, each frame,
|
||||
/// since any realm's population moves every band.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn refresh_list(
|
||||
realms: Res<Realms>,
|
||||
strings: Option<Res<GlueStrings>>,
|
||||
mut rows: Query<
|
||||
@@ -518,9 +681,11 @@ pub(super) fn refresh_rows(
|
||||
(&mut Node, &mut Visibility, Option<&mut ImageNode>),
|
||||
(With<RowHighlight>, Without<RealmAction>),
|
||||
>,
|
||||
mut ok: Query<&mut crate::glue::widgets::GlueDisabled, With<OkButton>>,
|
||||
mut ok: Query<&mut GlueDisabled, With<OkButton>>,
|
||||
mut tab_nodes: TabNodes,
|
||||
window: Query<&Window, With<PrimaryWindow>>,
|
||||
) {
|
||||
paint_tabs(&realms, &mut tab_nodes);
|
||||
let s = crate::glue::screen_scale(window.single().ok());
|
||||
let visible = realms.rows();
|
||||
let (mean, stddev) = realms.stats();
|
||||
@@ -602,3 +767,202 @@ pub(super) fn refresh_rows(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `RealmList_UpdateTabs` and `GlueTemplates_UpdateTabs`: one category hides the strip; two to
|
||||
/// eight show that many tabs, each labelled with its category's name. The front tab is
|
||||
/// `Disable()`d on the `ActiveTab` art in its `DisabledFont` (`GlueFontHighlightSmall`, white);
|
||||
/// the others stand on `InActiveTab` in gold, white with the highlight under the cursor.
|
||||
fn paint_tabs(realms: &Realms, nodes: &mut TabNodes) {
|
||||
let TabNodes { tabs, arts, labels } = nodes;
|
||||
let names = realms.tabs();
|
||||
let shown = if names.len() >= 2 { names.len() } else { 0 };
|
||||
let front = realms.category();
|
||||
let mut hovered = None;
|
||||
for (TabOf(ordinal), interaction, mut vis, mut disabled) in tabs.iter_mut() {
|
||||
let want = if *ordinal <= shown {
|
||||
Visibility::Inherited
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
if *vis != want {
|
||||
*vis = want;
|
||||
}
|
||||
let is_front = *ordinal == front;
|
||||
if disabled.0 != is_front {
|
||||
disabled.0 = is_front;
|
||||
}
|
||||
if !is_front && *interaction != Interaction::None {
|
||||
hovered = Some(*ordinal);
|
||||
}
|
||||
}
|
||||
for (TabOf(ordinal), art, mut vis) in arts.iter_mut() {
|
||||
let want = if art.front == (*ordinal == front) {
|
||||
Visibility::Inherited
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
if *vis != want {
|
||||
*vis = want;
|
||||
}
|
||||
}
|
||||
for (TabOf(ordinal), mut text, mut color) in labels.iter_mut() {
|
||||
let Some(name) = names.get(*ordinal - 1) else {
|
||||
continue;
|
||||
};
|
||||
if text.0 != *name {
|
||||
text.0 = name.to_string();
|
||||
}
|
||||
let c = if *ordinal == front || hovered == Some(*ordinal) {
|
||||
load::HIGHLIGHT
|
||||
} else {
|
||||
GOLD
|
||||
};
|
||||
if color.0 != c {
|
||||
color.0 = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::glue::{glue_clicks, GlueClicks};
|
||||
use benilla_protocol::RealmInfo;
|
||||
|
||||
fn realm(name: &str, category: u8) -> RealmInfo {
|
||||
RealmInfo {
|
||||
name: name.into(),
|
||||
address: "127.0.0.1:8085".into(),
|
||||
population: 1.0,
|
||||
characters: 0,
|
||||
realm_type: 0,
|
||||
flags: 0,
|
||||
category,
|
||||
id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The tab strip as [`spawn_tabs`] builds it, both art sets stood in, under the US client's
|
||||
/// categories, with the click and refresh passes the plugin runs.
|
||||
fn tab_app(list: &[(&str, u8)]) -> App {
|
||||
let mut app = App::new();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
app.init_resource::<Time>()
|
||||
.init_resource::<GlueClicks>()
|
||||
.insert_resource(crate::net::RealmChoice(tx))
|
||||
.add_message::<crate::sound::GlueSound>()
|
||||
.insert_resource(Realms {
|
||||
realms: list.iter().map(|&(n, c)| realm(n, c)).collect(),
|
||||
categories: Some(super::super::category::tests::us()),
|
||||
shown: true,
|
||||
..Realms::default()
|
||||
})
|
||||
.add_systems(
|
||||
Update,
|
||||
(glue_clicks, super::super::input::clicks, refresh_list).chain(),
|
||||
);
|
||||
let mut art = GlueArt::default();
|
||||
art.tab_active = Some((Handle::default(), Vec2::new(128.0, 32.0)));
|
||||
art.tab_inactive = Some((Handle::default(), Vec2::new(128.0, 32.0)));
|
||||
app.world_mut()
|
||||
.commands()
|
||||
.spawn(Node::default())
|
||||
.with_children(|b| spawn_tabs(b, &art, &Handle::default(), 1.0));
|
||||
app.world_mut().flush();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
/// Each shown tab's `(ordinal, label, front)`, in ordinal order.
|
||||
fn shown_tabs(app: &mut App) -> Vec<(usize, String, bool)> {
|
||||
let mut labels = app.world_mut().query::<(&TabOf, &Text)>();
|
||||
let labels: Vec<(usize, String)> = labels
|
||||
.iter(app.world())
|
||||
.map(|(t, text)| (t.0, text.0.clone()))
|
||||
.collect();
|
||||
let mut tabs = app
|
||||
.world_mut()
|
||||
.query::<(&TabOf, &Visibility, &GlueDisabled, &RealmAction)>();
|
||||
let mut out: Vec<(usize, String, bool)> = tabs
|
||||
.iter(app.world())
|
||||
.filter(|(_, vis, _, _)| **vis != Visibility::Hidden)
|
||||
.map(|(t, _, disabled, _)| {
|
||||
let label = labels.iter().find(|(o, _)| *o == t.0).unwrap().1.clone();
|
||||
(t.0, label, disabled.0)
|
||||
})
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// The front tab stands on the `ActiveTab` art alone, every other on `InActiveTab`.
|
||||
fn art_matches_front(app: &mut App, front: usize) -> bool {
|
||||
let mut arts = app.world_mut().query::<(&TabOf, &TabArt, &Visibility)>();
|
||||
arts.iter(app.world())
|
||||
.all(|(t, art, vis)| (*vis != Visibility::Hidden) == (art.front == (t.0 == front)))
|
||||
}
|
||||
|
||||
fn click(app: &mut App, ordinal: usize) {
|
||||
let mut tabs = app.world_mut().query::<(Entity, &TabOf, &RealmAction)>();
|
||||
let tab = tabs
|
||||
.iter(app.world())
|
||||
.find(|(_, t, _)| t.0 == ordinal)
|
||||
.unwrap()
|
||||
.0;
|
||||
for interaction in [Interaction::Pressed, Interaction::Hovered] {
|
||||
*app.world_mut().get_mut::<Interaction>(tab).unwrap() = interaction;
|
||||
app.update();
|
||||
}
|
||||
}
|
||||
|
||||
fn rows(app: &App) -> Vec<String> {
|
||||
let realms = app.world().resource::<Realms>();
|
||||
realms
|
||||
.rows()
|
||||
.iter()
|
||||
.map(|&i| realms.realms[i].name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One category holding a realm, a byte-0 realm folded in: `RealmList_UpdateTabs` hides all.
|
||||
#[test]
|
||||
fn one_category_hides_the_whole_strip() {
|
||||
let mut app = tab_app(&[("Alpha", 1), ("Dev", 0)]);
|
||||
assert!(shown_tabs(&mut app).is_empty());
|
||||
assert_eq!(rows(&app), ["Alpha", "Dev"]);
|
||||
}
|
||||
|
||||
/// Two categories: two tabs named from `Cfg_Categories.dbc`, the first in front.
|
||||
#[test]
|
||||
fn two_categories_show_two_named_tabs_the_first_in_front() {
|
||||
let mut app = tab_app(&[("Alpha", 1), ("Down Under", 5)]);
|
||||
assert_eq!(
|
||||
shown_tabs(&mut app),
|
||||
[
|
||||
(1, "United States".to_string(), true),
|
||||
(2, "Oceanic".to_string(), false)
|
||||
]
|
||||
);
|
||||
assert!(art_matches_front(&mut app, 1));
|
||||
assert_eq!(rows(&app), ["Alpha"]);
|
||||
}
|
||||
|
||||
/// A click on the second tab fronts it and lists its realms; the front tab takes no click.
|
||||
#[test]
|
||||
fn a_tab_click_fronts_the_tab_and_relists() {
|
||||
let mut app = tab_app(&[("Alpha", 1), ("Down Under", 5)]);
|
||||
click(&mut app, 2);
|
||||
assert_eq!(rows(&app), ["Down Under"]);
|
||||
assert_eq!(
|
||||
shown_tabs(&mut app),
|
||||
[
|
||||
(1, "United States".to_string(), false),
|
||||
(2, "Oceanic".to_string(), true)
|
||||
]
|
||||
);
|
||||
assert!(art_matches_front(&mut app, 2));
|
||||
|
||||
click(&mut app, 1);
|
||||
assert_eq!(rows(&app), ["Alpha"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//! `Cfg_Categories.dbc`, the realm list's category tabs. The reference builds its category set
|
||||
//! once per process (`0x46e300`, from `CGlueMgr::Initialize`) out of every row whose column 1
|
||||
//! equals the client Region ([`crate::client_region`]), in file order: column 0 is the id a
|
||||
//! realm's wire category byte is matched against, and the row's localized name is the tab's
|
||||
//! label (`GetRealmCategories`, `0x46f238`).
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use benilla_dbc::{FieldType, Schema, SchemaField};
|
||||
|
||||
use crate::dbc::{parse, str_at, u32_at};
|
||||
use crate::Chain;
|
||||
|
||||
const CFG_CATEGORIES: &str = "DBFilesClient\\Cfg_Categories.dbc";
|
||||
|
||||
/// One realm category of the client's Region.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RealmCategory {
|
||||
/// Column 0, compared for equality with a realm's category byte (`0x46e60b`).
|
||||
pub id: u32,
|
||||
/// The enUS `Name_Lang` slot, the column the reference reads on an enUS or enGB client.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// `ID`, `Region`, eight locale names and their flags: the 11 columns × 0x2c bytes the loader
|
||||
/// (`0x5411b0`) gates on.
|
||||
fn cfg_categories_schema() -> Schema {
|
||||
let mut s = Schema::new("Cfg_Categories");
|
||||
s.add_field(SchemaField::new("ID", FieldType::UInt32));
|
||||
s.add_field(SchemaField::new("Region", FieldType::UInt32));
|
||||
for i in 0..8 {
|
||||
s.add_field(SchemaField::new(format!("Name{i}"), FieldType::String));
|
||||
}
|
||||
s.add_field(SchemaField::new("NameFlags", FieldType::UInt32));
|
||||
s
|
||||
}
|
||||
|
||||
/// The categories of `region`, in file order (`0x46e3b1`: a row joins when its column 1 equals
|
||||
/// the Region). A Region no row names yields none.
|
||||
pub fn load_realm_categories(chain: &mut Chain, region: u32) -> Result<Vec<RealmCategory>> {
|
||||
let bytes = chain
|
||||
.read_file(CFG_CATEGORIES)
|
||||
.with_context(|| format!("reading {CFG_CATEGORIES}"))?;
|
||||
let rs = parse(&bytes, cfg_categories_schema(), "Cfg_Categories")?;
|
||||
Ok(rs
|
||||
.records()
|
||||
.iter()
|
||||
.filter(|r| u32_at(r, 1) == Some(region))
|
||||
.filter_map(|r| {
|
||||
Some(RealmCategory {
|
||||
id: u32_at(r, 0)?,
|
||||
// An empty name is pushed as the empty string; only a missing row reads "UNKNOWN".
|
||||
name: str_at(&rs, r, 2).unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rows(region: u32) -> Option<Vec<(u32, String)>> {
|
||||
let data = crate::wow_data_or_skip!(None);
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cats = load_realm_categories(&mut chain, region).expect("Cfg_Categories.dbc");
|
||||
Some(cats.into_iter().map(|c| (c.id, c.name)).collect())
|
||||
}
|
||||
|
||||
/// Region 1 is the US client's: "United States" and "Oceanic", ids 1 and 5, in file order.
|
||||
#[test]
|
||||
fn the_us_region_is_united_states_then_oceanic() {
|
||||
let Some(us) = rows(1) else { return };
|
||||
assert_eq!(
|
||||
us,
|
||||
[(1, "United States".to_string()), (5, "Oceanic".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
/// Region 3 is Europe's four language tabs; the ids are not contiguous, so a category is
|
||||
/// matched by id, never by position.
|
||||
#[test]
|
||||
fn the_european_region_is_four_tabs_with_a_gap_in_the_ids() {
|
||||
let Some(eu) = rows(3) else { return };
|
||||
let ids: Vec<u32> = eu.iter().map(|(id, _)| *id).collect();
|
||||
assert_eq!(ids, [1, 2, 3, 5]);
|
||||
assert_eq!(eu[0].1, "English");
|
||||
assert_eq!(eu[3].1, "Spanish");
|
||||
}
|
||||
|
||||
/// No row carries Region 0, the value a client without a `Wow.ini` Region reads.
|
||||
#[test]
|
||||
fn region_zero_has_no_categories() {
|
||||
let Some(none) = rows(0) else { return };
|
||||
assert!(none.is_empty(), "{none:?}");
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,10 @@ mod gm_ticket_category;
|
||||
pub use gm_ticket_category::{
|
||||
load_gm_ticket_categories, GmTicketCategory, GmTicketCategoryCatalog,
|
||||
};
|
||||
mod cfg_categories;
|
||||
pub use cfg_categories::{load_realm_categories, RealmCategory};
|
||||
mod wow_ini;
|
||||
pub use wow_ini::client_region;
|
||||
mod itembagfamily;
|
||||
pub use itembagfamily::{load_item_bag_families, ItemBagFamilyCatalog};
|
||||
mod itemclass;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! `Wow.ini`, the client's `[WoW Config]` section. The reference loads it through the archive
|
||||
//! chain at startup (`0x40249d`, `SFileLoadFile`), and a stock install ships it in
|
||||
//! `interface.MPQ`; `Region` picks the realm categories ([`crate::load_realm_categories`]).
|
||||
|
||||
use crate::Chain;
|
||||
|
||||
const WOW_INI: &str = "Wow.ini";
|
||||
const SECTION: &str = "WoW Config";
|
||||
|
||||
/// The client Region, `[WoW Config] Region` (`0x4024bd` into `[0x882730]`). 0 when the file is
|
||||
/// absent (`0x4024a4` skips the read, leaving the zero-initialised `.data` word) or has no such
|
||||
/// key (`0x44542c` stores 0 before the lookup).
|
||||
pub fn client_region(chain: &Chain) -> u32 {
|
||||
chain
|
||||
.read(WOW_INI)
|
||||
.ok()
|
||||
.and_then(|bytes| {
|
||||
ini_value(&String::from_utf8_lossy(&bytes), SECTION, "Region").map(ini_int)
|
||||
})
|
||||
.unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
/// The value of `key` in `[section]`. Both names compare case-insensitively (`SStrCmpI` at
|
||||
/// `0x445356` and `0x4453cb`).
|
||||
fn ini_value<'a>(text: &'a str, section: &str, key: &str) -> Option<&'a str> {
|
||||
let mut in_section = false;
|
||||
for line in text.lines().map(str::trim) {
|
||||
if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||
in_section = name.trim().eq_ignore_ascii_case(section);
|
||||
} else if let (true, Some((k, v))) = (in_section, line.split_once('=')) {
|
||||
if k.trim().eq_ignore_ascii_case(key) {
|
||||
return Some(v.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// An integer value as `0x445470` reads one: a quoted run of up to four characters packed
|
||||
/// big-endian, else `SStrToInt` (`0x64ac60`), an optional `-` then decimal digits up to the first
|
||||
/// non-digit, 0 when there are none.
|
||||
fn ini_int(value: &str) -> i32 {
|
||||
if let Some(quoted) = value.strip_prefix('\'') {
|
||||
return quoted
|
||||
.bytes()
|
||||
.take_while(|&b| b != b'\'')
|
||||
.take(4)
|
||||
.fold(0i32, |acc, b| (acc << 8) | i32::from(b));
|
||||
}
|
||||
let (negative, digits) = match value.strip_prefix('-') {
|
||||
Some(rest) => (true, rest),
|
||||
None => (false, value),
|
||||
};
|
||||
let n = digits
|
||||
.bytes()
|
||||
.take_while(u8::is_ascii_digit)
|
||||
.fold(0i32, |acc, d| {
|
||||
acc.wrapping_mul(10).wrapping_add(i32::from(d - b'0'))
|
||||
});
|
||||
if negative {
|
||||
n.wrapping_neg()
|
||||
} else {
|
||||
n
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_region_is_read_from_the_config_section_only() {
|
||||
let ini = "[Other]\r\nRegion=7\r\n[WoW Config]\r\nVersion=1\r\nregion=3\r\n";
|
||||
assert_eq!(ini_value(ini, SECTION, "Region"), Some("3"));
|
||||
assert_eq!(
|
||||
ini_value("[wow config]\nRegion = 1\n", SECTION, "Region"),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(
|
||||
ini_value("[WoW Config]\nVersion=1\n", SECTION, "Region"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_parses_as_the_reference_parses_it() {
|
||||
assert_eq!(ini_int("3"), 3);
|
||||
assert_eq!(ini_int("101"), 101);
|
||||
assert_eq!(ini_int("-2"), -2);
|
||||
assert_eq!(ini_int("12abc"), 12, "digits up to the first non-digit");
|
||||
assert_eq!(ini_int("abc"), 0);
|
||||
assert_eq!(ini_int("'AB'"), 0x4142, "a quoted run packs its bytes");
|
||||
}
|
||||
|
||||
/// The install carries a `Wow.ini` in its archives, and its Region names categories.
|
||||
#[test]
|
||||
fn the_install_names_a_region_with_categories() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
assert!(chain.contains(WOW_INI), "Wow.ini is not in the patch chain");
|
||||
let region = client_region(&chain);
|
||||
assert_ne!(region, 0, "the shipped Wow.ini names a Region");
|
||||
let categories =
|
||||
crate::load_realm_categories(&mut chain, region).expect("Cfg_Categories.dbc");
|
||||
assert!(!categories.is_empty(), "Region {region} has no categories");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -104,7 +104,7 @@
|
||||
- **benilla-adt** (lib.rs): combined_alpha
|
||||
- **benilla-app** (lib.rs): addon_harness area area_poi area_trigger asset_churn aura_visual bindings blob_shadow bowstring camera_shake capture char_create char_select chat_bubble chr_classes cinematic combat_log combat_text console crash creature_anim cursor cvars death debug_panel dev doodad_events entities fishing_line footprints game_plugins game_tip glue glue_strings go_anim go_templates hover_log items loading_screen local_state login minimap name_persist nameplates names net npc_text opaque2d pending_item_ops perf pipe_warm player poi_marker portrait preflight probe_shield query_cache quest_markers raid_marks ranged_flex realm_select realmlist run_mode screen_fade screenshot shaders shutdown smart_rect sound spell swing_refusal target test_support text_filter text_reshape textinput transport tutorial ui_action ui_auction ui_aura ui_bank ui_battlefield ui_battlefield_positions ui_battlefield_score ui_bind_confirm ui_binder ui_cast ui_char ui_chat ui_craft ui_dialog_verbs ui_dressup ui_duel ui_follow ui_gamma ui_gm_ticket ui_gossip ui_guild ui_hide ui_honor ui_inspect ui_instance ui_item_text ui_items ui_layout ui_logout ui_loot ui_loot_roll ui_macro ui_mail ui_merchant ui_mirror ui_models ui_net ui_party ui_pass ui_pet ui_pet_book ui_pet_doll ui_pet_stats ui_petition ui_quest ui_quest_log ui_quest_share ui_reputation ui_saved ui_script ui_session ui_shapeshift ui_social ui_spellbook ui_stable ui_summon ui_tabard ui_talent ui_talent_wipe ui_taxi ui_text ui_tooltip ui_trade ui_tradeskill ui_trainer ui_unit ui_world_map video vplates weapon_trail world_backdrop world_state world_state_ui
|
||||
- **benilla-assets** (lib.rs): adt anim_rng blp column_grid coords gpu_blp m2 materials minimap_grid model spatial_cache terrain tex_filter trace wdt wmo world_assets
|
||||
- **benilla-formats** (lib.rs): anim_data area_poi area_sound area_table area_trigger auction_house bank_bag_slot_prices camera_shakes chain characters chat_channels chr_classes cinematics creature_families creature_sound creature_types creatures dbc death_thud durability elevators emit_timing emote_text emotes environmental_damage exhaustion factions footsteps game_tips gameobjects garble gm_ticket_category ground_effects install item_random_properties item_sounds item_visuals itembagfamily itemclass items itemsets itemsubclass languages light liquid loading_screen lock lock_type macro_icons maps material minimap_translate models npc_greeting page_text_material particles pet_stats quest_headers quest_info race_pvp_team race_sound ribbons server_messages sheathe skill_lines sound_entries sound_provider sound_water spell_focus spell_mechanic spell_visual spells stable_slot_prices stationery talents taxi taxi_nodes taxi_path terrain text_filter_lists tga transport_period transports unit_blood value_track vocal_ui_sounds wdl weapon_impact weapon_swing wmo_area world_map_area world_map_continent world_map_overlay world_state_ui zone_map
|
||||
- **benilla-formats** (lib.rs): anim_data area_poi area_sound area_table area_trigger auction_house bank_bag_slot_prices camera_shakes cfg_categories chain characters chat_channels chr_classes cinematics creature_families creature_sound creature_types creatures dbc death_thud durability elevators emit_timing emote_text emotes environmental_damage exhaustion factions footsteps game_tips gameobjects garble gm_ticket_category ground_effects install item_random_properties item_sounds item_visuals itembagfamily itemclass items itemsets itemsubclass languages light liquid loading_screen lock lock_type macro_icons maps material minimap_translate models npc_greeting page_text_material particles pet_stats quest_headers quest_info race_pvp_team race_sound ribbons server_messages sheathe skill_lines sound_entries sound_provider sound_water spell_focus spell_mechanic spell_visual spells stable_slot_prices stationery talents taxi taxi_nodes taxi_path terrain text_filter_lists tga transport_period transports unit_blood value_track vocal_ui_sounds wdl weapon_impact weapon_swing wmo_area world_map_area world_map_continent world_map_overlay world_state_ui wow_ini zone_map
|
||||
- **benilla-m2** (lib.rs): error model skin tests track
|
||||
- **benilla-mpq** (lib.rs): crypto
|
||||
- **benilla-protocol** (lib.rs): auth events guid messages wire world
|
||||
|
||||
Reference in New Issue
Block a user