a realmlist button on the login screen, and a roadmap in the README
- the logon address is the reference's own `realmList` CVar — name, `host[:port]` form and the "Address of realm list server" prompt read straight out of `WoW.exe`'s string table — persisted in `benilla-config/config.toml` and edited on the login screen: a Realmlist button in the slot `AccountLogin.xml` already reserves under the Quit stack, the current address in grey beneath it, opening the reference's own `hasEditBox` glue dialog; before this the address was `$WOW_HOST` read once at process start with no way to change it; one text field and no saved list (every client at this scale ships one field), a pasted `SET realmlist "…"` line is accepted, and the default is `localhost` because the retail host has not resolved since 2019 - the README's "Not built yet" becomes "Where it's going": what done means, no dates, and one list in probable order — the remaining basics (battlegrounds, guild charters, the stable master, meeting stones, GM tickets, master loot, cinematics), addons / options / performance as ongoing, the no-brainer VanillaFixes- and SuperWoW-class fixes, playable downloads with Linux first; not planned: other expansions, Warden - a UI region's span comes from its content and the owner-edge fallback is deleted: the reference's resolver core has no parent pointer at all, a FontString floors at one unit on both axes, a texture takes its texel span, and a region resolves from its own anchors and span or not at all — a live census over 8772 rects: 0 stopped resolving, 923 FontStrings shrank (842 to exactly 1×1 — empty labels that had been the whole screen, or an inverted rect), 0 textures moved; and `Region:GetWidth`/`GetHeight` are the same virtual getters the resolver calls (authored value wins per axis, natural width, wrapped height, texel span for art), so a script reads the number its rect was built from Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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::<f32>() else {
|
||||
warn!("cvar {name}: unparseable value '{value}' ignored");
|
||||
@@ -767,6 +789,12 @@ fn load_config(mut persist: ResMut<CvarPersist>, 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::<VideoConfig>()
|
||||
// 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::<PaneRate>()
|
||||
.init_resource::<crate::ui_guild::GuildMemberNotify>()
|
||||
.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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -719,6 +719,10 @@ pub(crate) fn glue_edit_box<E: Bundle, T: Bundle + Clone>(
|
||||
/// 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<A: Component>(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
@@ -730,7 +734,7 @@ pub(crate) fn glue_button<A: Component>(
|
||||
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<A: Component>(
|
||||
s,
|
||||
);
|
||||
});
|
||||
b.id()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<u8>) -> &str {
|
||||
/// [`send_login`] with `announced = true`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn drive_policy(
|
||||
mut intent: ResMut<LoginIntent>,
|
||||
mut attempt: Attempt,
|
||||
mut dialog: ResMut<LoginDialog>,
|
||||
submit: Res<LoginSubmit>,
|
||||
abandon: Res<LoginAbandon>,
|
||||
strings: Option<Res<GlueStrings>>,
|
||||
time: Res<Time>,
|
||||
mut stages: MessageReader<LoginStageMessage>,
|
||||
@@ -223,8 +241,8 @@ fn drive_policy(
|
||||
// The env fast path, once (decision 0539 §3): any of WOW_USER/WOW_PASS/WOW_CHAR explicitly
|
||||
// set → auto-submit env-with-defaults, so every probe/smoke/harness invocation keeps working.
|
||||
// The login smoke drives its own credentials instead.
|
||||
if !intent.env_read {
|
||||
intent.env_read = true;
|
||||
if !attempt.intent.env_read {
|
||||
attempt.intent.env_read = true;
|
||||
// The same fact a lost session asks about (decision 1262) — read from the one place that
|
||||
// owns it, so "the harness logs in for us" and "the harness logs back in for us" can never
|
||||
// be two different answers.
|
||||
@@ -238,13 +256,13 @@ fn drive_policy(
|
||||
match crate::run_mode::account_guard(&user) {
|
||||
Ok(()) => {
|
||||
info!("login: env fast path — auto-submitting as {user}");
|
||||
intent.creds = Some((user, pass));
|
||||
intent.retry_at = Some(now);
|
||||
attempt.intent.creds = Some((user, pass));
|
||||
attempt.intent.retry_at = Some(now);
|
||||
}
|
||||
Err(why) if std::env::var_os("WOW_ALLOW_ACCOUNT").is_some() => {
|
||||
warn!("login: {why} — WOW_ALLOW_ACCOUNT is set, going ahead anyway");
|
||||
intent.creds = Some((user, pass));
|
||||
intent.retry_at = Some(now);
|
||||
attempt.intent.creds = Some((user, pass));
|
||||
attempt.intent.retry_at = Some(now);
|
||||
}
|
||||
Err(why) => {
|
||||
error!("login: REFUSING the env fast path — {why} Set WOW_ALLOW_ACCOUNT=1 if the cross-account login is deliberate.");
|
||||
@@ -264,13 +282,13 @@ fn drive_policy(
|
||||
}
|
||||
}
|
||||
for msg in failures.read() {
|
||||
intent.in_flight = false;
|
||||
intent.park = IoPark::AtLogin;
|
||||
attempt.intent.in_flight = false;
|
||||
attempt.intent.park = IoPark::AtLogin;
|
||||
// A terminal failure names something no resubmit can change (the server requires Warden,
|
||||
// say) — show the server's own words and drop the credentials so nothing retries.
|
||||
if msg.terminal {
|
||||
warn!("login: {}", msg.reason);
|
||||
intent.clear();
|
||||
attempt.intent.clear();
|
||||
dialog.open_error(&msg.reason);
|
||||
if harness {
|
||||
error!("login: FATAL — terminal login failure with nobody at the keyboard ({}); exiting", msg.reason);
|
||||
@@ -283,30 +301,30 @@ fn drive_policy(
|
||||
// A refusal: surface it (even on the silent path — the credentials went stale)
|
||||
// and never auto-retry against it.
|
||||
warn!("login: refused (code {code:#04x}) — {}", msg.reason);
|
||||
intent.clear();
|
||||
attempt.intent.clear();
|
||||
dialog.open_error(fail_text(strings, Some(code)));
|
||||
if harness {
|
||||
error!("login: FATAL — refused (code {code:#04x}) and no resubmit can change it; exiting");
|
||||
exit.write(AppExit::error());
|
||||
}
|
||||
}
|
||||
None if intent.announced => {
|
||||
None if attempt.intent.announced => {
|
||||
warn!("login: {}", msg.reason);
|
||||
dialog.open_error(fail_text(strings, None));
|
||||
}
|
||||
None => {
|
||||
// Silent transport failure with pending intent: schedule the paced resubmit.
|
||||
debug!("login: transport failure ({}) — retrying", msg.reason);
|
||||
if intent.creds.is_some() {
|
||||
intent.retry_at = Some(now + RETRY_DELAY_SECS);
|
||||
if attempt.intent.creds.is_some() {
|
||||
attempt.intent.retry_at = Some(now + RETRY_DELAY_SECS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for msg in disconnects.read() {
|
||||
// The IO thread is heading back to its pre-logon park.
|
||||
intent.park = IoPark::AtLogin;
|
||||
intent.in_flight = false;
|
||||
attempt.intent.park = IoPark::AtLogin;
|
||||
attempt.intent.in_flight = false;
|
||||
if msg.session_over {
|
||||
// The reference's `DISCONNECTED_FROM_SERVER` (decision 1262): `GlueParent.lua` answers
|
||||
// it with `SetGlueScreen("login")` + `GlueDialog_Show("DISCONNECTED")` — the account
|
||||
@@ -317,32 +335,32 @@ fn drive_policy(
|
||||
"login: {} — session over, back to the login screen",
|
||||
msg.reason
|
||||
);
|
||||
intent.clear();
|
||||
attempt.intent.clear();
|
||||
dialog.open_error(strings.text("DISCONNECTED", "Disconnected from server"));
|
||||
continue;
|
||||
}
|
||||
// Otherwise the session continues through the park and the re-auth is silent: immediate
|
||||
// after a clean logout (the roster IS the select screen the app now shows), paced after a
|
||||
// stream death an unattended run must recover from on its own (0065, paced app-side).
|
||||
if intent.creds.is_some() {
|
||||
if attempt.intent.creds.is_some() {
|
||||
let delay = if msg.end == benilla_protocol::SessionEnd::LoggedOut {
|
||||
0.0
|
||||
} else {
|
||||
RETRY_DELAY_SECS
|
||||
};
|
||||
intent.retry_at = Some(now + delay);
|
||||
attempt.intent.retry_at = Some(now + delay);
|
||||
}
|
||||
}
|
||||
|
||||
// The silent (re)submit tick.
|
||||
if intent.park == IoPark::AtLogin
|
||||
&& !intent.in_flight
|
||||
&& intent.retry_at.is_some_and(|t| now >= t)
|
||||
if attempt.intent.park == IoPark::AtLogin
|
||||
&& !attempt.intent.in_flight
|
||||
&& attempt.intent.retry_at.is_some_and(|t| now >= t)
|
||||
{
|
||||
if let Some((user, pass)) = intent.creds.clone() {
|
||||
send_login(&mut intent, &submit, &abandon, &user, &pass, false);
|
||||
if let Some((user, pass)) = attempt.intent.creds.clone() {
|
||||
attempt.send(&user, &pass, false);
|
||||
} else {
|
||||
intent.retry_at = None;
|
||||
attempt.intent.retry_at = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -452,10 +470,8 @@ fn login_input(
|
||||
mut clipboard: NonSendMut<HostClipboard>,
|
||||
raw_handle: Query<&bevy::window::RawHandleWrapper, With<bevy::window::PrimaryWindow>>,
|
||||
mut form: ResMut<LoginForm>,
|
||||
mut intent: ResMut<LoginIntent>,
|
||||
mut attempt: Attempt,
|
||||
mut dialog: ResMut<LoginDialog>,
|
||||
submit: Res<LoginSubmit>,
|
||||
abandon: Res<LoginAbandon>,
|
||||
strings: Option<Res<GlueStrings>>,
|
||||
mut sounds: MessageWriter<GlueSound>,
|
||||
mut quit: Local<bool>,
|
||||
@@ -508,6 +524,27 @@ fn login_input(
|
||||
LoginAction::FocusAccount | LoginAction::FocusPassword => {} // focused on the press
|
||||
LoginAction::Login => do_login = true,
|
||||
LoginAction::Quit => do_quit = true,
|
||||
// The realmlist control (1667) — the button and the address readout under it are
|
||||
// the same action, so clicking either opens the editor.
|
||||
LoginAction::Realmlist => {
|
||||
sounds.write(GlueSound("gsClick"));
|
||||
if attempt.realmlist.pinned_by_env() {
|
||||
// A harness/dev run owns the address for the session (`cvars`' env-override
|
||||
// law). Say so rather than opening an editor whose Okay would be a silent
|
||||
// no-op — the trap that shape would set.
|
||||
dialog.open_error(&format!(
|
||||
"$WOW_HOST is set for this session, so the realmlist is fixed at {}.",
|
||||
attempt.realmlist.address(),
|
||||
));
|
||||
} else {
|
||||
dialog.open_realmlist(
|
||||
// The reference's own registered help text for `realmList`, byte-verified
|
||||
// in `WoW.exe` beside the CVar's name and default.
|
||||
"Address of realm list server",
|
||||
attempt.realmlist.address(),
|
||||
);
|
||||
}
|
||||
}
|
||||
LoginAction::ToggleSave => {
|
||||
form.save = !form.save;
|
||||
// Verbatim ref quirk (`AccountLoginSaveAccountName` OnClick): checked plays the
|
||||
@@ -521,14 +558,29 @@ fn login_input(
|
||||
save_account("");
|
||||
}
|
||||
}
|
||||
LoginAction::Dialog => {} // the dialog driver's
|
||||
// The dialog's own buttons are [`drive_dialog`]'s, and this loop is skipped
|
||||
// entirely while one is open.
|
||||
LoginAction::Dialog | LoginAction::Dialog2 => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mods = textinput::mods_now(&keys);
|
||||
let wl = textinput::wayland_display(raw_handle.iter().next());
|
||||
for ev in keyboard.read() {
|
||||
// A dialog with an edit box owns the keyboard while it is up — the ref's `GlueDialog` is
|
||||
// `toplevel` with `enableKeyboard="true"`, so the boxes behind it hear nothing. ENTER and
|
||||
// ESCAPE still come back unclaimed; [`drive_dialog`] reads them as its two buttons.
|
||||
if dialog_open {
|
||||
if dialog.kind.is_some_and(DialogKind::has_edit_box) {
|
||||
textinput::feed_key(
|
||||
&mut dialog.edit,
|
||||
ev,
|
||||
mods,
|
||||
&mut clipboard,
|
||||
wl,
|
||||
textinput::CharFilter::Any,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// The shared law first (editing, caret, selection, the clipboard trio); only what it
|
||||
@@ -566,7 +618,7 @@ fn login_input(
|
||||
do_quit = true;
|
||||
}
|
||||
|
||||
if do_login && !intent.in_flight {
|
||||
if do_login && !attempt.intent.in_flight {
|
||||
// The ref's own guards: empty account / empty password get their dialog, no wire.
|
||||
if form.account.text.is_empty() {
|
||||
dialog.open_error(strings.text("LOGIN_ENTER_NAME", "Please enter your account name."));
|
||||
@@ -584,7 +636,7 @@ fn login_input(
|
||||
let (user, pass) = (form.account.text.clone(), form.password.text.clone());
|
||||
form.password.set_text("");
|
||||
dialog.open_status(strings.text("LOGIN_STATE_CONNECTING", "Connecting"));
|
||||
send_login(&mut intent, &submit, &abandon, &user, &pass, true);
|
||||
attempt.send(&user, &pass, true);
|
||||
}
|
||||
}
|
||||
if do_quit && !*quit {
|
||||
@@ -604,9 +656,17 @@ fn login_input(
|
||||
/// going missing again. It went missing once already, and nothing but an eye noticed: the box's
|
||||
/// `caret_shown` simply never left its `true` default, so the login caret was the one glue caret
|
||||
/// that sat solid.
|
||||
fn tick_login_caret(mut form: ResMut<LoginForm>, time: Res<Time>) {
|
||||
///
|
||||
/// **A dialog with an edit box takes the focus with the keys** (1667): its box blinks and the
|
||||
/// form's stops, so the screen never shows two live carets at once. Every other dialog leaves the
|
||||
/// form's caret running — a dialog eats the keys, not the clock.
|
||||
fn tick_login_caret(mut form: ResMut<LoginForm>, mut dialog: ResMut<LoginDialog>, time: Res<Time>) {
|
||||
let dt = time.delta_secs();
|
||||
textinput::tick_caret(form.focused(), true, dt);
|
||||
let in_dialog = dialog.kind.is_some_and(DialogKind::has_edit_box);
|
||||
textinput::tick_caret(form.focused(), !in_dialog, dt);
|
||||
if in_dialog {
|
||||
textinput::tick_caret(&mut dialog.edit, true, dt);
|
||||
}
|
||||
}
|
||||
|
||||
/// Editing the account box away from the saved name clears the save + unchecks (the ref's
|
||||
@@ -632,14 +692,46 @@ fn drive_quit(arm: Option<Res<QuitArm>>, time: Res<Time>, mut exit: MessageWrite
|
||||
|
||||
// ── The GlueDialog (connecting / error) ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Which dialog is up: the connecting status (Cancel button, text driven by the stages) or an
|
||||
/// error (Okay button).
|
||||
/// Which dialog is up: the connecting status (Cancel button, text driven by the stages), an
|
||||
/// error (Okay button), or the realmlist editor (Okay + Cancel over an edit box).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum DialogKind {
|
||||
Status,
|
||||
Error,
|
||||
/// The realmlist editor (decision 1667) — the reference's `GlueDialog` `hasEditBox` shape,
|
||||
/// which is how the shipped dialog asks for a typed value (`GlueDialog.lua`: it shows
|
||||
/// `GlueDialogEditBox` and re-heights the box to
|
||||
/// `16 + text + 8 + editbox + 8 + button + 16`). The reference never opens this particular
|
||||
/// dialog — it has no realmlist UI at all — but the widget is its own.
|
||||
Realmlist,
|
||||
}
|
||||
|
||||
impl DialogKind {
|
||||
/// Whether this dialog carries the ref's `GlueDialogEditBox` (its `hasEditBox` flag).
|
||||
pub(super) fn has_edit_box(self) -> bool {
|
||||
matches!(self, DialogKind::Realmlist)
|
||||
}
|
||||
|
||||
/// `(button1, button2)` captions — `GlueDialogTypes`' own two fields. A `None` second button
|
||||
/// is the ref's centred single-button layout; `Some` is its BOTTOMRIGHT/LEFT pair.
|
||||
pub(super) fn buttons(self, strings: &GlueStrings) -> (&str, Option<&str>) {
|
||||
match self {
|
||||
DialogKind::Status => (strings.text("CANCEL", "Cancel"), None),
|
||||
DialogKind::Error => (strings.text("OKAY", "Okay"), None),
|
||||
DialogKind::Realmlist => (
|
||||
strings.text("OKAY", "Okay"),
|
||||
Some(strings.text("CANCEL", "Cancel")),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the realmlist dialog says when the box holds something that is not an address. It replaces
|
||||
/// the prompt in place and **leaves the typed text alone**, so the fix is an edit rather than a
|
||||
/// retype — the reason a bad value does not close the dialog or become an error dialog of its own.
|
||||
const REALMLIST_BAD: &str =
|
||||
"That is not a server address.\nTry logon.example.org or 127.0.0.1:3724";
|
||||
|
||||
/// The login screen's one dialog (the ref's shared `GlueDialog`): kind + text; the driver spawns/
|
||||
/// despawns the tree (respawning on a kind change — the button caption differs) and updates the
|
||||
/// text in place.
|
||||
@@ -649,6 +741,11 @@ pub(crate) struct LoginDialog {
|
||||
pub(super) text: String,
|
||||
pub(super) dirty: bool,
|
||||
pub(super) root: Option<Entity>,
|
||||
/// The ref's `GlueDialogEditBox` — a real [`EditBoxState`] like the two on the screen behind
|
||||
/// it, so the realmlist box gets the same caret, selection, Ctrl+A and clipboard law
|
||||
/// (decision 0704). Only meaningful while a [`DialogKind::has_edit_box`] dialog is up; it is
|
||||
/// rebuilt from the current value on every open, so a cancelled edit leaves nothing behind.
|
||||
pub(super) edit: EditBoxState,
|
||||
/// The kind the spawned tree was built for.
|
||||
spawned: Option<DialogKind>,
|
||||
/// The glue scale the spawned tree was built at — a resize rebuilds it.
|
||||
@@ -664,6 +761,18 @@ impl LoginDialog {
|
||||
self.kind = Some(DialogKind::Error);
|
||||
self.set_text(text);
|
||||
}
|
||||
/// Open the realmlist editor over `current`, with the caret at the end of it and the whole
|
||||
/// value selected — the reference's `hasEditBox` dialogs open ready to be typed over, and a
|
||||
/// player changing servers is replacing the address far more often than editing it.
|
||||
fn open_realmlist(&mut self, prompt: &str, current: &str) {
|
||||
self.kind = Some(DialogKind::Realmlist);
|
||||
self.edit = textinput::field(crate::realmlist::MAX_LETTERS, false);
|
||||
self.edit.set_text(current);
|
||||
// `HighlightText(0, -1)` — the client's own select-all (`0x77cca0`), which resets the
|
||||
// blink on its way so the box opens on a solid caret.
|
||||
self.edit.highlight_text(0, -1);
|
||||
self.set_text(prompt);
|
||||
}
|
||||
fn set_text(&mut self, text: &str) {
|
||||
if self.text != text {
|
||||
self.text = text.to_string();
|
||||
@@ -686,6 +795,8 @@ fn drive_dialog(
|
||||
mut commands: Commands,
|
||||
mut dialog: ResMut<LoginDialog>,
|
||||
mut intent: ResMut<LoginIntent>,
|
||||
mut realmlist: ResMut<crate::realmlist::Realmlist>,
|
||||
mut script: Option<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
abandon: Res<LoginAbandon>,
|
||||
art: Res<crate::glue::art::GlueArt>,
|
||||
assets: Res<AssetServer>,
|
||||
@@ -723,6 +834,7 @@ fn drive_dialog(
|
||||
&dialog.text,
|
||||
s,
|
||||
));
|
||||
dialog.edit.reset_blink();
|
||||
dialog.spawned = Some(kind);
|
||||
dialog.spawned_s = s;
|
||||
dialog.dirty = false;
|
||||
@@ -735,19 +847,36 @@ fn drive_dialog(
|
||||
dialog.dirty = false;
|
||||
}
|
||||
|
||||
// The one button (or its keys).
|
||||
let mut pressed = buttons
|
||||
.iter()
|
||||
.any(|(e, a)| matches!(a, LoginAction::Dialog) && clicks.hit(e));
|
||||
if keys.just_pressed(KeyCode::Escape) {
|
||||
pressed = true;
|
||||
// The buttons (or their keys). Button 1 is the affirmative one on every kind — Cancel on
|
||||
// the status dialog, Okay on the other two — and button 2 exists only where the kind declares
|
||||
// it. ENTER confirms, ESCAPE dismisses; on a one-button dialog they are the same button.
|
||||
let hit = |want: LoginAction| buttons.iter().any(|(e, a)| *a == want && clicks.hit(e));
|
||||
let enter = keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::NumpadEnter);
|
||||
let escape = keys.just_pressed(KeyCode::Escape);
|
||||
let button1 = hit(LoginAction::Dialog)
|
||||
|| match kind {
|
||||
// The status dialog's one button IS Cancel, so ESCAPE is it.
|
||||
DialogKind::Status => escape,
|
||||
DialogKind::Error => escape || enter,
|
||||
DialogKind::Realmlist => enter,
|
||||
};
|
||||
let button2 = hit(LoginAction::Dialog2) || (kind == DialogKind::Realmlist && escape);
|
||||
|
||||
if kind == DialogKind::Realmlist && button1 {
|
||||
// Okay: take the typed address, or say why it cannot be taken and stay open with the text
|
||||
// as typed — the fix is then an edit, not a retype.
|
||||
let typed = dialog.edit.text.clone();
|
||||
if accept_realmlist(&typed, &mut realmlist, script.as_deref_mut()) {
|
||||
dialog.close();
|
||||
if let Some(root) = dialog.root.take() {
|
||||
commands.entity(root).despawn();
|
||||
}
|
||||
} else {
|
||||
dialog.set_text(REALMLIST_BAD);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if kind == DialogKind::Error
|
||||
&& (keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::NumpadEnter))
|
||||
{
|
||||
pressed = true;
|
||||
}
|
||||
if pressed {
|
||||
if button1 || button2 {
|
||||
if kind == DialogKind::Status {
|
||||
// Cancel: the next stage boundary discards the attempt; a canceled manual attempt
|
||||
// must not silently resubmit later.
|
||||
@@ -762,6 +891,45 @@ fn drive_dialog(
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the realmlist dialog's Okay: normalize what was typed, point the session at it, and mirror
|
||||
/// it into the `realmList` CVar so it is still there next launch. `false` = the box holds nothing
|
||||
/// usable and the caller should keep the dialog open.
|
||||
///
|
||||
/// The persistence leg is `char_select`'s `lastCharacterIndex` pattern exactly (1131/1293): an
|
||||
/// **engine-side** write rides the change queue like a Lua `SetCVar`, so `cvars::sync_cvars` folds
|
||||
/// it into the knob and marks the file dirty, and `save_config` writes `config.toml`. A write to a
|
||||
/// name the VM has not registered yet is a deliberate silent no-op there, so this reports that
|
||||
/// case rather than claiming a save that did not happen — the **session** value stands either way,
|
||||
/// which is the half that matters for the login about to be attempted.
|
||||
fn accept_realmlist(
|
||||
typed: &str,
|
||||
realmlist: &mut crate::realmlist::Realmlist,
|
||||
script: Option<&mut benilla_ui::script::UiScript>,
|
||||
) -> bool {
|
||||
let Some(address) = crate::realmlist::normalize(typed) else {
|
||||
return false;
|
||||
};
|
||||
realmlist.set(&address);
|
||||
let name = crate::realmlist::CVAR_REALMLIST;
|
||||
match script {
|
||||
Some(script) => {
|
||||
script.set_cvar_engine(name, &address);
|
||||
if script.cvar(name).is_some() {
|
||||
info!("login: realmlist -> {address}");
|
||||
} else {
|
||||
warn!(
|
||||
"login: realmlist -> {address} for this session, but the VM has not registered \
|
||||
{name} yet, so it was not saved"
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
warn!("login: realmlist -> {address} for this session only — no UI VM to persist it")
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ── The saved account name (decision 0539 §4) ────────────────────────────────────────────────────
|
||||
|
||||
/// Read the saved account name from `base` (missing file/dir = empty). Takes the *file* rather
|
||||
@@ -811,15 +979,18 @@ mod tests {
|
||||
/// Stand `drive_policy` up on its own with the env fast path already spent, so the policy
|
||||
/// under test is the disconnect arm and nothing else — and so an ambient `WOW_USER` in
|
||||
/// whatever shell runs the suite cannot seed credentials behind the assertions.
|
||||
fn policy_app() -> App {
|
||||
fn policy_app() -> (App, crossbeam_channel::Receiver<LoginRequest>) {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
// Held for the App's life: a dropped receiver would turn every submit into an `Err` and
|
||||
// hide a policy that sent one.
|
||||
std::mem::forget(rx);
|
||||
let mut app = App::new();
|
||||
app.init_resource::<Time>()
|
||||
.init_resource::<LoginIntent>()
|
||||
.init_resource::<LoginDialog>()
|
||||
// Literal, not Default: `Realmlist::default()` reads `$WOW_HOST`, and every probe
|
||||
// recipe in this repo exports it — a suite run from such a shell would otherwise
|
||||
// assert against whatever that shell happened to be pointing at.
|
||||
.insert_resource(crate::realmlist::Realmlist::unpinned(
|
||||
crate::realmlist::DEFAULT_REALMLIST,
|
||||
))
|
||||
.insert_resource(LoginSubmit(tx))
|
||||
.insert_resource(LoginAbandon(std::sync::Arc::new(
|
||||
std::sync::atomic::AtomicU64::new(0),
|
||||
@@ -829,7 +1000,10 @@ mod tests {
|
||||
.add_message::<DisconnectedMessage>()
|
||||
.add_systems(Update, drive_policy);
|
||||
app.world_mut().resource_mut::<LoginIntent>().env_read = true;
|
||||
app
|
||||
// The receiver is RETURNED rather than leaked: a dropped one turns every submit into an
|
||||
// `Err` and hides a policy that sent one, and holding it is also what lets a test read
|
||||
// back the request that went out.
|
||||
(app, rx)
|
||||
}
|
||||
|
||||
/// **A lost session does not log itself back in** (decision 1262).
|
||||
@@ -842,7 +1016,7 @@ mod tests {
|
||||
/// one-button dialog, and retries nothing.
|
||||
#[test]
|
||||
fn a_lost_session_clears_the_credentials_and_shows_the_dialog() {
|
||||
let mut app = policy_app();
|
||||
let (mut app, _requests) = policy_app();
|
||||
app.world_mut().resource_mut::<LoginIntent>().creds = Some(("one".into(), "pone".into()));
|
||||
app.world_mut().write_message(DisconnectedMessage {
|
||||
reason: "disconnected: world stream closed: failed to fill whole buffer".into(),
|
||||
@@ -870,7 +1044,7 @@ mod tests {
|
||||
/// select the player asked for. Breaking this would strand `/logout` on the login screen.
|
||||
#[test]
|
||||
fn a_logout_teardown_still_relists_at_once() {
|
||||
let mut app = policy_app();
|
||||
let (mut app, _requests) = policy_app();
|
||||
app.world_mut().resource_mut::<LoginIntent>().creds = Some(("one".into(), "pone".into()));
|
||||
app.world_mut().write_message(DisconnectedMessage {
|
||||
reason: "logged out".into(),
|
||||
@@ -899,7 +1073,7 @@ mod tests {
|
||||
/// the message, so the policy honours it without re-reading the environment.
|
||||
#[test]
|
||||
fn an_unattended_run_still_reconnects_on_its_own() {
|
||||
let mut app = policy_app();
|
||||
let (mut app, _requests) = policy_app();
|
||||
app.world_mut().resource_mut::<LoginIntent>().creds =
|
||||
Some(("probe1".into(), "pprobe1".into()));
|
||||
app.world_mut().write_message(DisconnectedMessage {
|
||||
@@ -919,6 +1093,111 @@ mod tests {
|
||||
assert!(app.world().resource::<LoginDialog>().kind.is_none());
|
||||
}
|
||||
|
||||
/// **The submitted attempt dials the configured realmlist** (decision 1667) — the whole
|
||||
/// point of the setting. Before this, the address was latched out of `$WOW_HOST` once at
|
||||
/// process start and the request had no say in it; now the request carries it, so a change
|
||||
/// made between attempts is the one the next attempt uses.
|
||||
#[test]
|
||||
fn a_submitted_attempt_carries_the_configured_realmlist() {
|
||||
let (mut app, requests) = policy_app();
|
||||
app.world_mut()
|
||||
.insert_resource(crate::realmlist::Realmlist::unpinned(
|
||||
"logon.example.org:3725",
|
||||
));
|
||||
// Credentials pending with the retry due: the policy's silent submit tick.
|
||||
{
|
||||
let mut intent = app.world_mut().resource_mut::<LoginIntent>();
|
||||
intent.creds = Some(("one".into(), "pone".into()));
|
||||
intent.retry_at = Some(0.0);
|
||||
}
|
||||
app.update();
|
||||
|
||||
let sent = requests.try_recv().expect("the policy submitted");
|
||||
assert_eq!(sent.user, "one");
|
||||
assert_eq!(
|
||||
sent.host, "logon.example.org:3725",
|
||||
"the attempt dials what the realmlist says, not a value latched at spawn",
|
||||
);
|
||||
|
||||
// And a change between attempts is picked up by the next one, with no relaunch.
|
||||
app.world_mut()
|
||||
.insert_resource(crate::realmlist::Realmlist::unpinned(
|
||||
"elsewhere.example.org",
|
||||
));
|
||||
{
|
||||
let mut intent = app.world_mut().resource_mut::<LoginIntent>();
|
||||
intent.in_flight = false;
|
||||
intent.retry_at = Some(0.0);
|
||||
}
|
||||
app.update();
|
||||
assert_eq!(
|
||||
requests.try_recv().expect("resubmitted").host,
|
||||
"elsewhere.example.org",
|
||||
);
|
||||
}
|
||||
|
||||
/// The dialog's Okay: what the box holds becomes the session's address, including the
|
||||
/// `realmlist.wtf` line a player pastes off a server's setup page. No VM here, so the
|
||||
/// persistence leg is the `None` arm — the session value is what this asserts, and it is the
|
||||
/// half the next login attempt reads.
|
||||
#[test]
|
||||
fn the_realmlist_dialog_takes_what_was_typed() {
|
||||
let mut realmlist = crate::realmlist::Realmlist::unpinned("localhost");
|
||||
assert!(accept_realmlist(
|
||||
r#" SET realmlist "logon.example.org" "#,
|
||||
&mut realmlist,
|
||||
None,
|
||||
));
|
||||
assert_eq!(realmlist.address(), "logon.example.org");
|
||||
}
|
||||
|
||||
/// …and a box holding something that is not an address changes nothing and reports it, so the
|
||||
/// caller keeps the dialog open over the text as typed rather than closing on a silent no-op.
|
||||
#[test]
|
||||
fn a_bad_address_leaves_the_realmlist_alone() {
|
||||
let mut realmlist = crate::realmlist::Realmlist::unpinned("localhost");
|
||||
for typed in ["", " ", "logon.example.org and more", "host:notaport"] {
|
||||
assert!(
|
||||
!accept_realmlist(typed, &mut realmlist, None),
|
||||
"{typed:?} is not an address",
|
||||
);
|
||||
assert_eq!(realmlist.address(), "localhost");
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog kinds' own shape: only the realmlist editor carries the ref's `hasEditBox`, and
|
||||
/// only it declares a second button. The caret clock and the keyboard routing both branch on
|
||||
/// the first of those, and `spawn_dialog` lays out from the second.
|
||||
#[test]
|
||||
fn only_the_realmlist_dialog_has_a_box_and_two_buttons() {
|
||||
let strings = GlueStrings::default();
|
||||
assert!(!DialogKind::Status.has_edit_box());
|
||||
assert!(!DialogKind::Error.has_edit_box());
|
||||
assert!(DialogKind::Realmlist.has_edit_box());
|
||||
assert_eq!(DialogKind::Status.buttons(&strings), ("Cancel", None));
|
||||
assert_eq!(DialogKind::Error.buttons(&strings), ("Okay", None));
|
||||
assert_eq!(
|
||||
DialogKind::Realmlist.buttons(&strings),
|
||||
("Okay", Some("Cancel")),
|
||||
);
|
||||
}
|
||||
|
||||
/// Opening the editor seats the current address in the box, selected whole — so typing a new
|
||||
/// server replaces the old one instead of appending to it.
|
||||
#[test]
|
||||
fn opening_the_editor_preselects_the_current_address() {
|
||||
let mut dialog = LoginDialog::default();
|
||||
dialog.open_realmlist("Address of realm list server", "logon.example.org");
|
||||
assert_eq!(dialog.kind, Some(DialogKind::Realmlist));
|
||||
assert_eq!(dialog.edit.text, "logon.example.org");
|
||||
assert_eq!(
|
||||
dialog.edit.selected_text().as_deref(),
|
||||
Some("logon.example.org")
|
||||
);
|
||||
assert_eq!(dialog.edit.max_letters, crate::realmlist::MAX_LETTERS);
|
||||
assert!(!dialog.edit.password, "an address is not a secret");
|
||||
}
|
||||
|
||||
/// The code→string map quotes the client's own strings for the vmangos-verified rows.
|
||||
#[test]
|
||||
fn fail_text_maps_the_verified_codes() {
|
||||
@@ -962,6 +1241,10 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<Time>()
|
||||
.init_resource::<LoginForm>()
|
||||
// The clock now asks which box owns the focus (1667): a dialog with an edit box takes
|
||||
// it. No dialog is open here, so the form's box keeps it — which is the case this
|
||||
// asserts.
|
||||
.init_resource::<LoginDialog>()
|
||||
.add_systems(Update, tick_login_caret);
|
||||
|
||||
let past_the_period = |app: &mut App| {
|
||||
|
||||
@@ -32,6 +32,9 @@ use benilla_assets::WorldAssets;
|
||||
use super::{ClientState, DialogKind, Field, LoginForm};
|
||||
|
||||
const SCREEN_Z: i32 = 1100;
|
||||
/// `GlueFontDisableSmall`'s color (GlueFonts.xml) — the grey the reference's own
|
||||
/// `AccountLoginRealmName` readout draws in.
|
||||
const DISABLED_GREY: Color = Color::srgb(0.5, 0.5, 0.5);
|
||||
/// `DEFAULT_TOOLTIP_COLOR` (AccountLogin.lua): the edit boxes' backdrop tint (border rgb, bg rgb).
|
||||
const BOX_BORDER: Color = Color::srgb(0.8, 0.8, 0.8);
|
||||
const BOX_FILL: Color = Color::srgb(0.09, 0.09, 0.09);
|
||||
@@ -44,8 +47,15 @@ pub(crate) enum LoginAction {
|
||||
Login,
|
||||
Quit,
|
||||
ToggleSave,
|
||||
/// The dialog's one button (Cancel / Okay — [`super::drive_dialog`]'s).
|
||||
/// Open the realmlist editor (decision 1667) — on the button and on the address readout under
|
||||
/// it, so clicking the address you want to change does what it looks like it does.
|
||||
Realmlist,
|
||||
/// The dialog's first button — Cancel on the status dialog, Okay on the other two
|
||||
/// ([`super::drive_dialog`]'s; the ref's `GlueDialogButton1`).
|
||||
Dialog,
|
||||
/// The dialog's second button, on the kinds that declare one (`GlueDialogButton2` — Cancel on
|
||||
/// the realmlist editor).
|
||||
Dialog2,
|
||||
}
|
||||
|
||||
/// Root of the login screen (despawned whole on exit); `with_art` mirrors the select screen's
|
||||
@@ -73,6 +83,14 @@ pub(super) struct CheckHilight;
|
||||
/// The dialog's message text (updated in place on stage changes).
|
||||
#[derive(Component)]
|
||||
pub(super) struct DialogText;
|
||||
/// The dialog's edit box row items — the ref's `GlueDialogEditBox`, painted from
|
||||
/// [`super::LoginDialog::edit`] by [`refresh_dialog_box`].
|
||||
#[derive(Component, Clone)]
|
||||
pub(super) struct DialogEditText;
|
||||
/// The realmlist readout under the button — the ref's own `AccountLoginRealmName` slot, rewritten
|
||||
/// in place by [`refresh_realmlist`] when the address changes.
|
||||
#[derive(Component)]
|
||||
pub(super) struct RealmlistReadout;
|
||||
|
||||
/// Spawn the screen tree once its prerequisites exist (the select screen's boot-order pattern:
|
||||
/// the INITIAL state's `OnEnter` fires before the MPQ chain / booth slots do) — and upgrade an
|
||||
@@ -89,6 +107,7 @@ pub(super) fn materialize_screen(
|
||||
mut add_mats: ResMut<Assets<crate::glue::add_material::AddUiMaterial>>,
|
||||
strings: Option<Res<GlueStrings>>,
|
||||
form: Res<LoginForm>,
|
||||
realmlist: Res<crate::realmlist::Realmlist>,
|
||||
window: Query<&Window, With<PrimaryWindow>>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
@@ -110,6 +129,7 @@ pub(super) fn materialize_screen(
|
||||
&art,
|
||||
strings.as_deref(),
|
||||
&form,
|
||||
&realmlist,
|
||||
&window,
|
||||
);
|
||||
}
|
||||
@@ -123,6 +143,7 @@ pub(super) fn materialize_screen(
|
||||
&art,
|
||||
strings.as_deref(),
|
||||
&form,
|
||||
&realmlist,
|
||||
&window,
|
||||
);
|
||||
}
|
||||
@@ -138,6 +159,7 @@ fn spawn_screen(
|
||||
art: &GlueArt,
|
||||
strings: Option<&GlueStrings>,
|
||||
form: &LoginForm,
|
||||
realmlist: &crate::realmlist::Realmlist,
|
||||
window: &Query<&Window, With<PrimaryWindow>>,
|
||||
) {
|
||||
let font = wow_font(assets);
|
||||
@@ -397,6 +419,76 @@ fn spawn_screen(
|
||||
);
|
||||
});
|
||||
|
||||
// **The realmlist control** (decision 1667) — benilla's, in the reference's coordinates.
|
||||
//
|
||||
// The bottom-right column of `AccountLogin.xml` is a stack anchored off the Quit button:
|
||||
// TOS sits BOTTOM ← `AccountLoginExitButton`'s TOP at (0, 80), with Credits and Cinematics
|
||||
// above it. Decision 0539 cut all three, so the slot is empty — and the reference hangs
|
||||
// `AccountLoginRealmName` off exactly that button (a 256-wide right-justified
|
||||
// `GlueFontDisableSmall` at TOPRIGHT ← its BOTTOMRIGHT, (−8, −10)), filled in
|
||||
// `AccountLogin_OnShow` from `GetServerName()`. So the authored layout already reserves a
|
||||
// button here with a server readout beneath it; we put ours in that slot rather than
|
||||
// inventing a spot.
|
||||
//
|
||||
// Resolved: Quit is BOTTOMRIGHT (−5, 29) at 150×38, so its top edge is bottom 67 and the
|
||||
// button lands at bottom 67 + 80 = 147, sharing Quit's right offset of 5. The readout's
|
||||
// top is 10 below the button's bottom (bottom 137 → top 768 − 137 = 631) and its right
|
||||
// edge is 8 further in (5 + 8 = 13).
|
||||
//
|
||||
// The caption is a bare literal, deliberately: the reference has no string for this,
|
||||
// because it has no such control. `CHANGE_REALM` was the near miss and is the wrong
|
||||
// word — it is the *character select* screen's button for picking another realm out of a
|
||||
// list already fetched, which is a different act from repointing the client at a different
|
||||
// logon server. "Realmlist" is what the file, the CVar and every private server's setup
|
||||
// page call it, in every locale.
|
||||
ui.spawn((Node {
|
||||
position_type: PositionType::Absolute,
|
||||
right: px(5.0),
|
||||
bottom: px(147.0),
|
||||
..default()
|
||||
},))
|
||||
.with_children(|c| {
|
||||
let btn = glue_button(
|
||||
c,
|
||||
art,
|
||||
&font,
|
||||
LoginAction::Realmlist,
|
||||
"Realmlist",
|
||||
150.0,
|
||||
38.0,
|
||||
GlueBtnKind::Small,
|
||||
s,
|
||||
);
|
||||
// `$WOW_HOST` owns the session, so the button reads disabled — the ref's own
|
||||
// `Enable()`/`Disable()` split, rendered by `glue_button_visuals`. Clicking it
|
||||
// still explains itself ([`super::login_input`]) rather than doing nothing.
|
||||
if realmlist.pinned_by_env() {
|
||||
c.commands()
|
||||
.entity(btn)
|
||||
.insert(crate::glue::widgets::GlueDisabled(true));
|
||||
}
|
||||
});
|
||||
// `AccountLoginRealmName`'s seat, right-justified by being anchored on its right edge.
|
||||
outlined_text(
|
||||
ui,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
right: px(13.0),
|
||||
top: px(631.0),
|
||||
..default()
|
||||
},
|
||||
(LoginAction::Realmlist, Button),
|
||||
RealmlistReadout,
|
||||
GlueText {
|
||||
text: realmlist.address(),
|
||||
size: 12.0, // GlueFontDisableSmall = GlueFontNormalSmall's 12, in grey
|
||||
color: DISABLED_GREY,
|
||||
wrap: false,
|
||||
},
|
||||
&font,
|
||||
s,
|
||||
);
|
||||
|
||||
// The Remember Account Name checkbox (20×20 at the resolved absolute (17, top 653) —
|
||||
// the ref anchors it under the Community button we cut; the spot is the same) + its
|
||||
// 10 px shadowed gold label at LEFT+24.
|
||||
@@ -515,6 +607,42 @@ pub(super) fn refresh_boxes(
|
||||
);
|
||||
}
|
||||
|
||||
/// The realmlist readout, rewritten in place when the address changes (the ref's own
|
||||
/// `AccountLoginRealmName:SetText`). `sync_outlines` carries the write into the eight outline
|
||||
/// copies, so the whole nine-string stack follows.
|
||||
///
|
||||
/// An unconditional compare rather than a `Res::is_changed` gate: it is one string comparison
|
||||
/// against one entity, and the gate would silently depend on the screen tree never being spawned
|
||||
/// **after** the last change to the resource — which is exactly what a rebuild on a window resize
|
||||
/// does. Cheap beats subtly order-dependent.
|
||||
pub(super) fn refresh_realmlist(
|
||||
realmlist: Res<crate::realmlist::Realmlist>,
|
||||
mut texts: Query<&mut Text, With<RealmlistReadout>>,
|
||||
) {
|
||||
for mut t in &mut texts {
|
||||
if t.0 != realmlist.address() {
|
||||
t.0 = realmlist.address().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint the dialog's edit box from [`super::LoginDialog::edit`], through the same
|
||||
/// [`paint_glue_field`] the two screen boxes use (decision 0704) — so the realmlist box gets the
|
||||
/// identical caret, selection highlight and scrolling.
|
||||
///
|
||||
/// Runs **after** [`super::drive_dialog`], which is what spawns the box: on the frame a dialog
|
||||
/// opens the entities do not exist until that system has run, and painting before it would show
|
||||
/// one frame of empty box.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn refresh_dialog_box(
|
||||
dialog: Res<super::LoginDialog>,
|
||||
mut boxes: Query<(&GlueFieldPart, Option<&mut Text>, &mut Visibility), With<DialogEditText>>,
|
||||
) {
|
||||
if dialog.kind.is_some_and(DialogKind::has_edit_box) {
|
||||
paint_glue_field(&dialog.edit, true, boxes.iter_mut());
|
||||
}
|
||||
}
|
||||
|
||||
/// The checkbox's visuals: the checked overlay tracks the form's save flag; the ADD hover ring
|
||||
/// tracks the button's interaction (the checkbox isn't a `GlueBtn`, so the shared pass skips it).
|
||||
#[allow(clippy::type_complexity)]
|
||||
@@ -551,9 +679,21 @@ pub(super) fn refresh_checkbox(
|
||||
}
|
||||
|
||||
/// Build the dialog tree (the ref's shared `GlueDialog`): the 512-wide `UI-DialogBox` backdrop
|
||||
/// centered on the screen, the message (`GlueFontNormalLarge`, wrapping at 440), and the one
|
||||
/// `GlueDialogButtonTemplate` 200×40 button — Cancel for the connecting status, Okay for an
|
||||
/// error. Content-sized vertically (the ref's own `GlueDialog_OnShow` resize, by layout).
|
||||
/// centered on the screen, the message (`GlueFontNormalLarge`, wrapping at 440), an optional edit
|
||||
/// box, and one or two `GlueDialogButtonTemplate` 200×40 buttons — Cancel for the connecting
|
||||
/// status, Okay for an error, Okay + Cancel over the box for the realmlist editor.
|
||||
/// Content-sized vertically (the ref's own `GlueDialog_OnShow` resize, by layout).
|
||||
///
|
||||
/// The edit box and the second button are both the reference's own (`GlueDialog.lua`'s
|
||||
/// `hasEditBox` and `button2`); benilla simply had no dialog that used either until 1667. The two
|
||||
/// authored geometries they bring with them: the button pair is Button1 BOTTOMRIGHT ← the
|
||||
/// backdrop's BOTTOM at (−6, 16) with Button2 LEFT ← Button1's RIGHT at (13, 0) — a centred pair
|
||||
/// with a 13 gap — and the box re-heights the backdrop to
|
||||
/// `16 + text + 8 + editbox + 8 + button + 16`, which the column below is already shaped as.
|
||||
///
|
||||
/// **One stated divergence:** the ref's `GlueDialogEditBox` is 130×32, sized for the short values
|
||||
/// its own dialogs ask for. A realmlist is a hostname, so ours is 300 wide inside the same 512
|
||||
/// backdrop; the height, insets and border treatment are the login screen's boxes unchanged.
|
||||
pub(super) fn spawn_dialog(
|
||||
commands: &mut Commands,
|
||||
art: &GlueArt,
|
||||
@@ -565,10 +705,8 @@ pub(super) fn spawn_dialog(
|
||||
) -> Entity {
|
||||
let px = |v: f32| Val::Px(v * s);
|
||||
let font = wow_font(assets);
|
||||
let caption = match kind {
|
||||
DialogKind::Status => strings.text("CANCEL", "Cancel"),
|
||||
DialogKind::Error => strings.text("OKAY", "Okay"),
|
||||
};
|
||||
let edit_font: Handle<Font> = assets.load("mpq://Fonts/ARIALN.ttf");
|
||||
let (caption, caption2) = kind.buttons(strings);
|
||||
commands
|
||||
.spawn((
|
||||
GlobalZIndex(1200), // over the screen's 1100
|
||||
@@ -629,18 +767,53 @@ pub(super) fn spawn_dialog(
|
||||
&font,
|
||||
s,
|
||||
);
|
||||
// The one button (GlueDialogButtonTemplate 200×40).
|
||||
glue_button(
|
||||
b,
|
||||
art,
|
||||
&font,
|
||||
LoginAction::Dialog,
|
||||
caption,
|
||||
200.0,
|
||||
40.0,
|
||||
GlueBtnKind::Dialog,
|
||||
s,
|
||||
);
|
||||
// The edit box, on the kinds that declare one (`hasEditBox`).
|
||||
if kind.has_edit_box() {
|
||||
glue_edit_box(
|
||||
b,
|
||||
art,
|
||||
&edit_font,
|
||||
(),
|
||||
DialogEditText,
|
||||
(300.0, 32.0),
|
||||
(BOX_BORDER, BOX_FILL),
|
||||
(15.0, 0.0, 0.0, 5.0), // the login boxes' TextInsets
|
||||
s,
|
||||
);
|
||||
}
|
||||
// The buttons (GlueDialogButtonTemplate 200×40) — one centred, or the authored
|
||||
// pair with its 13 gap.
|
||||
b.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: px(13.0),
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
glue_button(
|
||||
row,
|
||||
art,
|
||||
&font,
|
||||
LoginAction::Dialog,
|
||||
caption,
|
||||
200.0,
|
||||
40.0,
|
||||
GlueBtnKind::Dialog,
|
||||
s,
|
||||
);
|
||||
if let Some(caption2) = caption2 {
|
||||
glue_button(
|
||||
row,
|
||||
art,
|
||||
&font,
|
||||
LoginAction::Dialog2,
|
||||
caption2,
|
||||
200.0,
|
||||
40.0,
|
||||
GlueBtnKind::Dialog,
|
||||
s,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.id()
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::{send_login, LoginIntent};
|
||||
use super::Attempt;
|
||||
use crate::char_select::ClientState;
|
||||
use crate::net::{LoginAbandon, LoginFailedMessage, LoginSubmit};
|
||||
use crate::net::LoginFailedMessage;
|
||||
|
||||
/// Split `WOW_LOGIN_SMOKE=user:pass[:Character]` into its three fields. **Three-way, once**: a
|
||||
/// `split_once(':')` here would hand the character name to the password, which is exactly what it
|
||||
@@ -40,9 +40,7 @@ pub(crate) fn smoke_character(spec: &str) -> Option<String> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn debug_login_smoke(
|
||||
state: Res<State<ClientState>>,
|
||||
mut intent: ResMut<LoginIntent>,
|
||||
submit: Res<LoginSubmit>,
|
||||
abandon: Res<LoginAbandon>,
|
||||
mut attempt: Attempt,
|
||||
mut failures: MessageReader<LoginFailedMessage>,
|
||||
time: Res<Time>,
|
||||
mut exit: MessageWriter<AppExit>,
|
||||
@@ -56,7 +54,7 @@ pub(super) fn debug_login_smoke(
|
||||
let (user, pass, _) = smoke_spec(&spec);
|
||||
info!("login-smoke: submitting as {user}");
|
||||
let (user, pass) = (user.to_string(), pass.to_string());
|
||||
send_login(&mut intent, &submit, &abandon, &user, &pass, true);
|
||||
attempt.send(&user, &pass, true);
|
||||
*phase = 1;
|
||||
}
|
||||
1 => {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//!
|
||||
//! [`spawn_net`] starts a read thread with **two park points** (decisions 0193 + 0539): it first
|
||||
//! parks **pre-logon** on the credentials channel — the login screen's pause — then, once a
|
||||
//! [`LoginRequest`] walks logon → realm → world handshake (emitting [`SessionEvent::LoginStage`]s,
|
||||
//! [`LoginRequest`] (credentials *and* the realmlist to dial) walks logon → realm → world
|
||||
//! handshake (emitting [`SessionEvent::LoginStage`]s,
|
||||
//! and [`SessionEvent::LoginFailed`] + re-park on any pre-roster failure), it **parks at character
|
||||
//! select**: it emits the roster as a [`SessionEvent::CharacterList`] and blocks until the app
|
||||
//! answers with a guid over the pick channel. The pick sends `CMSG_PLAYER_LOGIN`, and the thread
|
||||
@@ -78,6 +79,12 @@ pub(crate) fn inbound_census() -> (u64, Option<u64>) {
|
||||
pub(crate) struct LoginRequest {
|
||||
pub(crate) user: String,
|
||||
pub(crate) pass: String,
|
||||
/// The realmlist to dial, `host[:port]` (decision 1667). **Per-attempt, exactly like the
|
||||
/// credentials beside it** — the login screen can now repoint the client between attempts, and
|
||||
/// an address travelling with its attempt means an edit made mid-dial cannot silently retarget
|
||||
/// the connection already in flight. It is also the only shape under which the abandon
|
||||
/// generation stays meaningful: what a cancel abandons is *this* attempt, at *that* server.
|
||||
pub(crate) host: String,
|
||||
pub(crate) generation: u64,
|
||||
}
|
||||
|
||||
@@ -105,11 +112,14 @@ pub(crate) struct PingClock {
|
||||
/// during an outage can't flood the log. Reset when a fresh writer arrives.
|
||||
const SEND_WARN_CAP: u32 = 8;
|
||||
|
||||
/// Connection parameters, from env (`WOW_HOST` as `host[:port]`, `WOW_CHAR`). Credentials are NOT here anymore
|
||||
/// (decision 0539): they arrive per-attempt over the login channel — the app's policy owns the
|
||||
/// env fast path and its `one`/`pone` defaults.
|
||||
/// What is left of the per-process connection parameters: `WOW_CHAR`, and nothing else.
|
||||
///
|
||||
/// Neither the credentials nor the **address** live here any more. 0539 moved the credentials onto
|
||||
/// each [`LoginRequest`]; decision 1667 moved the host the same way and for the same reason — it
|
||||
/// is now a setting the player edits on the login screen (`crate::realmlist`), so a value latched
|
||||
/// out of the environment once at spawn could only ever be stale. `$WOW_HOST` is still honoured;
|
||||
/// it is read where every other env-overridable setting is read, by `Realmlist::default()`.
|
||||
pub(super) struct NetConfig {
|
||||
host: String,
|
||||
/// `WOW_CHAR`, when explicitly set. Here it only names the create-if-empty character on a fresh
|
||||
/// account; as a *pick* it is app-side policy (`crate::char_select` auto-answers the roster with
|
||||
/// it — the dev fast path past the select screen).
|
||||
@@ -119,7 +129,6 @@ pub(super) struct NetConfig {
|
||||
impl NetConfig {
|
||||
pub(super) fn from_env() -> Self {
|
||||
NetConfig {
|
||||
host: std::env::var("WOW_HOST").unwrap_or_else(|_| "localhost".into()),
|
||||
character: std::env::var("WOW_CHAR").ok(),
|
||||
}
|
||||
}
|
||||
@@ -275,7 +284,7 @@ fn run(
|
||||
|
||||
// Logon (the dial + SRP6 exchange — one blocking sequence against realmd).
|
||||
stage(LoginStage::Connecting);
|
||||
let logon = match benilla_protocol::logon(&cfg.host, &req.user, &req.pass) {
|
||||
let logon = match benilla_protocol::logon(&req.host, &req.user, &req.pass) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
if canceled() {
|
||||
@@ -296,8 +305,8 @@ fn run(
|
||||
let world_addr = realm
|
||||
.as_ref()
|
||||
.map(|r| r.address.clone())
|
||||
// Strip any explicit auth `:port` off `WOW_HOST` — the fallback world port is its own.
|
||||
.unwrap_or_else(|| format!("{}:{}", host_port(&cfg.host, WORLD_PORT).0, WORLD_PORT));
|
||||
// Strip any explicit auth `:port` off the realmlist — the fallback world port is its own.
|
||||
.unwrap_or_else(|| format!("{}:{}", host_port(&req.host, WORLD_PORT).0, WORLD_PORT));
|
||||
|
||||
stage(LoginStage::Handshaking);
|
||||
let mut session = match WorldSession::connect(&world_addr, &req.user, logon.session_key) {
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//! The **realmlist** — the address benilla dials for the logon (realmd) handshake, and the one
|
||||
//! setting a player cannot start the game without (decision 1667).
|
||||
//!
|
||||
//! The reference client has no UI for this at all: it registers a CVar named `realmList` — help
|
||||
//! text *"Address of realm list server"*, default `us.logon.worldofwarcraft.com:3724` — and loads
|
||||
//! it from a plain-text `realmlist.wtf` beside the executable, which every private server's setup
|
||||
//! page tells you to open in Notepad. (All four strings are byte-verified in `WoW.exe`, adjacent
|
||||
//! in the string table at the CVar's registration site; wow-re `mpq/scratch/startup-order-A.md`
|
||||
//! row 62 records the same registration.) benilla keeps the **name, the `host[:port]` shape and
|
||||
//! the help string**, and replaces the text editor with a control on the login screen.
|
||||
//!
|
||||
//! **It is a CVar, in `config.toml` with every other setting — not a `realmlist.wtf` of our own.**
|
||||
//! The reference splits the file for reasons that are entirely its own (the installer and the
|
||||
//! patcher write the realmlist without touching a player's `Config.wtf`), and benilla has neither.
|
||||
//! Decision 0954's law is one folder and one config file; a second file would buy nothing and
|
||||
//! would fork the atomic-write, unknown-key-preservation and debounce machinery that already
|
||||
//! exists. `GetCVar("realmList")` answers, which is also what the reference's own console does.
|
||||
//!
|
||||
//! **`$WOW_HOST` still wins for the session** and never reaches the file — the same law
|
||||
//! `WOW_UI_SCALE`/`WOW_FARCLIP` run under (`crate::cvars`' module doc). Every probe, smoke run and
|
||||
//! harness leg sets it, so the env path is the one that must not change behaviour: its value is
|
||||
//! taken **verbatim**, not through [`normalize`], so nothing that connects today can start
|
||||
//! failing a syntax check.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Installs [`Realmlist`]. Its own plugin (rather than a line in `CvarPlugin`) because every other
|
||||
/// CVar knob resource is owned by the module it belongs to, and because the resource has to exist
|
||||
/// before `cvars::load_config` applies the saved value at `Startup` — `lib.rs` orders it there.
|
||||
pub(crate) struct RealmlistPlugin;
|
||||
|
||||
impl Plugin for RealmlistPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<Realmlist>();
|
||||
}
|
||||
}
|
||||
|
||||
/// The CVar name — the reference's own spelling, `realmList`.
|
||||
pub(crate) const CVAR_REALMLIST: &str = "realmList";
|
||||
|
||||
/// benilla's registered default.
|
||||
///
|
||||
/// **Not the reference's** `us.logon.worldofwarcraft.com:3724`, which is a knowing divergence and
|
||||
/// a small one: that host has not resolved since 2019, so shipping it would mean every first
|
||||
/// launch begins with a DNS failure. benilla is a client for servers you run or choose, and the
|
||||
/// one it can assume is the one on the machine it is running on — which is also what every
|
||||
/// existing `WOW_HOST`-less dev and capture run already dials.
|
||||
pub(crate) const DEFAULT_REALMLIST: &str = "localhost";
|
||||
|
||||
/// The dialog box's `letters` cap. The reference's login boxes are 16 (`AccountLogin.xml`); a
|
||||
/// hostname needs far more, and 64 covers any real DNS name (253 is the protocol limit, but
|
||||
/// nothing a player types by hand approaches it) while still bounding the field.
|
||||
pub(crate) const MAX_LETTERS: usize = 64;
|
||||
|
||||
/// The address the next logon attempt dials, as `host[:port]` — [`benilla_protocol::host_port`]
|
||||
/// supplies [`benilla_protocol::AUTH_PORT`] when the port is left off.
|
||||
///
|
||||
/// The session's live value, so a change takes effect on the **next attempt** with no relaunch.
|
||||
/// It is deliberately not read by the IO thread: the host travels on each
|
||||
/// [`crate::net::LoginRequest`], exactly as the credentials have since decision 0539 — an attempt
|
||||
/// carries everything about itself, and a mid-flight edit cannot repoint the attempt already on
|
||||
/// the wire.
|
||||
#[derive(Resource, Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct Realmlist {
|
||||
address: String,
|
||||
/// `$WOW_HOST` owns this session. The screen still shows the address (a harness run that
|
||||
/// dialed the wrong server should say so on its face), but the control is disabled and
|
||||
/// nothing is written to `config.toml`.
|
||||
pinned_by_env: bool,
|
||||
}
|
||||
|
||||
impl Default for Realmlist {
|
||||
fn default() -> Self {
|
||||
match std::env::var("WOW_HOST") {
|
||||
// Verbatim, not normalized — see the module doc.
|
||||
Ok(host) if !host.trim().is_empty() => Realmlist {
|
||||
address: host,
|
||||
pinned_by_env: true,
|
||||
},
|
||||
_ => Realmlist {
|
||||
address: DEFAULT_REALMLIST.to_string(),
|
||||
pinned_by_env: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Realmlist {
|
||||
/// A realmlist at `address` with **no env pin**, whatever the ambient `$WOW_HOST` says.
|
||||
///
|
||||
/// For tests only, and it exists because [`Default`] reads the environment: a suite run from a
|
||||
/// shell that exports `WOW_HOST` (which every probe recipe in this repo does) would otherwise
|
||||
/// assert against that shell's value and refuse every write.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn unpinned(address: &str) -> Self {
|
||||
Realmlist {
|
||||
address: address.to_string(),
|
||||
pinned_by_env: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the next attempt dials.
|
||||
pub(crate) fn address(&self) -> &str {
|
||||
&self.address
|
||||
}
|
||||
|
||||
/// Whether `$WOW_HOST` pinned this session (the control is disabled, nothing persists).
|
||||
pub(crate) fn pinned_by_env(&self) -> bool {
|
||||
self.pinned_by_env
|
||||
}
|
||||
|
||||
/// Point at `address` — already through [`normalize`]. Ignored while pinned by the env, so a
|
||||
/// stray `SetCVar` cannot repoint a harness leg mid-run.
|
||||
pub(crate) fn set(&mut self, address: &str) {
|
||||
if self.pinned_by_env || self.address == address {
|
||||
return;
|
||||
}
|
||||
self.address = address.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize what a player typed or pasted into a `host[:port]`, or `None` if there is nothing
|
||||
/// usable in it.
|
||||
///
|
||||
/// **Syntax only, never reachability** — the deliberate line every comparable client draws
|
||||
/// (Veloren, ClassiCube, Terraria, Minecraft): a name that does not resolve, a closed port and a
|
||||
/// firewalled box are all indistinguishable without dialing, and a pre-connect probe would report
|
||||
/// a false negative on exactly the LAN/VPN-hosted vmangos box this client exists for. Those
|
||||
/// failures surface where they always have — the authored `LOGIN_FAILED` dialog, with the address
|
||||
/// on screen behind it.
|
||||
///
|
||||
/// It accepts a **pasted `realmlist.wtf` line** verbatim (`SET realmlist "logon.example.org"`),
|
||||
/// because that is the literal string every private server's setup page tells a player to copy,
|
||||
/// and pasting it is what they will try first.
|
||||
pub(crate) fn normalize(input: &str) -> Option<String> {
|
||||
let mut s = input.trim();
|
||||
|
||||
// `SET realmlist <value>` / `set realmlist = <value>` — the .wtf line, unwrapped to its value.
|
||||
if let Some(rest) = strip_prefix_ci(s, "set") {
|
||||
if rest.starts_with(|c: char| c.is_whitespace()) {
|
||||
if let Some(value) = strip_prefix_ci(rest.trim_start(), "realmlist") {
|
||||
s = value.trim_start().strip_prefix('=').unwrap_or(value).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
// One matched pair of quotes (the .wtf line quotes its value); `trim_matches` would eat a
|
||||
// run of them, which is a different string than the one the player pasted.
|
||||
if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
|
||||
s = &s[1..s.len() - 1];
|
||||
}
|
||||
let s = s.trim();
|
||||
|
||||
if s.is_empty() || s.chars().count() > MAX_LETTERS {
|
||||
return None;
|
||||
}
|
||||
// No whitespace and no control characters: an address with a space in it is a paste that
|
||||
// brought a second word along, not a host.
|
||||
if s.chars().any(|c| c.is_whitespace() || c.is_control()) {
|
||||
return None;
|
||||
}
|
||||
// Mirror `host_port`'s own split exactly (`benilla-protocol`): a **single** colon means the
|
||||
// suffix was meant as a port, so a non-numeric one is a typo worth naming here rather than a
|
||||
// DNS failure thirty seconds later. Two or more colons is an IPv6 literal, which `host_port`
|
||||
// deliberately leaves intact — so this leaves it intact too rather than inventing a second,
|
||||
// stricter law for the same string.
|
||||
if let Some((host, port)) = s.rsplit_once(':') {
|
||||
if !host.contains(':') && (host.is_empty() || port.parse::<u16>().is_err()) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(s.to_string())
|
||||
}
|
||||
|
||||
/// `s` without `prefix`, matched case-insensitively. `str::get` returns `None` on a non-boundary
|
||||
/// index, so a multi-byte character straddling the split can never be sliced through.
|
||||
fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
|
||||
s.get(..prefix.len())
|
||||
.filter(|head| head.eq_ignore_ascii_case(prefix))
|
||||
.map(|_| &s[prefix.len()..])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_bare_host_passes_through() {
|
||||
assert_eq!(normalize("localhost").as_deref(), Some("localhost"));
|
||||
assert_eq!(
|
||||
normalize(" logon.example.org ").as_deref(),
|
||||
Some("logon.example.org"),
|
||||
);
|
||||
assert_eq!(
|
||||
normalize("127.0.0.1:3725").as_deref(),
|
||||
Some("127.0.0.1:3725")
|
||||
);
|
||||
}
|
||||
|
||||
/// The line a private server's setup page tells you to paste into `realmlist.wtf` — in every
|
||||
/// spelling those pages actually use.
|
||||
#[test]
|
||||
fn a_pasted_wtf_line_is_unwrapped() {
|
||||
for line in [
|
||||
r#"SET realmlist "logon.example.org""#,
|
||||
r#"set realmlist "logon.example.org""#,
|
||||
"SET realmlist logon.example.org",
|
||||
r#"set realmlist = "logon.example.org""#,
|
||||
r#" SET realmlist "logon.example.org" "#,
|
||||
] {
|
||||
assert_eq!(
|
||||
normalize(line).as_deref(),
|
||||
Some("logon.example.org"),
|
||||
"unwrapping {line:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A host that merely *starts* with the letters of the prefix is not a .wtf line.
|
||||
#[test]
|
||||
fn a_host_named_like_the_prefix_is_left_alone() {
|
||||
assert_eq!(
|
||||
normalize("settings.example.org").as_deref(),
|
||||
Some("settings.example.org")
|
||||
);
|
||||
assert_eq!(normalize("set").as_deref(), Some("set"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_usable_is_rejected() {
|
||||
assert_eq!(normalize(""), None);
|
||||
assert_eq!(normalize(" "), None);
|
||||
assert_eq!(normalize(r#"SET realmlist """#), None);
|
||||
// A paste that brought a second word along.
|
||||
assert_eq!(normalize("logon.example.org and more"), None);
|
||||
// Longer than the box can hold.
|
||||
assert_eq!(normalize(&"a".repeat(MAX_LETTERS + 1)), None);
|
||||
}
|
||||
|
||||
/// The port half of `host_port`'s law, enforced forward: a single colon means a port.
|
||||
#[test]
|
||||
fn a_single_colon_must_carry_a_real_port() {
|
||||
assert_eq!(normalize("logon.example.org:notaport"), None);
|
||||
assert_eq!(normalize("logon.example.org:"), None);
|
||||
assert_eq!(normalize("logon.example.org:99999"), None); // past u16
|
||||
assert_eq!(normalize(":3724"), None); // no host
|
||||
assert_eq!(
|
||||
normalize("logon.example.org:3724").as_deref(),
|
||||
Some("logon.example.org:3724"),
|
||||
);
|
||||
}
|
||||
|
||||
/// An IPv6 literal keeps `host_port`'s own answer: more than one colon is a raw address and
|
||||
/// comes back intact, with the default port.
|
||||
#[test]
|
||||
fn an_ipv6_literal_is_left_intact() {
|
||||
assert_eq!(normalize("::1").as_deref(), Some("::1"));
|
||||
assert_eq!(normalize("fe80::1").as_deref(), Some("fe80::1"));
|
||||
// And what normalize passes is what the protocol splits.
|
||||
assert_eq!(
|
||||
benilla_protocol::host_port("::1", benilla_protocol::AUTH_PORT),
|
||||
("::1", benilla_protocol::AUTH_PORT),
|
||||
);
|
||||
}
|
||||
|
||||
/// Every value this module can hand out survives `host_port` — the one consumer downstream.
|
||||
#[test]
|
||||
fn the_default_is_a_host_the_protocol_can_split() {
|
||||
let (host, port) =
|
||||
benilla_protocol::host_port(DEFAULT_REALMLIST, benilla_protocol::AUTH_PORT);
|
||||
assert_eq!(host, "localhost");
|
||||
assert_eq!(port, benilla_protocol::AUTH_PORT);
|
||||
}
|
||||
|
||||
/// A pinned session refuses to be repointed — the harness guard.
|
||||
#[test]
|
||||
fn an_env_pinned_realmlist_ignores_writes() {
|
||||
let mut r = Realmlist {
|
||||
address: "harness.example.org".into(),
|
||||
pinned_by_env: true,
|
||||
};
|
||||
r.set("somewhere.else.org");
|
||||
assert_eq!(r.address(), "harness.example.org");
|
||||
assert!(r.pinned_by_env());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unpinned_realmlist_takes_writes() {
|
||||
let mut r = Realmlist {
|
||||
address: DEFAULT_REALMLIST.into(),
|
||||
pinned_by_env: false,
|
||||
};
|
||||
r.set("logon.example.org");
|
||||
assert_eq!(r.address(), "logon.example.org");
|
||||
}
|
||||
}
|
||||
@@ -195,11 +195,11 @@ fn region_node_hash(data: &RegionData) -> u64 {
|
||||
/// leaves the axis at zero — which is the client's answer too when a texture has no `CGxTex*`
|
||||
/// at all.
|
||||
///
|
||||
/// **This is the texture half of 1349 §4 only.** A `CSimpleFontString`'s span is likewise derived
|
||||
/// (its measured extent, floored at one unit — `0x772930`), and our floor is still the zero-span
|
||||
/// collapse the sweep applies below; that half, and deleting the owner-edge fallback the derived
|
||||
/// spans make redundant, stay 1349's own arc.
|
||||
fn content_span(
|
||||
/// This is the TEXTURE half. A `CSimpleFontString`'s span is derived the same way and lives in
|
||||
/// the sweep itself, because it needs the measure the sweep already folds in
|
||||
/// ([`FONTSTRING_MIN_SPAN`]). Between them they are what let the owner-edge fallback go
|
||||
/// (decision 1664): a region's span comes from its content, never from the frame it hangs on.
|
||||
pub(super) fn content_span(
|
||||
data: &RegionData,
|
||||
probe: Option<&crate::script::TextureSizeProbe>,
|
||||
) -> Option<(f32, f32)> {
|
||||
@@ -224,6 +224,14 @@ pub(super) fn texel_span(
|
||||
/// form — 64 texels, `0x44a900` stamping `[tex+0x144] = [tex+0x148] = 8` (decision 1349 §1).
|
||||
const SOLID_TEXTURE_TEXELS: f32 = 8.0;
|
||||
|
||||
/// The floor under a **FontString's** derived span, in FrameXML units —
|
||||
/// `CSimpleFontString::GetWidth 0x772930` / `GetHeight 0x772a60` end
|
||||
/// `fld 1-unit; fcomp candidate; test ah,0x5; jp` and return the floor when the candidate is
|
||||
/// smaller (wow-re `region-size-fallback.md` §3, `tooltip-blank-line-height.md` §3). One unit,
|
||||
/// not one line: the reference's own blank tooltip spacer is `" \n"` — a real one-line string —
|
||||
/// and this floor is what a *genuinely* empty string reads back as, never `0.0`.
|
||||
pub(super) const FONTSTRING_MIN_SPAN: f32 = 1.0;
|
||||
|
||||
/// [`LayoutScope::node_of`] holds one roster index per id, and the two rosters are separate
|
||||
/// (`plan` for frames, `regions` for regions) — this bit says which one. Node counts run in the
|
||||
/// thousands, so the top bit is free forever.
|
||||
@@ -1126,28 +1134,9 @@ impl UiScript {
|
||||
if prof {
|
||||
n_regions_swept += 1;
|
||||
}
|
||||
// An owner with NO resolved rect does not disqualify its regions. `owner_rect`
|
||||
// is only the fallback for the axes this region's own anchors do not pin (see the
|
||||
// two `axis(..)` calls below) — a region anchored fully to some OTHER frame needs
|
||||
// nothing from its owner, and the reference resolves it.
|
||||
//
|
||||
// Skipping here made a whole shape silently invisible: a bare container frame
|
||||
// (`CreateFrame("Frame", n, UIParent)` with no size and no SetPoint) holding a
|
||||
// region anchored elsewhere. That is ordinary addon code — MapCoords builds three
|
||||
// of them, and its world-map coordinate readout computed the right string every
|
||||
// frame and was never positioned, with no error anywhere.
|
||||
//
|
||||
// It is only the FALLBACK, though, and an owner with no rect cannot supply one: an
|
||||
// unpinned axis on an unpositioned owner has nothing to fall back TO, and the
|
||||
// region is unresolvable exactly as its owner is. Standing a zero rect in there
|
||||
// instead put the region at the SCREEN ORIGIN, where a template's sibling-chained
|
||||
// textures resolve off it into real on-screen geometry — the stray dropdown capsule
|
||||
// at the bottom of the screen when the social pane opened (B264:
|
||||
// `BenillaFriendsDropDown` carries no anchors, exactly as the reference's
|
||||
// `FriendsDropDown` does, and the reference draws nothing). `None` keeps the
|
||||
// MapCoords fix — a region its own anchors fully pin never consults this — without
|
||||
// inventing a position for a frame that has none.
|
||||
let owner_rect = resolved.get(&owner).copied();
|
||||
// The owner supplies the region's SCALE and nothing else. It used to supply
|
||||
// fallback edges too; it does not any more (decision 1664) — see the `axis`
|
||||
// closure below for why, and what the two shapes that leaned on it get instead.
|
||||
let scale = arena.frame(owner).map(|f| f.effective_scale).unwrap_or(1.0);
|
||||
// A FontString with no explicit height takes its host-measured wrapped size
|
||||
// (the measure round-trip — the client's layout↔font-engine seam).
|
||||
@@ -1182,6 +1171,18 @@ impl UiScript {
|
||||
width = m.w;
|
||||
}
|
||||
}
|
||||
// …and then the FLOOR. `CSimpleFontString::GetWidth 0x772930` takes the
|
||||
// authored value when it is not exactly `0.0` and the measured extent otherwise,
|
||||
// then returns `max(candidate, ONE FrameXML unit)` — the floor sits past the
|
||||
// `jp 0x772957` that skips the measure, so it applies to BOTH legs. `0x772a60` is
|
||||
// the identical body for height. **A FontString's span is therefore never `0.0`**
|
||||
// (wow-re `region-size-fallback.md` §3), which is what makes a single-anchored
|
||||
// FontString always resolve — and what retires the zero-span collapse this sweep
|
||||
// used to apply in the `axis` closure below.
|
||||
if is_fontstring {
|
||||
width = width.max(FONTSTRING_MIN_SPAN);
|
||||
height = height.max(FONTSTRING_MIN_SPAN);
|
||||
}
|
||||
// A TEXTURE's unset axis is the client's *other* content-derived span: the art's
|
||||
// own texel extent ([`content_span`]). It is the same shape as the FontString
|
||||
// measure above — the reference's size getter is virtual, and both region classes
|
||||
@@ -1220,43 +1221,38 @@ impl UiScript {
|
||||
// on a performance fix.
|
||||
(id != SCREEN).then(|| solver.rect(id)).flatten()
|
||||
});
|
||||
// Unpinned edges: textures inherit the owner's edge (the v1 region model,
|
||||
// decision 0068). A FONTSTRING's implicit extent is its measured TEXT, and
|
||||
// empty/unmeasured text measures zero — so an unpinned edge COLLAPSES onto its
|
||||
// pinned opposite (a zero span), never onto the owner's edge. The owner
|
||||
// fallback there stretched an empty tooltip line to the plate's bottom, and
|
||||
// the line chain (each line anchors to the previous line's resolved bottom)
|
||||
// marched every later line out of the plate. An axis with NO pinned edge keeps
|
||||
// the owner fallback (nothing to collapse onto).
|
||||
// **An axis resolves, or the region does not** — there is no owner-edge
|
||||
// fallback and no zero-span collapse (decision 1664, from wow-re's byte-verified
|
||||
// `region-size-fallback.md` §7). A complete memory-operand enumeration of the real
|
||||
// resolver core `[0x7671a0, 0x76761f)` finds no parent or owner pointer in it at
|
||||
// all: the only fallbacks are the object's own nine anchor slots and
|
||||
// `combineEdge`/`combineCenter` over its own opposite edge/centre ± its own size.
|
||||
// `assemble 0x767a20` returns 0 the moment any edge is still UNSET, and
|
||||
// `0x768d20` latches the region unresolvable.
|
||||
//
|
||||
// `None` = this axis needs the owner and the owner has no rect (above): the region
|
||||
// is unresolvable, like its owner.
|
||||
// The two shapes that leaned on the fallback both have their own answer now, which
|
||||
// is why deleting it is a fidelity fix and not a hole:
|
||||
//
|
||||
// **The owner fallback is OURS, not the client's** (decision 1349, from wow-re's
|
||||
// byte-verified `region-size-fallback.md`): a complete operand enumeration of the
|
||||
// real resolver `[0x7671a0, 0x76761f)` finds no parent pointer in it at all. The
|
||||
// client reaches the same answer for the case that matters — a region with a parent
|
||||
// and NO anchors — at *attach* time instead, via an implicit `SetAllPoints(parent)`
|
||||
// (`0x7701c0` / `0x771480`), and reaches a DIFFERENT answer for every other shape,
|
||||
// because its size getters are virtual and content-derived (see `layout::size_span`).
|
||||
// That is why an authored-zero-width texture with one anchor resolves to its OWNER's
|
||||
// width here and to 8 points there. 1349 §4 carries the replacement and its scope.
|
||||
let axis = |lo: Option<f32>,
|
||||
hi: Option<f32>,
|
||||
owner: Option<(f32, f32)>|
|
||||
-> Option<(f32, f32)> {
|
||||
match (is_fontstring, lo, hi) {
|
||||
(true, Some(l), None) => Some((l, l)),
|
||||
(true, None, Some(h)) => Some((h, h)),
|
||||
(_, Some(l), Some(h)) => Some((l, h)),
|
||||
_ => {
|
||||
let (olo, ohi) = owner?;
|
||||
Some((lo.unwrap_or(olo), hi.unwrap_or(ohi)))
|
||||
}
|
||||
// * **No anchors at all.** The client anchors those at CREATION, per region type —
|
||||
// a texture gets `SetAllPoints(parent)`, a FontString one justify-selected
|
||||
// `SetPoint` — so by the time the resolver sees them they are pinned like
|
||||
// anything else ([`super::region::implicit_creation_anchor`], decision 1310).
|
||||
// The ones that reach here still unanchored are the shapes the reference leaves
|
||||
// rect-less too: a templateless `CreateTexture`, a title region, a plain frame.
|
||||
// * **A pinned edge with a zero span.** The span is content-derived on both region
|
||||
// classes — the texture's texel extent above, the FontString's measured extent
|
||||
// floored at one unit — so `combineEdge`'s zero-span leg is nearly unreachable
|
||||
// for a region at all (`region-size-fallback.md` §4). What is left is a texture
|
||||
// with no art whatever, which the reference resolves to nothing and draws as a
|
||||
// degenerate all-zero quad.
|
||||
let axis = |lo: Option<f32>, hi: Option<f32>| -> Option<(f32, f32)> {
|
||||
match (lo, hi) {
|
||||
(Some(l), Some(h)) => Some((l, h)),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let vertical = axis(edges[0], edges[2], owner_rect.map(|o| (o.bottom, o.top)));
|
||||
let horizontal = axis(edges[1], edges[3], owner_rect.map(|o| (o.left, o.right)));
|
||||
let vertical = axis(edges[0], edges[2]);
|
||||
let horizontal = axis(edges[1], edges[3]);
|
||||
let (Some((bottom, top)), Some((left, right))) = (vertical, horizontal) else {
|
||||
// Unresolvable: drop any rect it used to have, the way the frame pass does —
|
||||
// a region that stops resolving must stop drawing, not keep a stale position.
|
||||
|
||||
@@ -772,28 +772,123 @@ pub(super) fn region_set_point(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The measured extent a FontString reports, falling back to an explicit `SetSize`.
|
||||
/// `Region:GetWidth()`/`GetHeight()` — **the virtual size getters**, per region class.
|
||||
///
|
||||
/// Hoisted out of the text cluster when this file split (0716): `GetStringWidth`/`GetStringHeight`
|
||||
/// live in `region::text` and `GetWidth`/`GetHeight` in `region::layout`, and both read it.
|
||||
/// The Lua bindings are not field reads. `GetWidth 0x7a1e00` ends `ff 52 1c` and
|
||||
/// `GetHeight 0x7a2030` ends `ff 52 20` (wow-re `minimap-ping-law.md`): both dispatch through the
|
||||
/// receiver's own **geometry vtable** — slots `+0x1c`/`+0x20` — which is the identical call the
|
||||
/// rect resolver makes (`0x767579`). So a region's Lua-visible size and the size its rect is built
|
||||
/// from are one number BY CONSTRUCTION on the reference, and this function is how they are one
|
||||
/// number here (decision 1670; the resolver half is `layout::content_span` +
|
||||
/// `layout::FONTSTRING_MIN_SPAN`, and both are read from here).
|
||||
///
|
||||
/// | class | law |
|
||||
/// |---|---|
|
||||
/// | plain frame / title region | the flat authored field, `0x768420` / `0x768410`. No content. |
|
||||
/// | `CSimpleTexture` | `0x770720` / `0x770790`: authored when **not exactly `0.0`**, else the art's own texel extent, else `0.0`. |
|
||||
/// | `CSimpleFontString` | `0x772930` / `0x772a60`: authored when not `0.0`, else the cached measure — and then **floored at one FrameXML unit**. |
|
||||
///
|
||||
/// Two details of the FontString row are worth spelling out, because both were wrong here before
|
||||
/// and neither is guessable from the name:
|
||||
///
|
||||
/// * **The authored value WINS.** The old code preferred the measure and fell back to `SetSize`;
|
||||
/// the reference's `jp` at `0x77294a` skips the measure entirely when the authored value is
|
||||
/// non-zero. Per axis, not per region — `<Size x="290" y="0"/>` takes 290 from the author and
|
||||
/// the height from the text.
|
||||
/// * **The width is the NATURAL, unwrapped extent** — `0x772890`'s `[fs+0xfc]`, measured with no
|
||||
/// wrap constraint, the very cell `GetStringWidth 0x79e510` returns. The *height* is the other
|
||||
/// cell, `0x7729b0`'s `[fs+0x100]`, which IS the wrapped line count × line height. So a wrapped
|
||||
/// string reports a width wider than its own box and a height taller than one line, and that
|
||||
/// asymmetry is the client's (wow-re `fontstring-overflow.md` "The measurement echo").
|
||||
pub(super) fn measured_wh(lua: &Lua, this: &Table) -> mlua::Result<(f32, f32)> {
|
||||
let rh = region_handle_of(lua, this)?;
|
||||
// Same-tick measure when a host font engine is installed — see `region::text`'s `natural_w`.
|
||||
// A no-op for a Texture (not a FontString) and for an already-current measure.
|
||||
super::measure::ensure_measured(lua, rh);
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
let d = model.region_data.get(&rh);
|
||||
// The key carries the owner's effective_scale ([`RegionData::measure_key`]) — the same
|
||||
// recipe the request loop stamps, or every read under a SetScale'd owner reports stale.
|
||||
let scale = model
|
||||
.arena
|
||||
.region(rh)
|
||||
.and_then(|r| model.arena.frame(r.owner))
|
||||
.map(|f| f.effective_scale)
|
||||
.unwrap_or(1.0);
|
||||
let m = d.and_then(|d| d.measured.filter(|m| m.key == d.measure_key(scale)));
|
||||
let size = d.and_then(|d| d.size);
|
||||
let w = m.map(|m| m.w).or(size.map(|s| s.0)).unwrap_or(0.0);
|
||||
let h = m.map(|m| m.h).or(size.map(|s| s.1)).unwrap_or(0.0);
|
||||
Ok((w, h))
|
||||
Ok(virtual_span(&model, rh))
|
||||
}
|
||||
|
||||
/// [`measured_wh`]'s body, on a borrowed model — the form the engine's own callers want.
|
||||
pub(super) fn virtual_span(model: &Model, rh: RegionHandle) -> (f32, f32) {
|
||||
let Some(d) = model.region_data.get(&rh) else {
|
||||
return (0.0, 0.0);
|
||||
};
|
||||
let (aw, ah) = d.size.unwrap_or((0.0, 0.0));
|
||||
match model.arena.region(rh).map(|r| r.kind) {
|
||||
Some(RegionKind::FontString) => {
|
||||
// The key carries the owner's effective_scale ([`RegionData::measure_key`]) — the same
|
||||
// recipe the request loop stamps, or every read under a SetScale'd owner reports
|
||||
// stale. Unlike the layout sweep, the getter DOES key-check: the sweep holds a
|
||||
// last-known box so a line whose text just changed does not flicker for the frame the
|
||||
// re-measure is in flight, and a getter that did the same would hand a caller the
|
||||
// previous string's metric (the whisper header's latched inset).
|
||||
let scale = model
|
||||
.arena
|
||||
.region(rh)
|
||||
.and_then(|r| model.arena.frame(r.owner))
|
||||
.map(|f| f.effective_scale)
|
||||
.unwrap_or(1.0);
|
||||
let m = d.measured.filter(|m| m.key == d.measure_key(scale));
|
||||
// **The floor applies to a KNOWN extent, and a pending measure is not one.** On the
|
||||
// reference every extent is known — the getter measures inline — so `0.0` never comes
|
||||
// back and the question never arises. Ours can be *waiting*, which is not a size but
|
||||
// the absence of an answer, and several of our own convergence drivers read exactly
|
||||
// that: `BenillaGossipRow_Resize`, the tab fit and the quest panel all guard
|
||||
// `if h <= 0 then return end` and re-run from `OnUpdate` until the round-trip lands.
|
||||
// Flooring a pending measure to one unit hands them a number, so they stop waiting and
|
||||
// seat every row at 3px (`shipped_gossip_frame_drives_end_to_end` catches it).
|
||||
//
|
||||
// A genuinely EMPTY string is a different thing: its extent is known and it is zero, so
|
||||
// it floors — which is the case the reference's floor exists for. The layout sweep
|
||||
// floors unconditionally because a rect has to exist on every frame and nothing reads
|
||||
// it as a sentinel (decision 1664); the difference between the two is exactly this
|
||||
// pending state, which the reference does not have.
|
||||
//
|
||||
// "Known" is the same test the layout sweep applies: **empty text needs no
|
||||
// round-trip** — nothing ever measures an empty string, and its extent is known
|
||||
// without asking. So `""` is a known zero (⇒ one unit) while a pending measure on real
|
||||
// text is no answer at all (⇒ `0.0`), and the two are only indistinguishable if you
|
||||
// look at `measured` alone.
|
||||
let floor = super::layout::FONTSTRING_MIN_SPAN;
|
||||
let known = |from_measure: fn(&crate::script::types::MeasuredText) -> f32| {
|
||||
if d.text.as_deref().is_none_or(str::is_empty) {
|
||||
Some(0.0)
|
||||
} else {
|
||||
m.as_ref().map(from_measure)
|
||||
}
|
||||
};
|
||||
let w = if aw != 0.0 {
|
||||
aw.max(floor)
|
||||
} else {
|
||||
known(|m| m.natural_w).map_or(0.0, |v| v.max(floor))
|
||||
};
|
||||
let h = if ah != 0.0 {
|
||||
ah.max(floor)
|
||||
} else {
|
||||
known(|m| m.h).map_or(0.0, |v| v.max(floor))
|
||||
};
|
||||
(w, h)
|
||||
}
|
||||
Some(RegionKind::Texture) => {
|
||||
let texel = (aw == 0.0 || ah == 0.0)
|
||||
.then(|| super::layout::content_span(d, model.texture_size_probe.as_ref()))
|
||||
.flatten();
|
||||
(
|
||||
if aw != 0.0 {
|
||||
aw
|
||||
} else {
|
||||
texel.map_or(0.0, |t| t.0)
|
||||
},
|
||||
if ah != 0.0 {
|
||||
ah
|
||||
} else {
|
||||
texel.map_or(0.0, |t| t.1)
|
||||
},
|
||||
)
|
||||
}
|
||||
// A title region is a bare `CScriptRegion` — geometry vtable `0x81c9c8`, whose `+0x1c`
|
||||
// is the unoverridden `0x768420`. The flat field, like a frame.
|
||||
_ => (aw, ah),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,8 +92,11 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// metric — serving it is how the whisper header's `GetWidth()` latched the edit-box insets on
|
||||
// the previous header's width. A poll-until-nonzero caller (the chat header machine) now
|
||||
// converges on the RIGHT measure instead of settling on a stale one.
|
||||
// `GetWidth`/`GetHeight` prefer the measured extent, falling back to an explicit `SetSize` — the
|
||||
// real client's `SmallTextTooltipText:GetWidth()` idiom (ref-GameTooltip.xml l.63).
|
||||
// `GetWidth`/`GetHeight` are the neighbouring law and NOT this one ([`super::virtual_span`]):
|
||||
// they take the authored size first and the measure only on an axis authored `0` — and on that
|
||||
// axis the width they return is *this* number, because the reference reads both getters out of
|
||||
// the one cell `[fs+0xfc]` (`0x772890` is `0x79e510`'s callee). So the two agree exactly where
|
||||
// the reference makes them agree, and differ exactly where a width was declared.
|
||||
// GetStringWidth is the **natural, unwrapped** extent — never the declared box, and never the
|
||||
// wrapped one (wow-re `fontstring-overflow.md`, "The measurement echo": the reference's getter
|
||||
// re-measures the raw text with NO wrap constraint). Unlike `GetWidth` below it deliberately
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//!
|
||||
//! The smeared-merchant root cause: region `<Size>`/`<Anchors>` used to be dropped — a region either
|
||||
//! filled its owner or (with a size) drew centered, and its anchors were ignored. Regions now resolve
|
||||
//! through the same leaf math as frames, owner-relative, inheriting any edge their anchors don't pin.
|
||||
//! through the same leaf math as frames, off their own anchors and their own span; an edge nothing
|
||||
//! pins does NOT fall back to the owner's (decision 1664 retired that — the owner supplies scale and
|
||||
//! nothing else), because the span is content-derived and a pinned edge plus a span is a rect.
|
||||
|
||||
use super::common::script;
|
||||
use crate::layout::Rect;
|
||||
@@ -54,14 +56,13 @@ fn region_texture_anchored_topleft_resolves_exact_rect() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A FontString's implicit extent is its measured TEXT — an unpinned edge COLLAPSES onto its
|
||||
/// pinned opposite while the measure is pending (empty text never measures at all), never onto
|
||||
/// the owner's edge; only an axis with NO pinned edge keeps the v1 owner fallback. (The old
|
||||
/// owner-edge inheritance stretched an empty tooltip line to the plate's bottom and marched the
|
||||
/// line chain out of the plate — the live NPC-tooltip spill.) Textures keep the owner fallback:
|
||||
/// [`region_texture_anchored_topleft_resolves_exact_rect`]'s sized leg is unaffected either way.
|
||||
/// A FontString's implicit extent is its measured TEXT, **floored at one FrameXML unit**
|
||||
/// (`CSimpleFontString::GetWidth 0x772930` / `GetHeight 0x772a60` — decision 1664). So a
|
||||
/// single-anchored FontString whose measure has not landed is a 1×1 box seated on its anchor,
|
||||
/// not a collapse onto the pinned edge and not the owner's rect: the floor is what makes such a
|
||||
/// FontString **always resolve**, and it is why the resolver needs no owner-edge fallback at all.
|
||||
#[test]
|
||||
fn region_fontstring_unpinned_edge_collapses_until_measured() {
|
||||
fn region_fontstring_span_floors_at_one_unit_until_measured() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
@@ -77,9 +78,12 @@ fn region_fontstring_unpinned_edge_collapses_until_measured() {
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
// Pending measure: x collapses onto the pinned left (5); the y-axis has no pinned EDGE
|
||||
// (a LEFT point pins the y-CENTER only), so it keeps the owner fallback.
|
||||
assert_eq!(region_text_rect(&s, "Name"), Rect::new(0.0, 5.0, 50.0, 5.0));
|
||||
// Pending measure: both spans floor at one unit. x runs right from the pinned left (5→6);
|
||||
// a LEFT point pins the y-CENTER only (25), so y is the centre ± half a unit.
|
||||
assert_eq!(
|
||||
region_text_rect(&s, "Name"),
|
||||
Rect::new(24.5, 5.0, 25.5, 6.0)
|
||||
);
|
||||
// Measured, the rect is the text extent seated on the anchor: 40×12 around y-center 25.
|
||||
let answers: Vec<(u32, f32, f32, u64)> = s
|
||||
.fontstrings_needing_measure()
|
||||
@@ -94,6 +98,48 @@ fn region_fontstring_unpinned_edge_collapses_until_measured() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `ExhaustionLevelFillBar`'s exact shape, both ways (wow-re `region-size-fallback.md` §5, and
|
||||
/// the counterfactual it states): authored width **0**, one TOPLEFT anchor, and a `<Color>`.
|
||||
///
|
||||
/// The colour form installs a real 8×8 texture before any resolve (`0x7700a9` → `0x770360` →
|
||||
/// `0x44a900`), so `CSimpleTexture::GetWidth 0x770720` answers **8** for the authored zero and
|
||||
/// `combineEdge`'s first leg fires: RIGHT = LEFT + 8, and `assemble 0x767a20` returns 1. Strip the
|
||||
/// colour and there is no `CGxTex*` at all — the getter answers `0.0`, both `combineEdge` legs
|
||||
/// fail their `span != 0.0` test, and the rect never resolves. Our owner-edge fallback used to
|
||||
/// turn that second case into the owner's **full width** (1348's 1024-point white band); it is
|
||||
/// gone, so the two cases now differ by 8 points and a rect, exactly as they do on the reference.
|
||||
#[test]
|
||||
fn a_zero_width_solid_spans_eight_units_and_its_artless_twin_gets_no_rect() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "Owner")
|
||||
f:SetPoint("BOTTOMLEFT", 0, 0) -- owner [0, 0, 50, 100]
|
||||
f:SetSize(100, 50)
|
||||
Fill = f:CreateTexture("Fill", "BORDER")
|
||||
Fill:SetTexture(1, 1, 1, 1) -- the <Color> form: an 8x8 solid
|
||||
Fill:SetWidth(0); Fill:SetHeight(13)
|
||||
Fill:SetPoint("TOPLEFT", 0, 0)
|
||||
Bare = f:CreateTexture("Bare", "BORDER")
|
||||
Bare:SetWidth(0); Bare:SetHeight(13)
|
||||
Bare:SetPoint("TOPLEFT", 0, 0) -- same shape, no art at all
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s.run(
|
||||
r#"
|
||||
assert(Fill:GetLeft() == 0 and Fill:GetRight() == 8,
|
||||
"the solid spans its 8 texels, got " .. tostring(Fill:GetRight()))
|
||||
assert(Fill:GetTop() == 50 and Fill:GetBottom() == 37, "and its authored 13 of height")
|
||||
assert(Bare:GetLeft() == nil, "no art, no span, no rect — not the owner's width")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn templateless_lua_region_without_anchors_never_draws() {
|
||||
// Decision 1310 (wow-re `region-implicit-anchor.md`, VERIFIED): the creation-path implicit
|
||||
@@ -945,3 +991,108 @@ fn set_portrait_to_texture_is_a_global_taking_a_name() {
|
||||
.unwrap();
|
||||
assert!(s.errors().is_empty());
|
||||
}
|
||||
|
||||
/// **`Region:GetWidth`/`GetHeight` are the VIRTUAL getters** — the same content-derived law the
|
||||
/// rect resolver calls, because the Lua bindings dispatch through the same geometry-vtable slots
|
||||
/// (`GetWidth 0x7a1e00` ends `ff 52 1c`, `GetHeight 0x7a2030` ends `ff 52 20`; decision 1670).
|
||||
///
|
||||
/// Both halves of the FontString row, each of which we had backwards:
|
||||
///
|
||||
/// * the **authored** value wins per axis — `0x772930`'s `jp 0x77294a` skips the measure entirely
|
||||
/// when the authored width is not `0.0`, so `<Size x="300" y="0"/>` reports 300 from the author
|
||||
/// and the height from the text (`CharacterNameText` is exactly that, in the reference's own
|
||||
/// file), where we used to report the 37-point measure;
|
||||
/// * on an axis authored `0` the **width** is the NATURAL, unwrapped extent — the very cell
|
||||
/// `GetStringWidth` returns (`0x772890`'s `[fs+0xfc]`) — while the **height** is the *wrapped*
|
||||
/// one (`0x7729b0`'s `[fs+0x100]`). We used to report the laid-out width for both.
|
||||
#[test]
|
||||
fn the_size_getters_take_the_author_first_then_the_natural_width_and_wrapped_height() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "Owner")
|
||||
f:SetPoint("BOTTOMLEFT", 0, 0); f:SetSize(100, 50)
|
||||
Sized = f:CreateFontString("Sized", "ARTWORK")
|
||||
Sized:SetPoint("TOPLEFT"); Sized:SetWidth(300); Sized:SetText("Name")
|
||||
Auto = f:CreateFontString("Auto", "ARTWORK")
|
||||
Auto:SetPoint("TOPLEFT"); Auto:SetText("Name")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
// A wrapped measure: 40 wide as laid out, 24 tall over two lines, 90 unwrapped.
|
||||
let answers: Vec<(u32, f32, f32, f32, u64)> = s
|
||||
.fontstrings_needing_measure()
|
||||
.iter()
|
||||
.map(|r| (r.id, 40.0, 24.0, 90.0, r.key))
|
||||
.collect();
|
||||
s.set_measured_text(&answers);
|
||||
s.run(
|
||||
r#"
|
||||
assert(Sized:GetWidth() == 300, "the AUTHORED width wins, got " .. tostring(Sized:GetWidth()))
|
||||
assert(Sized:GetHeight() == 24, "and the un-authored height is the measure, got " .. tostring(Sized:GetHeight()))
|
||||
assert(Auto:GetWidth() == 90, "no author ⇒ the NATURAL width, got " .. tostring(Auto:GetWidth()))
|
||||
assert(Auto:GetWidth() == Auto:GetStringWidth(), "which is GetStringWidth's own cell")
|
||||
assert(Auto:GetHeight() == 24, "and the WRAPPED height, got " .. tostring(Auto:GetHeight()))
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The floor, and the state the reference does not have. `0x772930`/`0x772a60` end in a one-unit
|
||||
/// clamp, so a genuinely empty string reads back **1**, never `0.0`. But a measure that has not
|
||||
/// LANDED is not an extent at all — it is our async round-trip, which the reference has no
|
||||
/// equivalent of — and our own convergence drivers (`BenillaGossipRow_Resize` and the tab fit,
|
||||
/// which guard `if h <= 0 then return end` and re-run from `OnUpdate`) read that zero as
|
||||
/// "not yet". Flooring it would tell them to stop waiting. So: floor a known extent, not the
|
||||
/// absence of one.
|
||||
#[test]
|
||||
fn an_empty_string_reads_back_one_unit_and_a_pending_measure_reads_back_zero() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "Owner")
|
||||
f:SetPoint("BOTTOMLEFT", 0, 0); f:SetSize(100, 50)
|
||||
Pending = f:CreateFontString("Pending", "ARTWORK")
|
||||
Pending:SetPoint("TOPLEFT"); Pending:SetText("Name")
|
||||
Empty = f:CreateFontString("Empty", "ARTWORK")
|
||||
Empty:SetPoint("TOPLEFT"); Empty:SetText("")
|
||||
assert(Pending:GetHeight() == 0, "a pending measure is not a size, got " .. tostring(Pending:GetHeight()))
|
||||
assert(Empty:GetHeight() == 1, "an EMPTY string is one unit, got " .. tostring(Empty:GetHeight()))
|
||||
assert(Empty:GetWidth() == 1, "both axes")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// A TEXTURE's getters are the same virtual law with the other override behind them
|
||||
/// (`0x770720`/`0x770790`): the authored value when it is not exactly `0.0`, else the art's own
|
||||
/// texel extent, else `0.0` — and **no floor**, which only the FontString override carries. This
|
||||
/// is the getter half of 1662; before it, Lua saw `0` for a size the screen was already drawing.
|
||||
#[test]
|
||||
fn a_textures_getters_report_its_texel_span_on_an_unsized_axis() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_texture_size_probe(Box::new(|p| (p == "Interface\\Crest").then_some((128, 96))));
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "Owner")
|
||||
f:SetPoint("BOTTOMLEFT", 0, 0); f:SetSize(100, 50)
|
||||
Art = f:CreateTexture("Art", "ARTWORK")
|
||||
Art:SetTexture("Interface\\Crest"); Art:SetPoint("TOPLEFT")
|
||||
Half = f:CreateTexture("Half", "ARTWORK")
|
||||
Half:SetTexture("Interface\\Crest"); Half:SetPoint("TOPLEFT"); Half:SetHeight(13)
|
||||
Bare = f:CreateTexture("Bare", "ARTWORK")
|
||||
Bare:SetPoint("TOPLEFT")
|
||||
assert(Art:GetWidth() == 128 and Art:GetHeight() == 96, "the art's own texels")
|
||||
assert(Half:GetWidth() == 128 and Half:GetHeight() == 13, "per AXIS: authored 13 wins, width still derived")
|
||||
assert(Bare:GetWidth() == 0, "no art, no span — and no floor on a texture")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
@@ -642,11 +642,14 @@ fn an_unsized_image_reserves_its_arts_texel_height_unless_it_is_floated() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A VM with no host oracle has no art to measure, so a zero-size image stays exactly where it
|
||||
/// always was. The engine-less truth, stated so the fix cannot be read as "the engine now knows
|
||||
/// texel sizes".
|
||||
/// A VM with no host oracle has no art to measure, so a zero-size image has **no span on either
|
||||
/// axis** — and with one corner pinned and nothing to add to it, it does not resolve at all. That
|
||||
/// is the reference's own answer for a texture with no `CGxTex*`: `combineEdge`'s legs both fail
|
||||
/// their `span != 0.0` test, `assemble 0x767a20` returns 0, and the texture emits a degenerate
|
||||
/// all-zero quad (wow-re `region-size-fallback.md` §5's counterfactual and §7). Stated so the
|
||||
/// engine-less path cannot quietly go back to borrowing the page's width.
|
||||
#[test]
|
||||
fn without_a_size_oracle_an_unsized_image_is_unchanged() {
|
||||
fn without_a_size_oracle_an_unsized_image_does_not_resolve() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
page(&s);
|
||||
@@ -658,11 +661,9 @@ fn without_a_size_oracle_an_unsized_image_is_unchanged() {
|
||||
.iter()
|
||||
.position(|x| x.texture.is_some())
|
||||
.expect("an IMG block");
|
||||
let rect = resolved_rect(&s, "Page", img);
|
||||
assert_eq!(
|
||||
rect.right - rect.left,
|
||||
270.0,
|
||||
"the owner-edge fallback, unchanged"
|
||||
assert!(
|
||||
maybe_resolved_rect(&s, "Page", img).is_none(),
|
||||
"no art, no span, no rect"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -949,6 +950,15 @@ fn resolved_tops(s: &UiScript, name: &str) -> Vec<(f32, f32)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One block's resolved rect if it HAS one — the shape a region with no span reaches.
|
||||
fn maybe_resolved_rect(s: &UiScript, name: &str, block: usize) -> Option<crate::layout::Rect> {
|
||||
let lua = s.lua();
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
let fh = model.arena.lookup(name).expect("frame");
|
||||
let st = model.simple_html.get(&fh).expect("state");
|
||||
model.region_resolved.get(&st.blocks[block]).copied()
|
||||
}
|
||||
|
||||
/// One block's resolved rect, by index in the block list.
|
||||
fn resolved_rect(s: &UiScript, name: &str, block: usize) -> crate::layout::Rect {
|
||||
let lua = s.lua();
|
||||
|
||||
@@ -85,13 +85,17 @@ fn line_stack_autosize_and_right_flush() {
|
||||
}
|
||||
|
||||
/// An EMPTY line mid-stack (the corpus' `AddLine("")`; the live shape was an empty-string
|
||||
/// subtitle from the wire): a ZERO-height row that still charges its 2px slot gap — the ref's
|
||||
/// empty-line shape. Pre-fix, an empty FontString never measures, so its unpinned bottom edge
|
||||
/// fell back to the OWNER frame's bottom (the v1 region fallback): the line stretched to the
|
||||
/// plate's bottom and the anchor chain marched every later line OUT of the plate (the live
|
||||
/// NPC-tooltip spill — name inside, "Level 20"/"PvP" below the frame under the health bar).
|
||||
/// subtitle from the wire): a ONE-UNIT row that also charges its 2px slot gap. Two fallbacks died
|
||||
/// to get here. First the owner-edge one: an empty FontString never measures, so its unpinned
|
||||
/// bottom edge fell back to the OWNER frame's bottom (0068's v1 region rule), the line stretched
|
||||
/// to the plate's bottom and the anchor chain marched every later line OUT of the plate (the live
|
||||
/// NPC-tooltip spill — name inside, "Level 20"/"PvP" below the frame under the health bar). Then
|
||||
/// the zero-span collapse that replaced it, which the reference does not have either: its size
|
||||
/// getters floor at one FrameXML unit, so `0.0` is not a span a FontString can have (decision
|
||||
/// 1664). The row and the plate read that floor off ONE constant, which is what keeps them from
|
||||
/// drifting apart the way B309's did.
|
||||
#[test]
|
||||
fn empty_line_is_a_zero_row_and_the_chain_stays_inside_the_plate() {
|
||||
fn empty_line_is_a_one_unit_row_and_the_chain_stays_inside_the_plate() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
@@ -118,13 +122,17 @@ fn empty_line_is_a_zero_row_and_the_chain_stays_inside_the_plate() {
|
||||
("PvP", 24.0, 12.0),
|
||||
],
|
||||
);
|
||||
// Rows 14 + 0 + 12 + 12 with 3 slot gaps ⇒ totalh 44, height 64; maxw 90 ⇒ width 110.
|
||||
// Rows 14 + 1 + 12 + 12 with 3 slot gaps ⇒ totalh 45, height 65; maxw 90 ⇒ width 110.
|
||||
// The blank row is ONE unit, not zero: a FontString's span floors at one FrameXML unit on
|
||||
// both the plate's metric and the line's own rect, from one constant (decision 1664).
|
||||
// (That this row exists at all is a separate open question — on the reference `AddLine("")`
|
||||
// returns without incrementing the line count, wow-re `tooltip-blank-line-height.md` §3.)
|
||||
s.run(
|
||||
r#"
|
||||
assert(TTE:GetWidth() == 110, "auto width, got " .. tostring(TTE:GetWidth()))
|
||||
assert(TTE:GetHeight() == 64, "auto height, got " .. tostring(TTE:GetHeight()))
|
||||
-- The chain stays contiguous through the zero row: Level 20 sits gap+0+gap under the name.
|
||||
assert(TTETextLeft3:GetTop() == TTETextLeft1:GetBottom() - 4,
|
||||
assert(TTE:GetHeight() == 65, "auto height, got " .. tostring(TTE:GetHeight()))
|
||||
-- The chain stays contiguous through the blank row: Level 20 sits gap+1+gap under the name.
|
||||
assert(TTETextLeft3:GetTop() == TTETextLeft1:GetBottom() - 5,
|
||||
"chain contiguous through the empty row, got " .. tostring(TTETextLeft3:GetTop())
|
||||
.. " vs " .. tostring(TTETextLeft1:GetBottom()))
|
||||
-- And the tail line lands INSIDE the plate, a full pad above its bottom edge.
|
||||
@@ -565,16 +573,16 @@ fn an_emptied_pooled_line_drops_its_stale_box_and_the_plate_still_contains_the_c
|
||||
measure_all(&mut s, sizes);
|
||||
s.run(
|
||||
r#"
|
||||
-- Rows 14 + 0 + 12 with two slot gaps ⇒ totalh 30, height 50: the blank costs its gap
|
||||
-- and nothing else, exactly as it does on a cell that never held text.
|
||||
assert(TTR:GetHeight() == 50, "auto height, got " .. tostring(TTR:GetHeight()))
|
||||
-- Rows 14 + 1 + 12 with two slot gaps ⇒ totalh 31, height 51: the blank costs its gap
|
||||
-- and one floored unit, exactly as it does on a cell that never held text.
|
||||
assert(TTR:GetHeight() == 51, "auto height, got " .. tostring(TTR:GetHeight()))
|
||||
-- The one that was wrong: the tail line sits a full pad above the plate's bottom edge.
|
||||
-- Pre-fix it sat 12 BELOW it — line 2's dead "Level 20" box, drawn but never counted.
|
||||
assert(TTRTextLeft3:GetBottom() == TTR:GetBottom() + 10,
|
||||
"tail line inside the plate, got " .. tostring(TTRTextLeft3:GetBottom())
|
||||
.. " vs plate bottom " .. tostring(TTR:GetBottom()))
|
||||
-- And the chain is contiguous through the zero row, as on a cold cell.
|
||||
assert(TTRTextLeft3:GetTop() == TTRTextLeft1:GetBottom() - 4,
|
||||
-- And the chain is contiguous through the blank row, as on a cold cell.
|
||||
assert(TTRTextLeft3:GetTop() == TTRTextLeft1:GetBottom() - 5,
|
||||
"chain contiguous through the emptied row, got " .. tostring(TTRTextLeft3:GetTop()))
|
||||
"#,
|
||||
)
|
||||
|
||||
@@ -643,8 +643,8 @@ pub(super) fn hide_tooltip(lua: &Lua, h: FrameHandle) {
|
||||
}
|
||||
|
||||
/// One line cell's layout input: `Some((w, h))` once the cell is shown with text AND the measure
|
||||
/// round-trip has answered; `Some((0, 0))` for a shown but EMPTY cell (the corpus' `AddLine("")`
|
||||
/// — a real zero-height row that still charges its slot gap, the ref's empty-line shape); `None`
|
||||
/// round-trip has answered; one floored unit each way for a shown but EMPTY cell (the reference's
|
||||
/// `AddLine("")` adds no line at all, and its virtual `GetHeight` never reads back `0.0`); `None`
|
||||
/// for hidden/cleared cells and for PENDING measures. A pending cell contributes nothing — no
|
||||
/// width, no height, and (in the caller) no gaps — so a fresh tooltip holds its declared default
|
||||
/// size until real extents land, instead of collapsing to a gaps-only rect (the guard the first
|
||||
@@ -659,7 +659,17 @@ fn cell(model: &Model, rh: crate::widget::RegionHandle) -> Cell {
|
||||
return None;
|
||||
}
|
||||
if text.is_empty() {
|
||||
return Some((0.0, 0.0));
|
||||
// The same floor the sweep applies to the line's own rect
|
||||
// (`layout::FONTSTRING_MIN_SPAN`): the reference's `GetHeight` is virtual and ends in a
|
||||
// one-unit clamp, so `0.0` is not a height any caller — plate arithmetic included — can
|
||||
// read back off a FontString (wow-re `region-size-fallback.md` §3,
|
||||
// `tooltip-blank-line-height.md` §3). Reporting zero here while the chain below the row
|
||||
// resolved one unit is the B309 shape at 1/14th the size, and the point of one constant
|
||||
// is that the two cannot drift apart.
|
||||
return Some((
|
||||
super::layout::FONTSTRING_MIN_SPAN,
|
||||
super::layout::FONTSTRING_MIN_SPAN,
|
||||
));
|
||||
}
|
||||
d.measured.map(|m| (m.w, m.h))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user