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:
Sam
2026-08-29 17:18:00 +07:00
co-authored by Claude Fable 5
parent 1fb4d75acd
commit 0d7b984304
16 changed files with 1375 additions and 250 deletions
+14 -4
View File
@@ -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
+93 -18
View File
@@ -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,
} = &params;
// 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),
);
}
}
+6 -1
View File
@@ -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)]
+4
View File
@@ -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)
+358 -75
View File
@@ -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| {
+193 -20
View File
@@ -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()
+4 -6
View File
@@ -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 => {
+18 -9
View File
@@ -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) {
+295
View File
@@ -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");
}
}
+56 -60
View File
@@ -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.
+112 -17
View File
@@ -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),
}
}
+5 -2
View File
@@ -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
+162 -11
View File
@@ -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();
+23 -15
View File
@@ -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()))
"#,
)
+13 -3
View File
@@ -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))
}