diff --git a/README.md b/README.md index 468837f2..fe99a8b0 100644 --- a/README.md +++ b/README.md @@ -38,16 +38,26 @@ and no bundled game assets. - **UI:** a from-scratch FrameXML + Lua engine driving the built-in interface, from the login and character screens through the full HUD, the classic windows (guild, macros and key bindings included), chat, nameplates, floating combat text and tooltips; third-party addons load from - a `benilla-config/AddOns/` folder beside the executable (partial — see below). + a `benilla-config/AddOns/` folder beside the executable (partial: AtlasLoot and Bagnon run, + see below). - **Combat:** melee on the faithful swing law, ranged and Auto Shot, casting with GCD and cooldowns, combo points, crowd control that really holds you, and the spell visual pipeline. - **Audio:** music, ambience and SFX under the client's own selection and crossfade rules, with interior and underwater transitions and zone reverb. -## Not built yet +## Where it's going -Battlegrounds. Third-party addons are partial: AtlasLoot and Bagnon run, and the addon API -fills in as other addons are tried. +benilla is done when a 1.12.1 player can do everything here that they could in the original +client, it looks and feels the same, and it runs from a download on Windows, Linux and macOS. +No dates; the order is what is likely, not a promise. + +- The rest of the basics: battlegrounds, guild charters, the stable master, meeting stones, + GM tickets, master loot, cinematics and whatever else turns up. +- Addons, options and performance, ongoing. +- The no-brainer fixes from VanillaFixes, SuperWoW and the like. +- Playable downloads for Windows, Linux and macOS. Linux first. + +Not planned: other expansions or client versions, Warden (anticheat). ## Running it diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 18c3bec1..3e9ae1dd 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -64,6 +64,15 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[ // rather than its value and nothing here needs to resolve it — `""` is what `ace.trim` handles // cleanly, and inventing a realm name would be worse than admitting we have none yet. ("realmName", ""), + // The address of the logon server — the reference's own CVar, byte-verified in `WoW.exe` + // (the registration's string neighbours are `realmlist.wtf`, "Address of realm list server" + // and `us.logon.worldofwarcraft.com:3724`; wow-re `mpq/scratch/startup-order-A.md` row 62). + // A **string** row, so it is matched ahead of the numeric parse in `apply_to_knobs`. + // The default diverges knowingly — see `realmlist::DEFAULT_REALMLIST`. + ( + crate::realmlist::CVAR_REALMLIST, + crate::realmlist::DEFAULT_REALMLIST, + ), ("MasterVolume", "1"), ("SoundVolume", "1"), ("MusicVolume", "0.4"), @@ -479,6 +488,7 @@ pub(crate) struct KnobParams<'w> { tex_filter: ResMut<'w, benilla_assets::TexFilterSetting>, pane_rate: ResMut<'w, PaneRate>, guild_notify: ResMut<'w, crate::ui_guild::GuildMemberNotify>, + realmlist: ResMut<'w, crate::realmlist::Realmlist>, } impl KnobParams<'_> { @@ -511,6 +521,7 @@ impl KnobParams<'_> { tex_filter: &mut self.tex_filter, pane_rate: &mut self.pane_rate, guild_notify: &mut self.guild_notify, + realmlist: &mut self.realmlist, } } } @@ -539,24 +550,35 @@ struct Knobs<'a> { tex_filter: &'a mut benilla_assets::TexFilterSetting, pane_rate: &'a mut PaneRate, guild_notify: &'a mut crate::ui_guild::GuildMemberNotify, + realmlist: &'a mut crate::realmlist::Realmlist, } /// Apply one CVar to its knob resource (parse + the knob's own clamp). `false` = not a knob this /// build knows (the caller decides whether that warns or rides through). fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool { let key = name.to_ascii_lowercase(); - // `gxResolution` is a **string** CVar — `"WxH"`, the reference's own spelling — so it has to be - // matched ahead of the numeric parse every other row goes through, which would reject it as a - // bad value (decision 1627). It is the only such row; if a second one ever lands, this is the - // shape it joins, not a second special case somewhere else. - if key == "gxresolution" { - match crate::video::parse_resolution(value) { - Some(size) => knobs.video.windowed = size, - // Same posture as the numeric miss below: known key, bad value — consumed, and the - // resource keeps its truth. - None => warn!("cvar {name}: unparseable value '{value}' ignored"), + // **The string-valued rows**, matched ahead of the numeric parse every other row goes through + // — which would reject them as bad values. `gxResolution` was the first (decision 1627) and + // its comment named this as the shape a second one would join rather than a second special + // case somewhere else; `realmList` (1667) is that second one, so this is now that shape. + // Every arm shares the numeric miss's posture below: known key, bad value — consumed, with a + // warn, and the resource keeps its truth. + match key.as_str() { + "gxresolution" => { + match crate::video::parse_resolution(value) { + Some(size) => knobs.video.windowed = size, + None => warn!("cvar {name}: unparseable value '{value}' ignored"), + } + return true; } - return true; + "realmlist" => { + match crate::realmlist::normalize(value) { + Some(address) => knobs.realmlist.set(&address), + None => warn!("cvar {name}: unusable realmlist '{value}' ignored"), + } + return true; + } + _ => {} } let Ok(v) = value.parse::() else { warn!("cvar {name}: unparseable value '{value}' ignored"); @@ -767,6 +789,12 @@ fn load_config(mut persist: ResMut, mut params: KnobParams) { if std::env::var_os("WOW_RENDER_SCALE").is_some() { persist.env_overridden.insert("renderscale".into()); } + // `$WOW_HOST` is the realmlist for the session (1667) — every probe, smoke run and harness leg + // sets it, and a value pinned into the file would silently repoint the player's client at + // whatever a test dialed. `Realmlist::default()` has already taken it; this keeps it off disk. + if std::env::var_os("WOW_HOST").is_some() { + persist.env_overridden.insert("realmlist".into()); + } let cvars = match stored_config() { StoredConfig::Absent => return, // no file, hermetic capture, or no install StoredConfig::Bad(msg) => { @@ -891,6 +919,7 @@ fn sync_cvars( msaa, msaa_formats, tex_filter, + realmlist, } = ¶ms; // The config file's values go in FIRST (decision 1291): registration — ours below, or an // addon's `RegisterCVar` later — starts a key at its saved value. This is what carries a @@ -923,7 +952,7 @@ fn sync_cvars( .collect(), ); let flag = |b: bool| if b { "1" } else { "0" }.to_string(); - let session: [(&str, String); 39] = [ + let session: [(&str, String); 40] = [ ("MasterVolume", sound.master.to_string()), ("SoundVolume", sound.sfx.to_string()), ("MusicVolume", sound.music.to_string()), @@ -977,6 +1006,12 @@ fn sync_cvars( ("gxMultisample", msaa.samples.to_string()), ("trilinear", flag(tex_filter.trilinear)), ("anisotropic", tex_filter.aniso.to_string()), + // The other string-valued row (1667): what the next logon attempt will actually dial, + // including a `$WOW_HOST` the player never typed. + ( + crate::realmlist::CVAR_REALMLIST, + realmlist.address().to_string(), + ), ]; for (name, value) in session { script.set_cvar_host(name, &value); @@ -1284,6 +1319,10 @@ mod tests { trilinear: true, aniso: 1, }; + // Literal for the same reason once more (1667): Realmlist::default() reads $WOW_HOST, and + // a shell that happens to export it must not decide what this test asserts against. + let mut realmlist = + crate::realmlist::Realmlist::unpinned(crate::realmlist::DEFAULT_REALMLIST); let mut knobs = Knobs { sound: &mut sound, scale: &mut scale, @@ -1305,9 +1344,31 @@ mod tests { msaa: &mut msaa, tex_filter: &mut tex_filter, msaa_formats: &msaa_formats, + realmlist: &mut realmlist, }; assert!(apply_to_knobs("MusicVolume", "0.7", &mut knobs)); assert_eq!(knobs.sound.music, 0.7); + // The second string-valued row (1667): it must reach the knob rather than being rejected + // by the numeric parse every other row goes through, and a value that is not an address + // must be consumed (known key) while leaving the knob's truth alone. + assert!(apply_to_knobs( + "realmList", + "logon.example.org:3724", + &mut knobs + )); + assert_eq!(knobs.realmlist.address(), "logon.example.org:3724"); + assert!(apply_to_knobs( + "realmlist", + r#"SET realmlist "elsewhere.example.org""#, + &mut knobs + )); + assert_eq!(knobs.realmlist.address(), "elsewhere.example.org"); + assert!(apply_to_knobs("realmList", "not an address", &mut knobs)); + assert_eq!( + knobs.realmlist.address(), + "elsewhere.example.org", + "a known key with a bad value is consumed, and the resource keeps its truth", + ); // Clamps are the knob's own: volume to [0,1], farclip to FARCLIP_RANGE. assert!(apply_to_knobs("mastervolume", "7", &mut knobs)); assert_eq!(knobs.sound.master, 1.0); @@ -1551,6 +1612,10 @@ mod tests { .init_resource::() // Literal, not Default: RenderScale::default() reads $WOW_RENDER_SCALE. .insert_resource(RenderScale(1.0)) + // Literal for the same reason again (1667): Realmlist::default() reads $WOW_HOST. + .insert_resource(crate::realmlist::Realmlist::unpinned( + crate::realmlist::DEFAULT_REALMLIST, + )) .init_resource::() .init_resource::() .add_plugins(CvarPlugin); @@ -1739,11 +1804,15 @@ mod tests { /// resolved — `""` is what `Ace/AceState.lua:27`'s `ace.trim(GetCVar("realmName"))` handles /// cleanly, and inventing a realm name would be worse than admitting we have none yet. /// - /// **`gxResolution` defaults to the pre-1627 window** (decision 1627). It is the one row - /// [`apply_to_knobs`] matches ahead of its numeric parse, so its default is asserted through - /// the same parser the live value goes through — a spelling this table accepts but - /// [`crate::video::parse_resolution`] rejects would otherwise ship as a silent fall back to - /// `DEFAULT_WINDOWED`. + /// **`gxResolution` defaults to the pre-1627 window** (decision 1627), and **`realmList` to + /// `localhost`** (1667). These are the rows [`apply_to_knobs`] matches ahead of its numeric + /// parse, so each default is asserted through the same parser the live value goes through — a + /// spelling this table accepts but [`crate::video::parse_resolution`] or + /// [`crate::realmlist::normalize`] rejects would otherwise ship as a silent fall back. + /// + /// The list itself is the load-bearing half: a new string-valued row that forgets its arm in + /// `apply_to_knobs` is a CVar the player can set and the client will never honour, and this is + /// what makes adding one impossible to do quietly. #[test] fn the_string_valued_cvars_are_the_realm_and_the_windowed_size() { let mut strings: Vec<&str> = REGISTERED @@ -1752,7 +1821,7 @@ mod tests { .map(|(n, _)| *n) .collect(); strings.sort_unstable(); // the list is the claim, not where the rows sit in the table - assert_eq!(strings, vec!["gxResolution", "realmName"]); + assert_eq!(strings, vec!["gxResolution", "realmList", "realmName"]); let default_of = |name: &str| { REGISTERED .iter() @@ -1765,5 +1834,11 @@ mod tests { crate::video::parse_resolution(default_of("gxResolution")), Some(crate::video::DEFAULT_WINDOWED) ); + // Same posture for the third row (1667): a default this table accepts but + // `realmlist::normalize` rejects would ship as a client that silently cannot dial. + assert_eq!( + crate::realmlist::normalize(default_of(crate::realmlist::CVAR_REALMLIST)).as_deref(), + Some(crate::realmlist::DEFAULT_REALMLIST), + ); } } diff --git a/crates/benilla-app/src/glue/widgets.rs b/crates/benilla-app/src/glue/widgets.rs index 44a1a706..2eaec2d2 100644 --- a/crates/benilla-app/src/glue/widgets.rs +++ b/crates/benilla-app/src/glue/widgets.rs @@ -719,6 +719,10 @@ pub(crate) fn glue_edit_box( /// A glue-panel button (Accept/Back/Enter World/…): the real `Glue-Panel-Button` art with its /// additive hover sheen and a caption that whitens on hover (the ref's `HighlightFont`); /// plain-fill fallback. Art states + caption color are driven by [`super::glue_button_visuals`]. +/// +/// Returns the button's entity, so a screen can reach back into what it just built — the login +/// screen marks its realmlist button [`GlueDisabled`] when `$WOW_HOST` owns the session (1667). +/// Ignoring the return is the norm; nothing is `#[must_use]`. #[allow(clippy::too_many_arguments)] pub(crate) fn glue_button( parent: &mut ChildSpawnerCommands, @@ -730,7 +734,7 @@ pub(crate) fn glue_button( h: f32, kind: GlueBtnKind, s: f32, -) { +) -> Entity { let px = |v: f32| Val::Px(v * s); let mut b = parent.spawn(( action, @@ -797,6 +801,7 @@ pub(crate) fn glue_button( s, ); }); + b.id() } #[cfg(test)] diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 2a6dbc20..05873234 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -90,6 +90,7 @@ mod preflight; mod probe_shield; mod quest_markers; mod raid_marks; +mod realmlist; mod run_mode; mod screenshot; mod shaders; @@ -576,6 +577,9 @@ pub fn run(build: BuildId) -> AppExit { // The video knobs the CVar host writes into (today: `gxVSync`). Before CvarPlugin so the // resource exists when `load_config` applies the saved value at Startup. .add_plugins(video::VideoPlugin) + // The realmlist (decision 1667) — the logon address the login screen edits. Same reason as + // VideoPlugin above: it is a CVar knob, so its resource has to exist before `load_config`. + .add_plugins(realmlist::RealmlistPlugin) // The CVar host (decision 0954): registration, knob sync, config.toml persistence. After // UiScriptPlugin only for reading order — its systems gate on the VM existing anyway. .add_plugins(cvars::CvarPlugin) diff --git a/crates/benilla-app/src/login/mod.rs b/crates/benilla-app/src/login/mod.rs index 3e8fc4c4..4a04ee19 100644 --- a/crates/benilla-app/src/login/mod.rs +++ b/crates/benilla-app/src/login/mod.rs @@ -76,6 +76,9 @@ impl Plugin for LoginPlugin { screen::refresh_boxes, screen::refresh_checkbox, drive_dialog, + // Both after `drive_dialog`: it is what spawns the dialog's edit box, and + // what a realmlist Okay changes the address in. + (screen::refresh_dialog_box, screen::refresh_realmlist), crate::glue::art_swaps, crate::glue::glue_button_visuals, crate::glue::sync_outlines, @@ -138,24 +141,41 @@ impl LoginIntent { } } -/// Send one login attempt to the parked IO thread, stamped with the current abandon generation. -fn send_login( - intent: &mut LoginIntent, - submit: &LoginSubmit, - abandon: &LoginAbandon, - user: &str, - pass: &str, - announced: bool, -) { - intent.creds = Some((user.to_string(), pass.to_string())); - intent.in_flight = true; - intent.announced = announced; - intent.retry_at = None; - let _ = submit.0.send(LoginRequest { - user: user.to_string(), - pass: pass.to_string(), - generation: abandon.0.load(Ordering::SeqCst), - }); +/// **Everything one login attempt is made of**, as a single [`SystemParam`]: the policy's memory, +/// the channel to the parked IO thread, the abandon generation a Cancel bumps, and — since +/// decision 1667 — the realmlist it dials. +/// +/// A bundle rather than four parameters, for `cvars::KnobParams`' reason: adding the realmlist put +/// [`login_input`] at **seventeen** parameters, one past Bevy's ceiling, and the three systems +/// that submit were already re-typing the same four names. Now a submit is one call on one param, +/// and the next thing an attempt needs is one field here instead of a fourth signature to widen. +#[derive(bevy::ecs::system::SystemParam)] +pub(super) struct Attempt<'w> { + pub(super) intent: ResMut<'w, LoginIntent>, + submit: Res<'w, LoginSubmit>, + abandon: Res<'w, LoginAbandon>, + realmlist: Res<'w, crate::realmlist::Realmlist>, +} + +impl Attempt<'_> { + /// Send one login attempt to the parked IO thread, stamped with the current abandon + /// generation. + /// + /// The realmlist is read **at submit time** (decision 1667) rather than by the IO thread, so a + /// resubmit fired after the player repointed the client dials the new server while an attempt + /// already on the wire keeps the one it started with. + fn send(&mut self, user: &str, pass: &str, announced: bool) { + self.intent.creds = Some((user.to_string(), pass.to_string())); + self.intent.in_flight = true; + self.intent.announced = announced; + self.intent.retry_at = None; + let _ = self.submit.0.send(LoginRequest { + user: user.to_string(), + pass: pass.to_string(), + host: self.realmlist.address().to_string(), + generation: self.abandon.0.load(Ordering::SeqCst), + }); + } } /// The `LOGIN_STATE_*` glue string for a stage (the connecting dialog's text). @@ -199,10 +219,8 @@ fn fail_text(strings: &GlueStrings, code: Option) -> &str { /// [`send_login`] with `announced = true`. #[allow(clippy::too_many_arguments)] fn drive_policy( - mut intent: ResMut, + mut attempt: Attempt, mut dialog: ResMut, - submit: Res, - abandon: Res, strings: Option>, time: Res