Files
benilla-octo/crates/benilla-app/src/char_select/mod.rs
T
SamandClaude Fable 5 e3f9b7f400 the bevy 0.19 excursion: graded 5.5 ms slower lane-for-lane, rolled back
- the full bevy 0.19 / avian 0.7 / egui 0.35 port landed and rendered at visual parity, then profiling matched the lanes honestly: +5.5 ms against 0.18 in our scenes, priced to three named buckets — so it was reverted the same day, with the pins mapped for a future release
- back on 0.18 the client is faster than 0.18 ever ran here, keeping both performance days' wins
- what the hunt left behind: the material-animation scan visits only rows that can be registry keys, and a capture that settles on an all-black image now says EMPTY instead of grading it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 17:54:09 +07:00

884 lines
40 KiB
Rust

//! Character select — the faithful glue screen (decision 0465, superseding 0193 §4's v1 overlay).
//!
//! Owns [`ClientState`], the app's lifecycle state machine: `CharSelect` is the pre-world "glue"
//! layer (the real client's GlueXML universe — login/realm/select screens), `InWorld` is the game.
//! The IO thread parks after the world handshake and emits the account roster
//! ([`CharListMessage`]); the **pick policy** here decides what answers it — the pending pick
//! (seamless reconnect, decision 0065), the `WOW_CHAR` env fast path, or the director's choice on
//! the screen. The pick travels the [`CharPick`] channel; `Connected` flips us `InWorld`;
//! a `/logout` round-trip ([`LoggedOutMessage`]) flips back to select with the pending pick
//! cleared, and a **lost** session flips all the way back to the login screen (decision 1262 —
//! the reference's `DISCONNECTED_FROM_SERVER`).
//!
//! The screen is the reference's own arrangement (`CharacterSelect.xml/.lua`, extracted off the
//! patch chain — decision 0465): the fullscreen `UI_<Race>` glue scene with the **selected
//! character standing in it, geared from its enum record** (the glue booth), the right-column
//! character list (realm banner, ten row buttons, Create New Character), Enter World / Back /
//! Delete Character along the bottom, the rotate pair, drag-to-rotate, arrow-key cycling,
//! double-click-to-enter, and the typed-`DELETE` confirm dialog. Art/strings/sounds come off the
//! player's own client data ([`crate::glue`]) — never embedded.
//!
//! Module split: this file (state machine + roster policy + shared display constants),
//! [`screen`] (the authored layout), [`refresh`] (list/banner/booth-feed refresh), [`input`]
//! (clicks, keys, rotation, the flows), [`dialog`] (the delete confirm).
mod addons;
mod dialog;
mod input;
mod refresh;
mod screen;
use benilla_protocol::{CharAction, Character};
use bevy::prelude::*;
use crate::net::{
CharActionResultMessage, CharListMessage, CharPick, CharRequest, EnteredWorldMessage,
LoggedOutMessage,
};
/// The app's lifecycle: which screen owns the session (decision 0193). Grows glue variants
/// (`RealmList`, …) as the glue arc fills in.
#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub(crate) enum ClientState {
/// Parked pre-logon at the login screen (decision 0539): the IO thread waits for credentials;
/// [`crate::login`]'s policy decides what answers it (the env fast path, the reconnect
/// resubmit, or the director's typed submit).
#[default]
Login,
/// Parked at character select: the select screen is up, the IO thread waits for a pick, and
/// the in-world input surfaces (player controller, FrameXML keyboard) are gated off.
CharSelect,
/// The character-creation screen (decision 0423): still parked at select (the IO thread
/// services create/delete in place), a sibling glue screen. Entered from the select screen's
/// "Create New Character" button; Back returns to `CharSelect`. In-world input stays gated off
/// (any `in_state(InWorld)` system is off here, mechanically).
CharCreate,
/// A character is in (or entering) the world.
InWorld,
}
/// The character-select subsystem: the state machine + the select screen.
pub(crate) struct CharSelectPlugin {
/// The screen this session opens on.
///
/// A connected boot starts at [`ClientState::Login`] (decision 0539) and the roster's arrival
/// flips it. A world capture starts straight at [`ClientState::InWorld`] — no net thread, no
/// picker — so the deterministic scene harness is untouched by the glue layer. A **glue**
/// capture starts on the glue screen it photographs, which is the reason this is a state and
/// not the bool it used to be: there turned out to be three answers, not two.
pub(crate) start: ClientState,
}
/// Mirror the session's screen onto the engine's one-bit "is there a world" fact.
///
/// One writer, not a line at each of the dozen sites that set [`ClientState`] — a mirror a future
/// transition can forget is worse than the coupling it replaced.
fn publish_world_live(
state: Res<State<ClientState>>,
mut live: ResMut<benilla_world::schedule::WorldLive>,
) {
let now = benilla_world::schedule::WorldLive(*state.get() == ClientState::InWorld);
if *live != now {
*live = now;
}
}
impl Plugin for CharSelectPlugin {
fn build(&self, app: &mut App) {
app.insert_state(self.start)
.init_resource::<Roster>()
// **The engine's world-existence bit** (1160's wire (b)): the session owner is this
// module, so this module tells the world whether there is one. Ordered ahead of every
// world stage so the fact and its falling edge are this frame's, not last frame's — which
// is the whole reason `WorldLive` is a resource and not a mirrored state.
.add_systems(
Update,
publish_world_live.before(benilla_world::schedule::WorldStage::Net),
)
.init_resource::<dialog::DeleteDialog>()
.init_resource::<addons::AddonsPanel>()
.add_systems(OnEnter(ClientState::CharSelect), screen::enter_select)
.add_systems(OnExit(ClientState::CharSelect), screen::exit_select)
.add_systems(Update, (debug_glue_roundtrip, debug_logout_smoke))
.add_systems(
Update,
(
// The policy + transitions run in BOTH states: the roster auto-answer (reconnect
// relogin) happens while `InWorld`, and the logout edge arrives there too.
// `back_on_disconnect` LAST: a session that died during entry queues both
// edges into one frame, and the dead one has to win (decision 1262).
(
apply_roster_policy,
enter_on_connected,
back_on_logout,
back_on_disconnect,
)
.chain(),
(
screen::materialize_screen,
input::select_input,
input::rotate_model,
debug_select_dialog,
dialog::drive_delete_dialog,
// Before the list refresh, and before `select_input` reads a click that
// landed on the panel rather than the screen (decision 1196).
debug_select_addons,
addons::drive_addons_panel,
refresh::refresh_list,
refresh::refresh_banner_and_buttons,
refresh::feed_glue_preview,
crate::glue::art_swaps,
crate::glue::glue_button_visuals,
delete_result,
debug_select_shot,
crate::glue::sync_outlines,
)
.chain()
.run_if(in_state(ClientState::CharSelect)),
)
.chain()
.after(benilla_world::schedule::WorldStage::Net),
);
}
}
// ── State: the roster + pick policy ──────────────────────────────────────────────────────────────
/// The account roster + the pick policy's memory. `pending_pick` is the character we asked to log
/// in as — kept while in-world so a reconnect's fresh roster is auto-answered with it (decision
/// 0065's seamless relogin); cleared by a deliberate logout so the roster is *shown* instead.
#[derive(Resource, Default)]
pub(crate) struct Roster {
pub(super) chars: Vec<Character>,
/// The selected row (the ref's `CharacterSelect.selectedIndex`, 0-based; `None` = empty list).
/// A fresh roster clamps it into range and defaults to the first row (the ref's law).
pub(super) selected: Option<usize>,
/// The guid we answered the IO thread with; `Some` = a login is requested/live.
pub(super) pending_pick: Option<u64>,
/// `WOW_CHAR`, when explicitly set: auto-pick this name on the FIRST roster (the dev fast
/// path past the screen). `take()`n once — a later `/logout` shows the screen normally.
env_char: Option<String>,
/// A just-created character's name (decision 0423): its row gets selected (the ref's
/// `SELECT_LAST_CHARACTER`, keyed by name so it survives the create/enum race). Armed by
/// [`Roster::note_created`], which consumes it against the roster **already in hand** —
/// [`apply_roster_policy`] only has to answer the other arrival order.
just_created: Option<String>,
/// The auth realm-list entry this session connected to (the screen's realm banner, 0465);
/// refreshed with each roster.
pub(super) realm: Option<benilla_protocol::RealmInfo>,
/// `WOW_CHAR` read latch (env read once at first policy run).
env_read: bool,
}
impl Roster {
/// A roster with a pick already in flight — the state world entry runs in.
///
/// `#[cfg(test)]` and `pub(crate)` because the fields are `pub(super)`: a test outside this
/// module cannot build one, and `ui_script`'s does exactly that to drive
/// `seat_from_roster` over a real row rather than a copy of its logic.
#[cfg(test)]
pub(crate) fn with_pending_pick(chars: Vec<Character>, guid: u64) -> Self {
Self {
chars,
pending_pick: Some(guid),
..Self::default()
}
}
/// Note a character the create screen just made, and select its row.
///
/// **The consume happens here, not on the next roster message** (B119): the IO thread
/// re-enumerates and emits the fresh roster *before* the create result (`net::io`), so by the
/// time the result reaches [`crate::char_create`] the new row is normally already in
/// [`Self::chars`] and [`apply_roster_policy`] has already run for that list. Arming a flag for
/// "the next roster update" then waited for a message that never arrives again — the select
/// screen came back on the previously-selected row (reproduced: create a tauren on a
/// human-first account and the human is still standing there).
///
/// The arm survives when the name isn't in hand yet, so the reverse arrival order (result
/// first) is still answered by [`apply_roster_policy`].
pub(crate) fn note_created(&mut self, name: String) {
self.just_created = Some(name);
self.select_created_by_name();
}
/// Select the armed just-created row **by name**, disarming only on a hit. Case-insensitive:
/// vmangos normalizes a created name (`normalizePlayerName` — first letter upper, rest lower),
/// so a typed `ZZBULL` enumerates back as `Zzbull` and an exact compare would miss.
fn select_created_by_name(&mut self) {
let Some(name) = self.just_created.as_deref() else {
return;
};
if let Some(row) = self
.chars
.iter()
.position(|c| c.name.eq_ignore_ascii_case(name))
{
self.selected = Some(row);
self.just_created = None;
}
}
/// The selected character, if any.
pub(super) fn selected_char(&self) -> Option<&Character> {
self.selected.and_then(|i| self.chars.get(i))
}
/// The pending pick's map — the loading screen resolves the *destination's* art from the
/// roster at the entry edge, before the server's `SMSG_LOGIN_VERIFY_WORLD` snap lands
/// (decision 0737).
pub(crate) fn pending_map(&self) -> Option<u32> {
self.pending_row().map(|c| c.map)
}
/// The picked character's `(map, wow xyz)` — **where the world we are about to load actually
/// is**, known from the roster row a whole server round-trip before `SMSG_LOGIN_VERIFY_WORLD`
/// says so. The streamers aim at this during world entry (decision 0777); without it the only
/// answer available before the snap is the hardcoded Northshire anchor
/// ([`crate::SPAWN_XY`]), which is a guess that is wrong for every character who isn't a fresh
/// human — and a wrong guess here does not merely idle, it spends the entry's IO budget
/// decoding tiles that are dropped a moment later.
pub(crate) fn pending_entry(&self) -> Option<(u32, [f32; 3])> {
self.pending_row()
.map(|c| (c.map, [c.position.x, c.position.y, c.position.z]))
}
/// The roster row for the pick in flight.
///
/// `pub(crate)` because this row is the ONLY description of the character that exists during
/// world entry: `Connected` flips us `InWorld` a whole server round-trip before the self
/// descriptor streams in, and the in-game UI — plus every addon's file scope — materializes
/// inside that window. `ui_script::load_ingame_ui_on_world_entry` seats
/// `UnitName("player")` from here for exactly that reason.
pub(crate) fn pending_row(&self) -> Option<&Character> {
self.pending_pick
.and_then(|g| self.chars.iter().find(|c| c.guid == g))
}
}
/// Ask the parked IO thread to log in as `guid` (the pick channel) and remember it as pending.
/// `pub(crate)` for the probe rig (decision 0651), which picks a character it may have had to
/// *create* first — going through here is what keeps `pending_pick` truthful, so 0065's reconnect
/// re-answers with the rigged body rather than showing the roster.
pub(crate) fn send_pick(roster: &mut Roster, pick: &CharPick, guid: u64) {
roster.pending_pick = Some(guid);
let _ = pick.0.send(CharRequest::Enter(guid));
}
/// Drain each [`CharListMessage`] into the roster, then decide what answers it: the pending pick
/// (reconnect), the `WOW_CHAR` fast path (first roster only), or nothing — the screen waits for
/// the director. A shown roster clamps the selection into range and defaults it to the first row
/// (the ref's `UpdateCharacterList`).
fn apply_roster_policy(
mut msgs: MessageReader<CharListMessage>,
mut roster: ResMut<Roster>,
pick: Res<CharPick>,
// Is the character pick already spoken for? Present only when a rig is driving this run
// (decision 1174's always-present run fact) — absent in every ordinary run, which is the
// player answer and the one this screen was written for.
rig: Option<Res<crate::run_mode::RigCharacter>>,
mut exit: MessageWriter<AppExit>,
) {
if !roster.env_read {
roster.env_read = true;
// `WOW_RIG` with a body outranks `WOW_CHAR`: the rig picks its own derived character, and
// it may have to CREATE it first, which this fast path (a one-shot `take()` that gives up
// on a name it can't find) structurally cannot wait for.
roster.env_char = match rig.as_deref() {
Some(crate::run_mode::RigCharacter(name)) => {
if let Ok(ignored) = std::env::var("WOW_CHAR") {
warn!("char select: WOW_RIG names {name} — ignoring WOW_CHAR={ignored}");
}
None
}
// `WOW_CHAR`, or the login smoke's optional third field (decision 1262): the smoke's
// seat exists because `WOW_CHAR` is also an *unattended* marker
// ([`crate::run_mode::unattended_login`]), so using it to reach the world would switch
// the very branch a session test is trying to exercise. Same fast path, no marker.
None => std::env::var("WOW_CHAR").ok().or_else(|| {
std::env::var("WOW_LOGIN_SMOKE")
.ok()
.and_then(|s| crate::login::smoke_character(&s))
}),
};
}
for msg in msgs.read() {
roster.chars = msg.characters.clone();
roster.realm = msg.realm.clone();
// A still-armed create (the result hasn't landed yet — the reverse of the order `net::io`
// actually sends in) selects its row here: by name first, else the LAST row, which is the
// ref's literal `SELECT_LAST_CHARACTER` → `SelectCharacter(numChars)` and lands on the same
// character regardless — vmangos enumerates `ORDER BY create_time, guid`, so the new one is
// last. Otherwise clamp into range, first row default.
if roster.just_created.is_some() {
roster.select_created_by_name();
if let Some(name) = roster.just_created.take() {
warn!(
"char select: created {name:?} is not on the fresh roster — selecting the last row"
);
roster.selected = roster.chars.len().checked_sub(1);
}
}
if roster.chars.is_empty() {
roster.selected = None;
} else {
let sel = roster.selected.unwrap_or(0).min(roster.chars.len() - 1);
roster.selected = Some(sel);
}
// `WOW_CHARSELECT_PICK=<name>` — **select** that row and stay on the screen, the
// deliberate opposite of `WOW_CHAR`'s enter-the-world fast path. Without it the screen is
// only ever reachable on its default first row, which makes every "what does this
// character look like at select?" check hostage to the account's create order (it cost
// this session's item-glow verification a run). An unknown name leaves the default.
if let Ok(name) = std::env::var("WOW_CHARSELECT_PICK") {
match roster
.chars
.iter()
.position(|c| c.name.eq_ignore_ascii_case(&name))
{
Some(row) => {
info!("char select: WOW_CHARSELECT_PICK={name} — selecting row {row}");
roster.selected = Some(row);
}
None => warn!("char select: WOW_CHARSELECT_PICK={name} not on this account"),
}
}
if let Some(guid) = roster.pending_pick {
// We already chose this session (in-world reconnect, or a pick raced a dying socket):
// re-answer without showing the screen.
send_pick(&mut roster, &pick, guid);
} else if let Some(name) = roster.env_char.take() {
match roster
.chars
.iter()
.find(|c| c.name.eq_ignore_ascii_case(&name))
{
Some(c) => {
let guid = c.guid;
info!("char select: WOW_CHAR={name} — fast path");
send_pick(&mut roster, &pick, guid);
}
None => {
warn!("char select: WOW_CHAR={name} not on this account — showing roster");
// A harness run (env creds, no smoke) can never pick a different row itself —
// parked here it burns its whole wall-clock. Same marker the login arm uses,
// same greppable verdict for leg.sh.
if crate::run_mode::unattended_login()
&& std::env::var_os("WOW_LOGIN_SMOKE").is_none()
{
error!("login: FATAL — WOW_CHAR={name} is not on this account; exiting");
exit.write(AppExit::error());
}
}
}
}
}
}
/// `Connected` (bridged as [`EnteredWorldMessage`]) → the world owns the session.
fn enter_on_connected(
mut msgs: MessageReader<EnteredWorldMessage>,
mut next: ResMut<NextState<ClientState>>,
) {
if msgs.read().next().is_some() {
next.set(ClientState::InWorld);
}
}
/// **The session died** → all the way back to the login screen (decision 1262), which is where the
/// reference's `GlueParent.lua` puts it: `SetGlueScreen("login")` on `DISCONNECTED_FROM_SERVER`,
/// with [`crate::login`] raising the "Disconnected from server" dialog over it.
///
/// Chained **after** [`enter_on_connected`] on purpose. Both edges can land in the same frame — the
/// IO thread emits `Connected` before its read loop starts, so a stream that dies during entry
/// queues `Connected` and `Disconnected` back to back and the app drains both at once. That is the
/// race the report caught: the entry half won, and the client walked into a world whose session had
/// already been torn down — a camera with no avatar, `SelfGuid` set, an entry awaiting a snap that
/// could never arrive. Ordering it last makes the dead session the last word by construction.
fn back_on_disconnect(
mut msgs: MessageReader<crate::net::DisconnectedMessage>,
mut roster: ResMut<Roster>,
mut next: ResMut<NextState<ClientState>>,
) {
if !msgs.read().any(|m| m.session_over) {
return;
}
// The pending pick is the reconnect's memory (0065). With no reconnect coming it would only
// auto-answer the *next* roster — sending the player straight back into the world they were
// just thrown out of, without ever seeing the screen.
roster.pending_pick = None;
next.set(ClientState::Login);
}
/// A confirmed `/logout` → back to the glue layer, pick cleared (the follow-up roster must be
/// shown, not auto-answered).
///
/// The in-world UI's input latches are released by [`crate::ui_script::end_ui_session`], on the
/// `OnExit(InWorld)` edge this transition causes: they stick because `feed_ui_input` stops running
/// outside `InWorld`, which is a fact about the edge and not about *why* we left it — so both this
/// path and the disconnect above get it from one place (1290).
fn back_on_logout(
mut msgs: MessageReader<LoggedOutMessage>,
mut roster: ResMut<Roster>,
mut next: ResMut<NextState<ClientState>>,
) {
if msgs.read().next().is_some() {
roster.pending_pick = None;
next.set(ClientState::CharSelect);
}
}
/// Surface a refused delete (the roster refresh already reflects a success — the row vanishes).
/// A refusal is realistically unreachable on vmangos (any enumerated character deletes), so a log
/// line honest-flags it rather than growing an error dialog nothing can trigger.
fn delete_result(mut msgs: MessageReader<CharActionResultMessage>) {
for msg in msgs.read() {
if msg.action == CharAction::Delete
&& msg.code != benilla_protocol::messages::CHAR_DELETE_SUCCESS
{
warn!("char select: delete refused (code {:#04x})", msg.code);
}
}
}
/// Glue-flow smoke (`WOW_GLUE_ROUNDTRIP=1`, decision 0423): once a real roster is up, bounce
/// CharSelect → CharCreate → **Back** → CharSelect and exit — so the return-to-select rebuild is
/// provable headlessly from the logs. Runs ungated (it crosses states); inert without the env.
fn debug_glue_roundtrip(
roster: Res<Roster>,
state: Res<State<ClientState>>,
mut next: ResMut<NextState<ClientState>>,
time: Res<Time>,
mut exit: MessageWriter<AppExit>,
mut phase: Local<u8>,
mut mark: Local<f32>,
) {
if std::env::var("WOW_GLUE_ROUNDTRIP").is_err() {
return;
}
let now = time.elapsed_secs();
match *phase {
0 if !roster.chars.is_empty() && *state.get() == ClientState::CharSelect => {
info!(
"glue-roundtrip: initial roster = {} char(s) → entering CharCreate",
roster.chars.len()
);
next.set(ClientState::CharCreate);
(*phase, *mark) = (1, now);
}
1 if *state.get() == ClientState::CharCreate && now - *mark > 1.5 => {
info!("glue-roundtrip: in CharCreate → Back to CharSelect");
next.set(ClientState::CharSelect);
(*phase, *mark) = (2, now);
}
2 if *state.get() == ClientState::CharSelect && now - *mark > 1.5 => {
info!(
"glue-roundtrip: back at CharSelect, roster = {} char(s) — done",
roster.chars.len()
);
exit.write(AppExit::Success);
*phase = 3;
}
_ => {}
}
}
/// The logout-boundary smoke (`WOW_LOGOUT_SMOKE=1`, meant with the `WOW_CHAR` fast path): once
/// seated in the world, linger, request the `/logout` round-trip, confirm the return to
/// CharSelect, linger again (the glue theme should be the only thing audible over the logs'
/// world-teardown lines), **enter the world a second time**, and exit. Inert without the env.
///
/// The re-entry leg is not decoration. Since decision 0777 the world is *released* on the way out
/// (`terrain_stream::release_world`), so the second entry is a materially different code path from
/// the first — it streams a map the streamer has already torn down once — and a teardown that
/// forgets to reset its own bookkeeping fails exactly here and nowhere else. The world-audio
/// boundary this smoke was written for is still checked on the way through.
#[allow(clippy::too_many_arguments)] // a smoke test that drives the whole round trip
fn debug_logout_smoke(
state: Res<State<ClientState>>,
player: Res<crate::player::Player>,
commands: Res<crate::net::NetCommands>,
mut roster: ResMut<Roster>,
pick: Res<CharPick>,
streamer: Res<benilla_world::terrain_stream::TerrainStreamer>,
time: Res<Time>,
mut exit: MessageWriter<AppExit>,
mut phase: Local<u8>,
mut mark: Local<f32>,
) {
if std::env::var("WOW_LOGOUT_SMOKE").is_err() {
return;
}
let now = time.elapsed_secs();
match *phase {
0 if *state.get() == ClientState::InWorld && player.active => {
info!("logout-smoke: seated in world — lingering");
(*phase, *mark) = (1, now);
}
1 if now - *mark > 3.0 => {
info!("logout-smoke: requesting logout");
let _ = commands.0.send(crate::net::ClientCommand::Logout);
*phase = 2;
}
2 if *state.get() == ClientState::CharSelect => {
info!("logout-smoke: back at character select — lingering");
(*phase, *mark) = (3, now);
}
// **The residency reading belongs HERE, not on arrival.** It used to print on the first
// frame at CharSelect and report "25 tiles resident (must be 0)" every single run — a
// permanent, alarming, wrong number. `release_world` runs ~150 ms later (it hangs off the
// world-live falling edge, not the state edge), so the probe was reading the count before
// the thing it is checking had happened, and reporting the world it had just left.
//
// It cost more than a wrong line: 1291 wrote the reading up as a live contradiction of
// 0777's release-world claim, on the strength of it reproducing identically on an older
// commit — which it did, because an instrument that measures too early does that reliably.
// A number that is always wrong teaches everyone to skip the line (0777's own lesson about
// tripwires nobody trips, from the other end).
3 if now - *mark > 4.0 => match roster.chars.first().map(|c| (c.guid, c.name.clone())) {
Some((guid, name)) => {
info!(
"logout-smoke: {} tiles resident after the release (must be 0)",
streamer.residency().1
);
info!("logout-smoke: re-entering the world as {name}");
send_pick(&mut roster, &pick, guid);
(*phase, *mark) = (4, now);
}
None => {
warn!("logout-smoke: empty roster — cannot test re-entry");
*phase = 5;
}
},
4 if *state.get() == ClientState::InWorld && player.active && now - *mark > 3.0 => {
info!(
"logout-smoke: re-entered — {} tiles resident, done",
streamer.residency().1
);
*phase = 5;
}
5 => {
info!("logout-smoke: done");
exit.write(AppExit::Success);
*phase = 6;
}
_ => {}
}
}
/// The shot instrument's delete-dialog dial (`WOW_CHARSELECT_DIALOG=<typed>`): open the
/// typed-confirm dialog for the selected character a few seconds after the screen is up, with
/// `<typed>` pre-typed (may be empty) — so the dialog's geometry (the ChatInputBorder edit box,
/// the caret bar) is capturable headlessly. Pair with `WOW_CHARSELECT_SHOT_OUT`; inert without
/// the env; fires once.
fn debug_select_dialog(
roster: Res<Roster>,
mut dialog: ResMut<dialog::DeleteDialog>,
time: Res<Time>,
mut entered_at: Local<Option<f32>>,
mut done: Local<bool>,
) {
if *done {
return;
}
let Ok(typed) = std::env::var("WOW_CHARSELECT_DIALOG") else {
*done = true;
return;
};
let start = *entered_at.get_or_insert(time.elapsed_secs());
if time.elapsed_secs() - start < 4.0 {
return;
}
let Some(c) = roster.selected_char() else {
return; // roster not in yet — keep waiting
};
let (guid, name, level, class) = (c.guid, c.name.clone(), c.level, class_name(c.class));
dialog.open_for(guid, name, level, class);
dialog.typed.set_text(&typed);
info!("char select: dialog instrument opened the delete confirm");
*done = true;
}
/// The shot instrument's AddOns dial (`WOW_CHARSELECT_ADDONS=1`): open the AddOns panel a few
/// seconds after the screen is up, exactly as the screen's own button would — so the reference
/// AddonList layout is exercised and capturable headlessly (pair with
/// `WOW_CHARSELECT_SHOT_OUT`). Inert without the env; fires once.
fn debug_select_addons(
roster: Res<Roster>,
mut panel: ResMut<addons::AddonsPanel>,
time: Res<Time>,
mut entered_at: Local<Option<f32>>,
mut done: Local<bool>,
) {
if *done {
return;
}
if std::env::var("WOW_CHARSELECT_ADDONS").is_err() {
*done = true;
return;
}
let start = *entered_at.get_or_insert(time.elapsed_secs());
if time.elapsed_secs() - start < 4.0 {
return;
}
if roster.chars.is_empty() {
return; // roster not in yet — keep waiting
}
// The same open the AddOns button performs (`input::select_input`): realm + full roster.
let realm = roster
.realm
.as_ref()
.map(|r| r.name.clone())
.unwrap_or_else(|| "Realm".into());
let chars = roster.chars.iter().map(|c| c.name.clone()).collect();
panel.open_for(realm, chars);
info!("char select: addons instrument opened the AddOn List");
*done = true;
}
/// Default delay before the select-screen shot fires (seconds from the screen coming up) — long
/// enough for the art, the glue scene and the geared model to settle.
const SELECT_SHOT_AT: f32 = 8.0;
/// The select-screen shot instrument (`WOW_CHARSELECT_SHOT_OUT=<path>`, decision 0465): once the
/// screen has been up a few seconds (art + scene + model settled), write one PNG of the window via
/// Bevy's own framebuffer readback — machine-checkable geometry without macOS screen-recording
/// permission. Inert without the env.
///
/// `WOW_CHARSELECT_SHOT_AT=<secs>` moves the shot (default [`SELECT_SHOT_AT`], measured from the
/// first frame the screen is up). The knob exists because the fixed 8 s lands *inside* the
/// create round-trip when the run is `WOW_CHARCREATE_SHOT` + `WOW_CHARCREATE_NAME`: the B119 retest
/// wants the screen a few seconds AFTER the new character lands, and it captured the frame 19 ms
/// after the create result — before the booth had re-baked the new selection.
fn debug_select_shot(
mut commands: Commands,
time: Res<Time>,
mut entered_at: Local<Option<f32>>,
mut done: Local<bool>,
) {
if *done {
return;
}
let Ok(out) = std::env::var("WOW_CHARSELECT_SHOT_OUT") else {
*done = true;
return;
};
let at = std::env::var("WOW_CHARSELECT_SHOT_AT")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.unwrap_or(SELECT_SHOT_AT);
let start = *entered_at.get_or_insert(time.elapsed_secs());
if time.elapsed_secs() - start < at {
return;
}
use bevy::render::view::screenshot::{save_to_disk, Screenshot};
commands
.spawn(Screenshot::primary_window())
.observe(save_to_disk(out.clone()));
info!("char select: shot instrument writing {out}");
*done = true;
}
/// The WoW UI font, straight off the patch chain (Bevy's TTF loader over the `mpq://` source).
pub(crate) fn wow_font(assets: &AssetServer) -> Handle<Font> {
assets.load("mpq://Fonts/FRIZQT__.ttf")
}
// ── 1.12 display constants (frozen facts of the build; display only) ────────────────────────────
pub(crate) fn race_name(race: u8) -> &'static str {
match race {
1 => "Human",
2 => "Orc",
3 => "Dwarf",
4 => "Night Elf",
5 => "Undead",
6 => "Tauren",
7 => "Gnome",
8 => "Troll",
_ => "?",
}
}
pub(crate) fn class_name(class: u8) -> &'static str {
match class {
1 => "Warrior",
2 => "Paladin",
3 => "Hunter",
4 => "Rogue",
5 => "Priest",
7 => "Shaman",
8 => "Mage",
9 => "Warlock",
11 => "Druid",
_ => "?",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn character(guid: u64, name: &str) -> Character {
Character {
guid,
name: name.to_string(),
race: 1,
class: 1,
gender: 0,
skin: 0,
face: 0,
hair_style: 0,
hair_color: 0,
facial_hair: 0,
level: 1,
zone: 0,
map: 0,
position: benilla_protocol::wire::Vector3d {
x: 0.0,
y: 0.0,
z: 0.0,
},
flags: 0,
equipment: [benilla_protocol::CharEnumItem::default(); 19],
}
}
/// **A session that dies during world entry does not leave the client in the world**
/// (decision 1262).
///
/// The IO thread emits `Connected` *before* its read loop starts, so a socket that dies mid-
/// entry — which is exactly what a displacement kick looks like, a bare EOF and nothing else —
/// queues `Connected` and `Disconnected` back to back, and the drain (`try_iter`) hands the app
/// both in one frame. Both edges then fire in the same `Update`. Whichever writes `NextState`
/// last wins, and before this the entry did: the client flipped `InWorld` against a world the
/// same frame's teardown had already emptied, and stood there in a camera with no avatar.
///
/// The guard is the *ordering*, which nothing else can catch: each system is correct alone, the
/// build is green either way, and the failure needs two logins racing on one account to
/// reproduce by hand.
#[test]
fn a_session_lost_during_entry_beats_the_entry() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
.insert_state(ClientState::CharSelect)
.init_resource::<Roster>()
.init_resource::<crate::ui_script::PlayerUiHover>()
.init_resource::<crate::ui_script::UiKeyboardCapture>()
.add_message::<EnteredWorldMessage>()
.add_message::<crate::net::DisconnectedMessage>()
.add_systems(Update, (enter_on_connected, back_on_disconnect).chain());
app.world_mut().resource_mut::<Roster>().pending_pick = Some(7);
// One frame carrying both halves of the race, in the order the drain produces them.
app.world_mut().write_message(EnteredWorldMessage);
app.world_mut()
.write_message(crate::net::DisconnectedMessage {
reason: "disconnected: world stream closed: failed to fill whole buffer".into(),
end: benilla_protocol::SessionEnd::Lost,
session_over: true,
});
app.update();
app.update(); // `StateTransition` applies the pending state at the next frame
assert_eq!(
*app.world().resource::<State<ClientState>>().get(),
ClientState::Login,
"the dead session must be the last word — the reference's DISCONNECTED_FROM_SERVER \
puts the client back on the account screen, not into a world with no session",
);
assert_eq!(
app.world().resource::<Roster>().pending_pick,
None,
"and the reconnect's pending pick goes with it, or the next roster walks straight \
back into the world the player was just thrown out of",
);
}
/// The same frame with the session *not* over (a clean logout's teardown, or an unattended run
/// keeping 0065's seamless reconnect) still enters the world — the guard above must not have
/// turned every disconnect into a bounce to the login screen.
#[test]
fn a_teardown_that_is_not_the_end_leaves_the_entry_alone() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
.insert_state(ClientState::CharSelect)
.init_resource::<Roster>()
.init_resource::<crate::ui_script::PlayerUiHover>()
.init_resource::<crate::ui_script::UiKeyboardCapture>()
.add_message::<EnteredWorldMessage>()
.add_message::<crate::net::DisconnectedMessage>()
.add_systems(Update, (enter_on_connected, back_on_disconnect).chain());
app.world_mut().write_message(EnteredWorldMessage);
app.world_mut()
.write_message(crate::net::DisconnectedMessage {
reason: "logged out".into(),
end: benilla_protocol::SessionEnd::LoggedOut,
session_over: false,
});
app.update();
app.update();
assert_eq!(
*app.world().resource::<State<ClientState>>().get(),
ClientState::InWorld,
);
}
/// B119 — a created character is selected against the roster **already in hand**. `net::io`
/// re-enumerates and emits the fresh roster BEFORE the create result, so by the time
/// `note_created` runs the row exists and the roster message is spent; a version that only armed
/// a flag for "the next roster update" left the previously-selected row standing (reproduced
/// live: create a tauren on a human-first account, the human stays on the stage).
#[test]
fn a_created_character_is_selected_from_the_roster_in_hand() {
let mut roster = Roster {
chars: vec![
character(1, "Kerwind"),
character(2, "Xero"),
character(3, "Zzbullone"), // the fresh row, appended by the re-enum
],
selected: Some(0),
..default()
};
roster.note_created("Zzbullone".to_string());
assert_eq!(roster.selected, Some(2), "the new row must be selected");
assert!(roster.just_created.is_none(), "and the arm consumed");
}
/// The name key is case-insensitive: vmangos normalizes a created name
/// (`normalizePlayerName` — first letter upper, rest lower), so the string the create screen
/// typed is NOT what the enum sends back.
#[test]
fn the_created_name_matches_the_servers_normalized_spelling() {
let mut roster = Roster {
chars: vec![character(1, "Kerwind"), character(2, "Zzbullone")],
selected: Some(0),
..default()
};
roster.note_created("ZZBULLONE".to_string()); // as typed
assert_eq!(roster.selected, Some(1));
}
/// The reverse arrival order (result first, roster after) stays armed and is answered by the
/// roster policy — and a name that still can't be found falls back to the LAST row, which is the
/// ref's literal `SELECT_LAST_CHARACTER` → `SelectCharacter(numChars)` and the same character
/// anyway (vmangos enumerates `ORDER BY create_time, guid`).
#[test]
fn an_unanswerable_create_stays_armed() {
let mut roster = Roster {
chars: vec![character(1, "Kerwind")],
selected: Some(0),
..default()
};
roster.note_created("Zzbulltwo".to_string());
assert_eq!(
roster.just_created.as_deref(),
Some("Zzbulltwo"),
"no row to select yet — the arm must survive for the roster policy"
);
assert_eq!(roster.selected, Some(0), "and nothing is mis-selected");
}
}