diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index e27a53a9..1f99894b 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -22,4 +22,4 @@ body: attributes: label: Build and setup description: The `benilla build` line from the start of the terminal output, your OS, and the server you play on. - placeholder: "benilla build 64cca13 · 2026-09-24 · release, macOS 15, vmangos" + placeholder: "benilla build 0.2.0 · 64cca13 · 2026-09-24 · release, macOS 15, vmangos" diff --git a/Cargo.lock b/Cargo.lock index ab0e83e3..aba3867d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -488,7 +488,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "benilla" -version = "0.1.0" +version = "0.2.0" dependencies = [ "benilla-app", "benilla-buildstamp", @@ -496,14 +496,14 @@ dependencies = [ [[package]] name = "benilla-adt" -version = "0.1.0" +version = "0.2.0" dependencies = [ "benilla-bytes", ] [[package]] name = "benilla-app" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "arboard", @@ -512,6 +512,7 @@ dependencies = [ "benilla-assets", "benilla-formats", "benilla-protocol", + "benilla-srp", "benilla-ui", "benilla-world", "bevy", @@ -528,6 +529,7 @@ dependencies = [ "kira", "libc", "mint", + "num-bigint", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-core-audio", @@ -538,6 +540,7 @@ dependencies = [ "regex", "rtrb", "serde", + "sha1", "smithay-clipboard", "symphonia", "toml", @@ -547,7 +550,7 @@ dependencies = [ [[package]] name = "benilla-assets" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "benilla-formats", @@ -558,7 +561,7 @@ dependencies = [ [[package]] name = "benilla-blp" -version = "0.1.0" +version = "0.2.0" dependencies = [ "benilla-bytes", "texpresso", @@ -566,22 +569,22 @@ dependencies = [ [[package]] name = "benilla-buildstamp" -version = "0.1.0" +version = "0.2.0" [[package]] name = "benilla-bytes" -version = "0.1.0" +version = "0.2.0" [[package]] name = "benilla-dbc" -version = "0.1.0" +version = "0.2.0" dependencies = [ "benilla-bytes", ] [[package]] name = "benilla-formats" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "benilla-adt", @@ -599,14 +602,14 @@ dependencies = [ [[package]] name = "benilla-m2" -version = "0.1.0" +version = "0.2.0" dependencies = [ "benilla-bytes", ] [[package]] name = "benilla-mpq" -version = "0.1.0" +version = "0.2.0" dependencies = [ "benilla-bytes", "flate2", @@ -614,7 +617,7 @@ dependencies = [ [[package]] name = "benilla-protocol" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "benilla-srp", @@ -628,7 +631,7 @@ dependencies = [ [[package]] name = "benilla-srp" -version = "0.1.0" +version = "0.2.0" dependencies = [ "num-bigint", "rand 0.8.6", @@ -637,7 +640,7 @@ dependencies = [ [[package]] name = "benilla-ui" -version = "0.1.0" +version = "0.2.0" dependencies = [ "mlua", "roxmltree", @@ -645,7 +648,7 @@ dependencies = [ [[package]] name = "benilla-visual" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "image", @@ -653,18 +656,18 @@ dependencies = [ [[package]] name = "benilla-wdt" -version = "0.1.0" +version = "0.2.0" [[package]] name = "benilla-wmo" -version = "0.1.0" +version = "0.2.0" dependencies = [ "benilla-bytes", ] [[package]] name = "benilla-world" -version = "0.1.0" +version = "0.2.0" dependencies = [ "avian3d", "benilla-assets", @@ -680,7 +683,7 @@ dependencies = [ [[package]] name = "benilla-worldview" -version = "0.1.0" +version = "0.2.0" dependencies = [ "benilla-buildstamp", "benilla-world", diff --git a/Cargo.toml b/Cargo.toml index 9ff6207c..d747a754 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package] -version = "0.1.0" +version = "0.2.0" edition = "2021" license = "MIT OR Apache-2.0" authors = ["sam "] diff --git a/README.md b/README.md index 9773abf6..46d22c13 100644 --- a/README.md +++ b/README.md @@ -72,11 +72,22 @@ benilla builds and runs on macOS, Linux and Windows. You need: WOW_DATA=/path/to/WoW/Data cargo run --release -p benilla ``` +On Windows, in PowerShell: + +```powershell +$env:WOW_DATA="C:\path\to\WoW\Data"; cargo run --release -p benilla +``` + +Each release is a tag on the [Releases page](https://github.com/samwhosung/benilla/releases): +`git checkout ` first runs that release, and `main` is the development tip. + `WOW_DATA` names the install's `Data` folder; a link to the install named `WoW` at the repo root -does the same. The server defaults to `localhost:3724`, the stock auth port. Point `WOW_HOST` at -another (`WOW_HOST=play.example.com`, or `play.example.com:5000` for a remapped port), or set it -from the Realmlist button on the login screen, which remembers it. Credentials go in at the login -screen, or set `WOW_USER` and `WOW_PASS` to skip it. +does the same (`ln -s /path/to/WoW WoW`, or on Windows a junction, which needs no admin rights: +`New-Item -ItemType Junction -Path WoW -Target C:\path\to\WoW`). The server defaults to +`localhost:3724`, the stock auth port. Point `WOW_HOST` at another (`WOW_HOST=play.example.com`, or +`play.example.com:5000` for a remapped port), or set it from the Realmlist button on the login +screen, which remembers it. Credentials go in at the login screen, or set `WOW_USER` and `WOW_PASS` +to skip it. Settings, screenshots and addons live in `benilla-config/` at the repo root: a 1.12 addon goes in `benilla-config/AddOns/`. [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) has the rest, from the diff --git a/crates/benilla-app/Cargo.toml b/crates/benilla-app/Cargo.toml index c6b65cd4..80ed5285 100644 --- a/crates/benilla-app/Cargo.toml +++ b/crates/benilla-app/Cargo.toml @@ -96,3 +96,9 @@ cpal = "0.17" # Windows CPU clocks for `perf::clock`. [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] } + +# The login cycle's fake realmd runs the server half of SRP6 (`net::io::cycle_tests`). +[dev-dependencies] +benilla-srp = { path = "../benilla-srp" } +num-bigint = "0.4.6" +sha1.workspace = true diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 559beb70..6fd190d5 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -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, } @@ -684,6 +685,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); } } diff --git a/crates/benilla-app/src/capture/realm_list.rs b/crates/benilla-app/src/capture/realm_list.rs new file mode 100644 index 00000000..270314f4 --- /dev/null +++ b/crates/benilla-app/src/capture/realm_list.rs @@ -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 { + 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, + mut step: Local, + mut lists: MessageWriter, + mut realms: ResMut, + choice: Res, +) { + 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; +} diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index 477899e4..455ca964 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -171,10 +171,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", @@ -186,6 +189,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 diff --git a/crates/benilla-app/src/crash.rs b/crates/benilla-app/src/crash.rs index d1c26422..eb17c7ff 100644 --- a/crates/benilla-app/src/crash.rs +++ b/crates/benilla-app/src/crash.rs @@ -133,6 +133,8 @@ mod tests { use super::*; const BUILD: BuildId = BuildId { + version: "0.2.0", + describe: "v0.2.0-3-g0123456", sha: "0123456789abcdef0123456789abcdef01234567", short: "0123456", date: "2026-09-16", @@ -154,7 +156,7 @@ mod tests { }); for needle in [ "sha 0123456789abcdef0123456789abcdef01234567", - "0123456 · 2026-09-16 · debug", + "0.2.0+3 · 0123456 · 2026-09-16 · debug", "12.3 s after launch", "thread: main", "at: crates/benilla-app/src/net/apply.rs:100:5", diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 9e5e95be..34565223 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -234,14 +234,7 @@ pub(crate) const REGISTERED: &[Registered] = &[ // `autoSelfCast` (register site `0x6e731d`, record `[0xceac34]`, read at `0x6e53d7`; `0x870dc0` // is its name string): a friendly cast that binds nothing falls back to the caster. // `TOGGLEAUTOSELFCAST` toggles it. - deviates( - "autoSelfCast", - "1", - "0", - "with it off, an unbindable friendly cast falls into the reference's \ - targeting-cursor machine, which is unmodeled — leaving no path at all. Flip when that \ - machine lands", - ), + same("autoSelfCast", "0"), // The five saved camera views and the live index, at the reference's names and default strings; // owned by [`crate::player::camera_view`]. Registered so a `SaveView` persists. same( @@ -2370,7 +2363,6 @@ mod tests { names, vec![ "SoundReverb", - "autoSelfCast", "frillDensity", "gxApi", "gxColorBits", diff --git a/crates/benilla-app/src/debug_panel/inspect.rs b/crates/benilla-app/src/debug_panel/inspect.rs index 5cd74b11..e49bea0b 100644 --- a/crates/benilla-app/src/debug_panel/inspect.rs +++ b/crates/benilla-app/src/debug_panel/inspect.rs @@ -443,8 +443,8 @@ pub(super) fn inspect_ui( 3 => "neutral", 4 => "friendly", 5 => "honored", - 6 => "revered", - _ => "exalted", + // `0x606439` caps the rank at 6: Exalted reads here too. + _ => "revered+", }; // A faction with a reputation slot reacts by our standing (`0x605fc0` -> `0x4d63a0`), // before any template comparison; everything else by the template comparator. diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 6ade0e73..75f6478a 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -695,7 +695,12 @@ pub(crate) mod schedule_tests { /// `Player`: it reads `control_lost`, and they write only `settling` and `world_stale`; /// - `ui_quest::lines::feed_quest_lines` against `ui_items::feed_item_stats` and /// `ui_tooltip::feed_spell_tooltips` over `Items` and the VM: it only reads templates, which - /// those two do not write, and a chat line commutes with their pushes. + /// those two do not write, and a chat line commutes with their pushes; + /// - `spell::targeting::drain_spell_target_unit` against the lone `.after(UiInput)` cast + /// drains over the `CastLadder`, and the party, pet-book and chat drains over the unit-token + /// resolver: the class `drop_item_on_unit` and the world click's legs already carry, as none + /// of those drains orders against the target chain. A cast press and a unit-frame bind in one + /// frame take either order until the cast drains share a set that does. /// /// Raising the ceiling is a claim that a new undeclared order is acceptable: make it with the /// reason read off the dump, or declare the order (`.after`, a set, a `chain`). A resource diff --git a/crates/benilla-app/src/glue/art.rs b/crates/benilla-app/src/glue/art.rs index 8db69f07..8010d921 100644 --- a/crates/benilla-app/src/glue/art.rs +++ b/crates/benilla-app/src/glue/art.rs @@ -144,6 +144,10 @@ pub(crate) struct GlueArt { pub(crate) column_tabs: Option<(Handle, Vec2)>, pub(crate) tab_highlight: Option>, pub(crate) sort_arrow: Option<(Handle, 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, Vec2)>, + pub(crate) tab_inactive: Option<(Handle, 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 {} \ diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index e4be9117..f669ffe1 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -66,7 +66,7 @@ impl Plugin for NetPlugin { crate::query_cache::register::(app); crate::query_cache::register::(app); crate::query_cache::register::(app); - let handles = io::spawn_net(io::NetConfig::from_env(), self.connect); + let handles = io::spawn_net(self.connect); if !self.connect { app.insert_resource(NetOffline); } @@ -884,6 +884,12 @@ pub(crate) enum ClientCommand { spell_id: u32, target: Option, }, + /// `CMSG_CAST_SPELL` at a corpse: its bit and packed guid, a resurrection on a released player. + CastSpellCorpse { + spell_id: u32, + target: benilla_protocol::messages::CorpseTarget, + corpse_guid: u64, + }, /// `CMSG_CAST_SPELL` with `TARGET_FLAG_DEST_LOCATION`; `dest` in WoW coordinates. CastSpellAtDest { spell_id: u32, diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index b34539c3..01290709 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; -use anyhow::{anyhow, bail, Result}; +use anyhow::{anyhow, Result}; use benilla_assets::LockRecover; use benilla_protocol::{ host_port, messages, AuthReject, CharAction, LoginStage, Poll, SessionEnd, SessionEvent, @@ -145,21 +145,6 @@ impl PingClock { /// Cap on send warnings per connection, so a movement stream during an outage cannot flood the log. const SEND_WARN_CAP: u32 = 8; -/// The per-process connection parameters. Credentials and address ride each [`LoginRequest`]; -/// `$WOW_HOST` is read by `Realmlist::default()`. -pub(super) struct NetConfig { - /// `WOW_CHAR`: here only the name of the starter character on an empty account. - character: Option, -} - -impl NetConfig { - pub(super) fn from_env() -> Self { - NetConfig { - character: std::env::var("WOW_CHAR").ok(), - } - } -} - /// What one wake-up at the character park asked for, so every jump out is made outside `select!`. enum Parked { /// `CMSG_PLAYER_LOGIN` with this guid. @@ -204,7 +189,7 @@ pub(super) struct NetHandles { } /// Spawns the read thread with its park and cycle loop, and the one long-lived write thread. -pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles { +pub(super) fn spawn_net(connect: bool) -> NetHandles { let (events_tx, events_rx) = crossbeam_channel::unbounded(); let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let (pick_tx, pick_rx) = crossbeam_channel::unbounded::(); @@ -247,7 +232,6 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles { // writer, since the keepalive can still fire on the stale one until then. read_clock.lock_recover().clear(); match run( - &cfg, &events_tx, &writer_tx, &parks, @@ -310,7 +294,6 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles { /// dies, the character logs out or the app exits. A pre-roster failure emits /// [`SessionEvent::LoginFailed`] and re-parks; any retry is the app's. fn run( - cfg: &NetConfig, events_tx: &Sender, writer_tx: &Sender, parks: &Parks, @@ -501,31 +484,10 @@ fn run( return Ok(Cycle::Repark); } - // The roster, creating a starter character on an empty account so `PLAYER_LOGIN` has a - // target. Failures here are still pre-roster. - let mut characters = match (|| -> Result> { - let mut characters = session.char_enum()?; - if characters.is_empty() { - let name = cfg.character.as_deref().unwrap_or("One"); - let starter = messages::CharCreateReq { - name: name.to_string(), - race: messages::RACE_HUMAN, - class: messages::CLASS_WARRIOR, - gender: messages::GENDER_MALE, - skin: 0, - face: 0, - hair_style: 0, - hair_color: 0, - facial_hair: 0, - }; - match session.create_character(&starter)? { - messages::CHAR_CREATE_SUCCESS | messages::CHAR_CREATE_NAME_IN_USE => {} - other => bail!("character creation failed: result {other:#x}"), - } - characters = session.char_enum()?; - } - Ok(characters) - })() { + // The roster as the account holds it, an empty one included: the reference builds + // `CMSG_CHAR_CREATE` in one place (`0x5aac50`), reached only from the create screen. + // Failures here are still pre-roster. + let mut characters = match session.char_enum() { Ok(c) => c, Err(e) => { if canceled() { @@ -1067,6 +1029,11 @@ fn writer_loop( count, } => w.destroy_item(bag_index, slot, count), ClientCommand::CastSpell { spell_id, target } => w.cast_spell(spell_id, target), + ClientCommand::CastSpellCorpse { + spell_id, + target, + corpse_guid, + } => w.cast_spell_corpse(spell_id, target, corpse_guid), ClientCommand::CastSpellAtDest { spell_id, dest } => { w.cast_spell_at_dest(spell_id, dest) } @@ -1530,6 +1497,9 @@ fn writer_loop( } } +#[cfg(test)] +mod cycle_tests; + #[cfg(test)] mod rtt_tests { use super::{PingClock, RTT_RING}; diff --git a/crates/benilla-app/src/net/io/cycle_tests.rs b/crates/benilla-app/src/net/io/cycle_tests.rs new file mode 100644 index 00000000..c30a155f --- /dev/null +++ b/crates/benilla-app/src/net/io/cycle_tests.rs @@ -0,0 +1,274 @@ +//! The login cycle, [`super::run`], against a fake realmd and a fake world server on loopback: the +//! real logon, realm park, world handshake and roster, driven through the parks as the app does. + +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::AtomicU64; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use benilla_protocol::messages::{self, opcode}; +use benilla_protocol::{SessionEvent, WorldWriter}; +use benilla_srp::vanilla_header::HeaderCrypto; +use benilla_srp::{ + password_verifier, NormalizedString, GENERATOR, LARGE_SAFE_PRIME_LITTLE_ENDIAN, + SESSION_KEY_LENGTH, +}; +use crossbeam_channel::{Receiver, Sender}; +use num_bigint::BigUint; +use sha1::{Digest, Sha1}; + +use super::{run, Cycle, LoginRequest, Parks, PingClock}; +use crate::net::{CharRequest, RealmRequest}; + +const USER: &str = "empty"; +const PASS: &str = "secret"; +const REALM: &str = "Fake"; +const SALT: [u8; 32] = [0x5a; 32]; +/// Every wait in the test: long enough for a loaded machine, short of a hung gate. +const WAIT: Duration = Duration::from_secs(20); + +fn sha1(parts: &[&[u8]]) -> [u8; 20] { + let mut h = Sha1::new(); + for p in parts { + h.update(p); + } + h.finalize().into() +} + +fn le32(v: &BigUint) -> [u8; 32] { + let raw = v.to_bytes_le(); + let mut out = [0u8; 32]; + out[..raw.len()].copy_from_slice(&raw); + out +} + +/// `SHA1_Interleave` over all 32 bytes of `S`, as vmangos's `SRP6::HashSessionKey` does. +fn interleave(s: &[u8; 32]) -> [u8; SESSION_KEY_LENGTH] { + let even: Vec = s.iter().step_by(2).copied().collect(); + let odd: Vec = s.iter().skip(1).step_by(2).copied().collect(); + let (g, h) = (sha1(&[&even]), sha1(&[&odd])); + let mut out = [0u8; SESSION_KEY_LENGTH]; + for (i, (gi, hi)) in g.iter().zip(&h).enumerate() { + out[i * 2] = *gi; + out[i * 2 + 1] = *hi; + } + out +} + +/// A realmd that logs [`USER`] in with the server half of SRP6 and lists one realm, [`REALM`] at +/// `world`. The session key goes to the fake world server, which needs it for the header cipher. +fn fake_realmd(world: String, keys: Sender<[u8; SESSION_KEY_LENGTH]>) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + thread::spawn(move || { + let (mut s, _) = listener.accept().unwrap(); + let n = BigUint::from_bytes_le(&LARGE_SAFE_PRIME_LITTLE_ENDIAN); + let g = BigUint::from(GENERATOR); + let user = NormalizedString::new(USER).unwrap(); + let pass = NormalizedString::new(PASS).unwrap(); + let v = BigUint::from_bytes_le(&password_verifier(&user, &pass, &SALT)); + + // CMD_AUTH_LOGON_CHALLENGE_Client: opcode, protocol, u16 size, then the body. + let mut head = [0u8; 4]; + s.read_exact(&mut head).unwrap(); + let mut body = vec![0u8; usize::from(u16::from_le_bytes([head[2], head[3]]))]; + s.read_exact(&mut body).unwrap(); + + // B = k·v + g^b, with k = 3; `b` walks until `B`'s top byte is non-zero, since the client + // redials on a zero one. + let (b, b_pub) = (1u32..) + .map(|i| { + let b = BigUint::from_bytes_le(&[0x3c; 32]) + i; + let b_pub = le32(&((BigUint::from(3u8) * &v + g.modpow(&b, &n)) % &n)); + (b, b_pub) + }) + .find(|(_, b_pub)| b_pub[31] != 0) + .unwrap(); + let mut reply = vec![0x00, 0x00, 0x00]; + reply.extend_from_slice(&b_pub); + reply.extend_from_slice(&[1, GENERATOR, 32]); + reply.extend_from_slice(&LARGE_SAFE_PRIME_LITTLE_ENDIAN); + reply.extend_from_slice(&SALT); + reply.extend_from_slice(&[0u8; 16]); // crc_salt + reply.push(0); // security_flag + s.write_all(&reply).unwrap(); + + // CMD_AUTH_LOGON_PROOF_Client: opcode, A, M1, crc_hash, key count, security flag. + let mut proof = [0u8; 75]; + s.read_exact(&mut proof).unwrap(); + let (a_pub, m1) = (&proof[1..33], &proof[33..53]); + let u = BigUint::from_bytes_le(&sha1(&[a_pub, &b_pub])); + let a = BigUint::from_bytes_le(a_pub); + let big_s = (a * v.modpow(&u, &n) % &n).modpow(&b, &n); + let key = interleave(&le32(&big_s)); + let mut reply = vec![0x01, 0x00]; + reply.extend_from_slice(&sha1(&[a_pub, m1, &key])); + reply.extend_from_slice(&0u32.to_le_bytes()); // hardware_survey_id + s.write_all(&reply).unwrap(); + keys.send(key).unwrap(); + + // CMD_REALM_LIST_Client, answered with the one realm. + s.read_exact(&mut [0u8; 5]).unwrap(); + let mut realm = Vec::new(); + realm.extend_from_slice(&0u32.to_le_bytes()); // realm_type + realm.push(0); // flags + realm.extend_from_slice(format!("{REALM}\0{world}\0").as_bytes()); + realm.extend_from_slice(&0f32.to_le_bytes()); // population + realm.extend_from_slice(&[0, 1, 1]); // characters, category, id + let mut list = vec![0x10]; + list.extend_from_slice(&((4 + 1 + realm.len() + 2) as u16).to_le_bytes()); + list.extend_from_slice(&0u32.to_le_bytes()); + list.push(1); + list.extend_from_slice(&realm); + list.extend_from_slice(&0u16.to_le_bytes()); + s.write_all(&list).unwrap(); + + // Held until the cycle drops the logon, as realmd keeps it for the list refresh. + let _ = s.read(&mut [0u8; 1]); + }); + addr +} + +/// A server packet: `u16` BE size counting the opcode, `u16` LE opcode, the body. +fn send(s: &mut TcpStream, crypto: Option<&mut HeaderCrypto>, op: u16, body: &[u8]) { + let size = ((body.len() + 2) as u16).to_be_bytes(); + let op = op.to_le_bytes(); + let mut header = [size[0], size[1], op[0], op[1]]; + if let Some(c) = crypto { + c.encrypter().encrypt(&mut header); + } + s.write_all(&header).unwrap(); + s.write_all(body).unwrap(); +} + +/// A client packet's opcode, reading past its body: `u16` BE size counting the 4-byte opcode. +fn recv(s: &mut TcpStream, crypto: Option<&mut HeaderCrypto>) -> Option { + let mut header = [0u8; 6]; + s.read_exact(&mut header).ok()?; + if let Some(c) = crypto { + c.decrypter().decrypt(&mut header); + } + let size = usize::from(u16::from_be_bytes([header[0], header[1]])); + s.read_exact(&mut vec![0u8; size.saturating_sub(4)]).ok()?; + Some(u16::from_le_bytes([header[2], header[3]])) +} + +/// A world server whose account has no characters: it admits the session under realmd's key, +/// answers every `CMSG_CHAR_ENUM` with an empty roster and a `CMSG_CHAR_CREATE` with success, and +/// logs every opcode the client sends. +fn fake_world( + listener: TcpListener, + keys: Receiver<[u8; SESSION_KEY_LENGTH]>, +) -> Arc>> { + let sent = Arc::new(Mutex::new(Vec::new())); + let log = Arc::clone(&sent); + thread::spawn(move || { + let (mut s, _) = listener.accept().unwrap(); + send( + &mut s, + None, + opcode::SMSG_AUTH_CHALLENGE, + &0xDEAD_BEEFu32.to_le_bytes(), + ); + assert_eq!(recv(&mut s, None), Some(opcode::CMSG_AUTH_SESSION)); + let mut crypto = HeaderCrypto::from_session_key(keys.recv_timeout(WAIT).unwrap()); + send( + &mut s, + Some(&mut crypto), + opcode::SMSG_AUTH_RESPONSE, + &[messages::AUTH_OK], + ); + while let Some(op) = recv(&mut s, Some(&mut crypto)) { + log.lock().unwrap().push(op); + match op { + opcode::CMSG_CHAR_ENUM => { + send(&mut s, Some(&mut crypto), opcode::SMSG_CHAR_ENUM, &[0]); + } + opcode::CMSG_CHAR_CREATE => send( + &mut s, + Some(&mut crypto), + opcode::SMSG_CHAR_CREATE, + &[messages::CHAR_CREATE_SUCCESS], + ), + _ => {} + } + } + }); + sent +} + +/// The next event, skipping the stage announcements; a login failure fails the test by name. +fn next_event(events: &Receiver) -> SessionEvent { + loop { + match events.recv_timeout(WAIT).expect("the cycle went quiet") { + SessionEvent::LoginStage { .. } => continue, + SessionEvent::LoginFailed { reason, .. } => panic!("the login failed: {reason}"), + ev => return ev, + } + } +} + +/// The reference builds `CMSG_CHAR_CREATE` in one place (`0x5aac50`), reached only from the glue's +/// `CreateCharacter`, which the stock glue calls only from the create screen's Okay; so a login to +/// an account with no characters parks at select on the empty list and creates nothing. +#[test] +fn an_empty_account_parks_at_select_without_creating_a_character() { + let world_listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let world = world_listener.local_addr().unwrap().to_string(); + let (keys_tx, keys_rx) = crossbeam_channel::unbounded(); + let sent = fake_world(world_listener, keys_rx); + let realmd = fake_realmd(world, keys_tx); + + let (events_tx, events) = crossbeam_channel::unbounded(); + let (writer_tx, _writer_rx) = crossbeam_channel::unbounded::(); + let (login_tx, login_rx) = crossbeam_channel::unbounded(); + let (realm_tx, realm_rx) = crossbeam_channel::unbounded(); + let (pick_tx, pick_rx) = crossbeam_channel::unbounded(); + let cycle = thread::spawn(move || { + let parks = Parks { + login_rx, + realm_rx, + pick_rx, + }; + let ended = run( + &events_tx, + &writer_tx, + &parks, + &AtomicU64::new(0), + &Mutex::new(PingClock::default()), + &mut Default::default(), + ); + matches!(ended, Ok(Cycle::Repark)) + }); + + login_tx + .send(LoginRequest { + user: USER.into(), + pass: PASS.into(), + host: realmd, + generation: 0, + }) + .unwrap(); + match next_event(&events) { + SessionEvent::RealmList { realms } => assert_eq!(realms[0].name, REALM), + other => panic!("expected the realm list, got {other:?}"), + } + realm_tx.send(RealmRequest::Enter(REALM.into())).unwrap(); + match next_event(&events) { + SessionEvent::CharacterList { characters, .. } => assert!( + characters.is_empty(), + "the roster at select must be the account's, empty: {characters:?}" + ), + other => panic!("expected the roster at select, got {other:?}"), + } + // Select's Back ends the cycle, so the socket is done and its log complete. + pick_tx.send(CharRequest::Abandon).unwrap(); + assert!(cycle.join().unwrap(), "Back returns to the login park"); + let sent = sent.lock().unwrap().clone(); + assert!( + !sent.contains(&opcode::CMSG_CHAR_CREATE), + "the login sent CMSG_CHAR_CREATE on its own: {sent:#06x?}" + ); +} diff --git a/crates/benilla-app/src/player/camera_channel.rs b/crates/benilla-app/src/player/camera_channel.rs index 80425aff..bbc620ee 100644 --- a/crates/benilla-app/src/player/camera_channel.rs +++ b/crates/benilla-app/src/player/camera_channel.rs @@ -19,7 +19,7 @@ /// four channels and the `0x5107f0`/`0x5106f0` displacement predicates. pub(super) const CHANNEL_EPS: f32 = 0.001; -/// One `arm` request: the armer's four arguments plus the ground channel's duration clamp. +/// One `arm` request: the armer's four arguments plus the ground channel's duration bound. #[derive(Clone, Copy, Debug)] pub(super) struct Arm { /// In the live value's units. @@ -29,9 +29,26 @@ pub(super) struct Arm { pub(super) factor: f32, /// Live-value units per second: `cvar.to_radians()` for the angular channels, yd/s for height. pub(super) rate: f32, - /// The ground channel's bound: after the arm, `0x50dd29` clamps `[cam+0x1ac]` between - /// `Factor × cameraTerrainTiltTimeMin` and `Factor × cameraTerrainTiltTimeMax`. - pub(super) duration: Option<(f32, f32)>, + /// The ground channel's `(cameraTerrainTiltTimeMin, cameraTerrainTiltTimeMax)`, seconds, which + /// [`tilt_bound`] scales by `factor` to bound the duration. + pub(super) bound: Option<(f32, f32)>, +} + +/// The ground channel's duration bound, `0x50dd29`-`0x50dd83`, with `lo = TimeMin × factor` and +/// `hi = TimeMax × factor`: below `lo` takes `lo`, else at or above `hi`, or NaN, takes `hi`, else +/// the duration stands. Nothing orders the pair, so an inverted one is an odd duration, not a fault. +fn tilt_bound(duration: f32, (time_min, time_max): (f32, f32), factor: f32) -> f32 { + // `lo` is compared unrounded, live on the x87 stack (`0x50dd4c`); `hi` is stored as an f32 + // first (`0x50dd40`). Only an inverted pair can see the difference. + let lo = f64::from(time_min) * f64::from(factor); + let hi = time_max * factor; + if f64::from(duration) < lo { + lo as f32 + } else if duration < hi { + duration + } else { + hi + } } impl Arm { @@ -42,7 +59,7 @@ impl Arm { delay: 0.0, factor: 1.0, rate, - duration: None, + bound: None, } } } @@ -122,11 +139,16 @@ impl SmoothChannel { self.live += two_pi; } } - if let Some(f) = self.flight { + if let Some(f) = self.flight.as_mut() { if (self.to - arm.target).abs() < CHANNEL_EPS && (f.memo.0 - arm.delay).abs() < CHANNEL_EPS && (f.memo.1 - arm.factor).abs() < CHANNEL_EPS { + // The bound runs on this return too (`0x512555` returns 1, `0x50dd21` tests it), + // so it bounds the duration in flight again every frame. + if let Some(bound) = arm.bound { + f.duration = tilt_bound(f.duration, bound, arm.factor); + } return Armed::Already; } } @@ -138,15 +160,15 @@ impl SmoothChannel { return Armed::AtRest; } let mut duration = gap / arm.rate.max(f32::EPSILON) * arm.factor; - if let Some((lo, hi)) = arm.duration { - duration = duration.clamp(lo, hi); + if let Some(bound) = arm.bound { + duration = tilt_bound(duration, bound, arm.factor); } self.from = self.live; self.to = arm.target; self.flight = Some(Flight { elapsed: 0.0, delay: arm.delay, - duration: duration.max(f32::EPSILON), + duration, memo: (arm.delay, arm.factor), }); Armed::Started @@ -158,7 +180,10 @@ impl SmoothChannel { f.elapsed += dt; let t = f.elapsed - f.delay; if t >= 0.0 { - let s = t / f.duration; + // The floor lands a zero or NaN duration on the target. The reference's ground step + // divides by it raw (`0x50f45e`) and eases whenever C0 is set (`0x50f46f`), which a + // NaN `s` sets, so a NaN duration writes NaN into its live value. + let s = t / f.duration.max(f32::EPSILON); if s >= 1.0 { self.live = self.to; self.flight = None; @@ -230,7 +255,7 @@ mod tests { delay: 0.0, factor, rate, - duration: None + bound: None }), Armed::Started ); @@ -302,7 +327,7 @@ mod tests { delay: 0.5, factor: 1.0, rate: 1.0, - duration: None, + bound: None, }); let frames = run(&mut c, 0.45); assert!(frames.iter().all(|v| *v == 0.0), "held for the delay"); @@ -318,7 +343,7 @@ mod tests { delay: 0.0, factor: 1.0, rate: 7.5, - duration: Some((0.1, 0.5)), + bound: Some((0.1, 0.5)), }); let frames = run(&mut c, 0.6); let arrived = frames @@ -330,4 +355,145 @@ mod tests { "|Δ|/rate would be 13.3 s; the bound caps it at 0.5" ); } + + // The expectations below are `0x50dd29`-`0x50dd83` read directly: `lo = TimeMin × factor`, + // `hi = TimeMax × factor`; below `lo` → `lo`, else at or above `hi` or NaN → `hi`, else as is. + + #[test] + fn an_ordered_tilt_bound_keeps_its_clamp() { + for (duration, factor, expected) in [ + (1.0, 1.0, 3.0), + (5.0, 1.0, 5.0), + (10.0, 1.0, 10.0), + (20.0, 1.0, 10.0), + (1.0, 2.0, 6.0), + (10.0, 2.0, 10.0), + (25.0, 2.0, 20.0), + ] { + assert_eq!(tilt_bound(duration, (3.0, 10.0), factor), expected); + } + } + + #[test] + fn an_inverted_tilt_bound_gives_the_reference_duration() { + // `cameraTerrainTiltTimeMin 20` over `cameraTerrainTiltTimeMax 1`: at factor 2, lo 40, hi 2. + for (duration, expected) in [ + (1.0, 40.0), // below both: under lo + (10.0, 40.0), // between hi and lo: still under lo + (40.0, 2.0), // at lo: not under it, and at or above hi + (100.0, 2.0), // above both + ] { + assert_eq!(tilt_bound(duration, (20.0, 1.0), 2.0), expected); + } + assert_eq!(tilt_bound(0.5, (20.0, 1.0), 1.0), 20.0); + assert_eq!(tilt_bound(30.0, (20.0, 1.0), 1.0), 1.0); + } + + #[test] + fn a_nan_tilt_lo_is_no_floor() { + // No duration is under a NaN lo. + assert_eq!(tilt_bound(1.0, (f32::NAN, 10.0), 1.0), 1.0); + assert_eq!(tilt_bound(20.0, (f32::NAN, 10.0), 1.0), 10.0); + } + + #[test] + fn a_nan_tilt_hi_is_the_answer_above_lo() { + assert_eq!(tilt_bound(1.0, (3.0, f32::NAN), 1.0), 3.0); + assert!(tilt_bound(5.0, (3.0, f32::NAN), 1.0).is_nan()); + // A NaN factor makes both bounds NaN, so hi. + assert!(tilt_bound(5.0, (3.0, 10.0), f32::NAN).is_nan()); + } + + #[test] + fn a_nan_tilt_duration_takes_hi() { + // Under neither bound, so at or above hi. + assert_eq!(tilt_bound(f32::NAN, (3.0, 10.0), 1.0), 10.0); + } + + #[test] + fn the_tilt_bound_compares_lo_unrounded() { + // TimeMin 20 × (1 + 2⁻²³) is 20 + 1.25 ulp, which rounds down to 20 + 1 ulp as an f32. + let factor = f32::from_bits(0x3f80_0001); + let lo_f32 = f32::from_bits(0x41a0_0001); + assert_eq!(20.0 * factor, lo_f32); + assert!(f64::from(20.0_f32) * f64::from(factor) > f64::from(lo_f32)); + // A duration of exactly the rounded lo is under the unrounded one, so it takes lo; an f32 + // compare would pass it on to the hi of TimeMax 1 below it. + assert_eq!( + tilt_bound(lo_f32, (20.0, 1.0), factor).to_bits(), + lo_f32.to_bits() + ); + } + + /// Arm and step once a frame, as the terrain tilt drives the ground channel, until it lands; + /// the seconds it took. + fn arrival(c: &mut SmoothChannel, arm: &Arm, limit: f32) -> Option { + let dt = 1.0 / 60.0; + (1..=(limit / dt).round() as usize).find_map(|frame| { + c.arm(arm); + c.advance(dt); + (!c.in_flight()).then_some(frame as f32 * dt) + }) + } + + #[test] + fn an_inverted_tilt_pair_arms_and_is_bounded_again_in_flight() { + let arm = Arm { + target: 1.0, + delay: 0.0, + factor: 1.0, + rate: 1.0, + bound: Some((20.0, 1.0)), + }; + // The 1 s tween is under lo 20, so the arm takes 20. + let mut c = SmoothChannel::default(); + assert_eq!(c.arm(&arm), Armed::Started); + assert_eq!(c.flight.map(|f| f.duration), Some(20.0)); + // The next frame's arm is already arming this, and the bound runs again: 20 is not under + // lo, and is at or above hi 1, so 1. + assert_eq!(c.arm(&arm), Armed::Already); + assert_eq!(c.flight.map(|f| f.duration), Some(1.0)); + let took = arrival(&mut SmoothChannel::default(), &arm, 30.0).expect("lands"); + assert!( + (took - 1.0).abs() < 0.05, + "lands at hi, 1 s, took {took:.2}s" + ); + } + + #[test] + fn a_tilt_bound_changed_in_flight_bounds_the_tween_again() { + let mut arm = Arm { + target: 100.0, + delay: 0.0, + factor: 1.0, + rate: 1.0, + bound: Some((3.0, 10.0)), + }; + let mut c = SmoothChannel::default(); + assert_eq!(c.arm(&arm), Armed::Started); + assert_eq!(c.flight.map(|f| f.duration), Some(10.0)); + // `cameraTerrainTiltTimeMax 5` mid-tween. + arm.bound = Some((3.0, 5.0)); + assert_eq!(c.arm(&arm), Armed::Already); + assert_eq!(c.flight.map(|f| f.duration), Some(5.0)); + } + + #[test] + fn a_nan_tilt_duration_lands_on_the_target() { + let arm = Arm { + target: 5.0, + delay: 0.0, + factor: 1.0, + rate: 1.0, + bound: Some((3.0, f32::NAN)), + }; + let mut c = SmoothChannel::default(); + assert_eq!(c.arm(&arm), Armed::Started); + assert!( + c.flight.is_some_and(|f| f.duration.is_nan()), + "the bound's answer is the NaN hi" + ); + assert_eq!(c.advance(1.0 / 60.0), 5.0); + assert!(!c.in_flight()); + } } diff --git a/crates/benilla-app/src/player/camera_dynamics.rs b/crates/benilla-app/src/player/camera_dynamics.rs index c11154c7..285a431e 100644 --- a/crates/benilla-app/src/player/camera_dynamics.rs +++ b/crates/benilla-app/src/player/camera_dynamics.rs @@ -421,7 +421,7 @@ impl TerrainTilt { delay, factor, rate, - duration: Some((cfg.tilt_time_min * factor, cfg.tilt_time_max * factor)), + bound: Some((cfg.tilt_time_min, cfg.tilt_time_max)), }); } self.ground.advance(dt) @@ -878,6 +878,40 @@ mod tests { assert!(zeroed.abs() < CHANNEL_EPS, "levels off, at {zeroed}"); } + /// `cameraTerrainTiltTimeMin 20` over `cameraTerrainTiltTimeMax 1`, a pair 1.12.1 accepts, onto + /// a 20° slope: the first arm takes `20 × Factor`, and every later one bounds that to + /// `1 × Factor`, since it is not under lo and is at or above hi (`0x50dd29`). + #[test] + fn an_inverted_tilt_time_pair_leans_over_the_max_times_the_factor() { + use super::super::camera::FollowStyle; + let cfg = CameraOptions { + terrain_tilt: true, + tilt_time_min: 20.0, + tilt_time_max: 1.0, + ..CameraOptions::default() + }; + let target = TerrainTilt::slope_to_pitch(0.5); + // Walking is the Factor 1 row, falling the 0.75 one. + for (move_flags, factor) in [(mf::FORWARD, 1.0), (mf::FORWARD | mf::FALLING, 0.75)] { + let subject = SubjectState { + move_flags, + ..SubjectState::default() + }; + let mut t = TerrainTilt::default(); + let took = (1..=600) + .find_map(|frame| { + let p = t.advance(|| 0.5, true, &subject, FollowStyle::Smart, &cfg, 1.0 / 60.0); + (p == target).then_some(frame as f32 / 60.0) + }) + .expect("the lean arrives"); + let expected = cfg.tilt_time_max * factor; + assert!( + (took - expected).abs() < 0.05, + "Factor {factor}: {expected} s, took {took:.2}s" + ); + } + } + #[test] fn head_bob_arms_on_the_movement_command_word_and_on_the_mouse_chord() { use super::super::camera::follow_cmd as c; diff --git a/crates/benilla-app/src/realm_select/category.rs b/crates/benilla-app/src/realm_select/category.rs new file mode 100644 index 00000000..83b81fb6 --- /dev/null +++ b/crates/benilla-app/src/realm_select/category.rs @@ -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> { + // 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], 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> { + 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 { + 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, + assets: Option>, +) { + 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::>() + ); + 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 { + 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 { + 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"); + } +} diff --git a/crates/benilla-app/src/realm_select/input.rs b/crates/benilla-app/src/realm_select/input.rs index 9b8dee46..eb49770c 100644 --- a/crates/benilla-app/src/realm_select/input.rs +++ b/crates/benilla-app/src/realm_select/input.rs @@ -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, @@ -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); } diff --git a/crates/benilla-app/src/realm_select/mod.rs b/crates/benilla-app/src/realm_select/mod.rs index 9c9630a9..467b268c 100644 --- a/crates/benilla-app/src/realm_select/mod.rs +++ b/crates/benilla-app/src/realm_select/mod.rs @@ -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, /// The highlighted row by realm name, so a refresh that adds or drops a realm cannot move it. selected: Option, - /// The category tab in front (`RealmList.selectedCategory`), by the wire's category byte. - pub(super) category: Option, + /// The client Region's categories ([`category`]), read once at startup; `None` until then, + /// and every realm lists untabbed. + pub(super) categories: Option>, + /// The tab in front, `RealmList.selectedCategory`: a 1-based ordinal over the categories + /// holding a realm. + pub(super) category: Option, + /// `[0x837e98]`, the stored category `GetSelectedCategory` opens the list on, by index into + /// [`Self::categories`]; `None` is its `-1` load value. + chosen_category: Option, /// 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 { - let category = self.category; let (mean, stddev) = self.stats(); - let mut rows: Vec = 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 { - let mut cats: Vec = 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::().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::() + .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.shown), ) .chain() - .run_if(|realms: Res| 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) -> 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. diff --git a/crates/benilla-app/src/realm_select/screen.rs b/crates/benilla-app/src/realm_select/screen.rs index ad980ccc..cbd6710e 100644 --- a/crates/benilla-app/src/realm_select/screen.rs +++ b/crates/benilla-app/src/realm_select/screen.rs @@ -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`: 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, 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, 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, + Without, + Without, + Without, + ), + >, + arts: Query< + 'w, + 's, + (&'static TabOf, &'static TabArt, &'static mut Visibility), + (Without, Without), + >, + labels: + Query<'w, 's, (&'static TabOf, &'static mut Text, &'static mut TextColor), Without>, +} + +/// `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, strings: Option>, mut rows: Query< @@ -518,9 +681,11 @@ pub(super) fn refresh_rows( (&mut Node, &mut Visibility, Option<&mut ImageNode>), (With, Without), >, - mut ok: Query<&mut crate::glue::widgets::GlueDisabled, With>, + mut ok: Query<&mut GlueDisabled, With>, + mut tab_nodes: TabNodes, window: Query<&Window, With>, ) { + 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::