the auction house opens, the Honor tab lands, and a day of fixes

- the auction house end to end: right-clicking an auctioneer opens Browse/Bids/Auctions on the server's reply — search, the category tree from your own DBCs, the reference's 50-a-page pager, multi-key sorting (directions corrected at the bytes), bid/buyout, the sell slot with deposit and duration, cancel with its fee popup; prices sit on the reference's own money-frame kit in lined-up columns, the twenty auction messages split refusals-red / outcomes-to-chat, and a won or sold auction reads as a proper mail receipt — the window's Lua re-worded in our own terms after the overlap ratchet caught the first cut leaning on the reference's text
- the PvP honor system whole: the Honor tab and the inspect page over a real wire model, with current rank and highest-ever rank correctly two different fields
- a day of reported fixes: WASD survives a /logout re-entry, addon settings save on a window close and Cmd+Q (exactly once — a mac close could fire the tail twice), clams open again, random-enchant items name themselves on every surface, the loot window's rows hold their seats with legible counts, idle players stop sinking through building floors, the shooter re-pulls between Auto Shots, portraits loosen ~19% to the client's own aspect-1.0 matrix, and big creatures thump the camera off the CameraShakes table
- character select clicks like 1.12 — captions whiten on hover, buttons fire on release, Humans face straight ahead off the stage bone's real yaw, the facing is per-character, and hunter/warlock pets stand beside you at their family-ramp size with their own emitters (the imp's flames), the Voidwalker's sequence-hidden shackles culled
- the smaller player-facing set: nameplate on/off rows in Options with the CanAttack category gate (emissaries stop plating), the bonus bar slides up with igBonusBarOpen, the camera's framing height glides on a shapeshift, the open letter evicts the centre seat, the far-band fog hull stops printing a pale horizon stripe at low view distance, the guide arrow is gold with per-source rim art, error text fades through its shadow too, and /convertraid converts the party
- README: the auction house and PvP honor move out of "not built yet"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-22 19:04:27 +07:00
co-authored by Claude Fable 5
parent 6aa78bc468
commit cf581aa2ac
170 changed files with 20573 additions and 1578 deletions
+14 -5
View File
@@ -587,8 +587,8 @@ pub(super) fn drive_addons_panel(
mouse: Res<ButtonInput<MouseButton>>,
mut wheel: MessageReader<bevy::input::mouse::MouseWheel>,
mut sounds: MessageWriter<GlueSound>,
presses: Query<(&AddonsAction, &Interaction), Changed<Interaction>>,
hovers: Query<(Entity, &AddonsAction, &Interaction)>,
clicks: Res<crate::glue::GlueClicks>,
hovers: Query<(Entity, &AddonsAction, Ref<Interaction>)>,
lit: Query<(&Interaction, &Children), With<HoverLit>>,
mut hilights: Query<&mut Visibility, With<Hilight>>,
band: Query<(&ComputedNode, &UiGlobalTransform), With<ScrollBand>>,
@@ -608,8 +608,17 @@ pub(super) fn drive_addons_panel(
let mut close_and_save = false;
let mut close_and_discard = false;
let mut bar_pressed = false;
for (action, interaction) in &presses {
if *interaction != Interaction::Pressed {
for (entity, action, interaction) in &hovers {
// **The scroll bar warps on the PRESS**, and only it: the reference's `CSimpleSlider`
// OnMouseDown (`0x789ca0`, 45 bytes, zero branches) warps the value from any press inside
// the hit rect — there is no thumb hit-test in the class (wow-re `ui.md`). Every *button*
// in this panel fires on the RELEASE, over the button that took the press (1533).
let click = if *action == AddonsAction::ScrollBar {
interaction.is_changed() && *interaction == Interaction::Pressed
} else {
clicks.hit(entity)
};
if !click {
continue;
}
match *action {
@@ -761,7 +770,7 @@ pub(super) fn drive_addons_panel(
}
let hover = hovers.iter().find_map(|(_, a, i)| {
if !matches!(i, Interaction::Hovered | Interaction::Pressed) {
if !matches!(*i, Interaction::Hovered | Interaction::Pressed) {
return None;
}
match a {
+5 -3
View File
@@ -99,7 +99,8 @@ pub(super) fn drive_delete_dialog(
keys: Res<ButtonInput<KeyCode>>,
mut keyboard: MessageReader<KeyboardInput>,
mut sounds: MessageWriter<GlueSound>,
presses: Query<(&DialogAction, &Interaction), Changed<Interaction>>,
buttons: Query<(Entity, &DialogAction)>,
clicks: Res<crate::glue::GlueClicks>,
mut okay: Query<(&DialogAction, &mut GlueDisabled)>,
// The five row items of the confirm box (segments + carets), painted by the shared
// `paint_glue_field`; plus the host pasteboard and the window handle its Wayland backend needs.
@@ -178,8 +179,9 @@ pub(super) fn drive_delete_dialog(
// The flows.
let mut do_delete = false;
let mut do_cancel = false;
for (action, interaction) in &presses {
if *interaction != Interaction::Pressed {
// Both buttons fire on the RELEASE (1533) — a confirm you can slide off and cancel.
for (entity, action) in &buttons {
if !clicks.hit(entity) {
continue;
}
match action {
+19 -14
View File
@@ -15,11 +15,8 @@ use super::dialog::DeleteDialog;
use super::screen::SelectAction;
use super::{class_name, send_pick, ClientState, Roster};
/// The ref's drag constant (`CHARACTER_ROTATION_CONSTANT = 0.6`, CharacterSelect.lua — degrees per
/// pixel; the facing setter is in degrees, deg→rad at the C boundary).
pub(crate) const ROTATION_PER_PX: f32 = 0.6 * std::f32::consts::PI / 180.0;
/// The rotate buttons' hold rate — the ref's 2°-per-frame `CHARACTER_FACING_INCREMENT`, per-second.
const ROTATE_RATE: f32 = 120.0 * std::f32::consts::PI / 180.0;
use crate::glue::{drag_yaw, ROTATE_RATE};
/// The double-click window (the ref rides the OS notion; this is the conventional interval).
const DOUBLE_CLICK_SECS: f32 = 0.4;
@@ -29,7 +26,8 @@ const DOUBLE_CLICK_SECS: f32 = 0.4;
/// sits over the buttons).
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub(super) fn select_input(
presses: Query<(&SelectAction, &Interaction), Changed<Interaction>>,
buttons: Query<(Entity, &SelectAction)>,
clicks: Res<crate::glue::GlueClicks>,
keys: Res<ButtonInput<KeyCode>>,
mut roster: ResMut<Roster>,
pick: Res<CharPick>,
@@ -48,8 +46,10 @@ pub(super) fn select_input(
let now = time.elapsed_secs();
let mut enter_world = false;
let mut back_to_login = false;
for (action, interaction) in &presses {
if *interaction != Interaction::Pressed {
// Buttons fire on the RELEASE, over the button that took the press — the reference's stock
// `<Button>` click mask is `LeftButtonUp` alone (1533, `crate::glue::glue_clicks`).
for (entity, action) in &buttons {
if !clicks.hit(entity) {
continue;
}
match *action {
@@ -58,10 +58,13 @@ pub(super) fn select_input(
// within the window is the double-click → enter world.
let double =
last_click.is_some_and(|(row, at)| row == i && now - at < DOUBLE_CLICK_SECS);
let was = roster.selected();
*last_click = Some((i, now));
if roster.selected != Some(i) {
roster.selected = Some(i);
} else if double {
// The ref selects on **every** click, the row you were already on included — which
// is what re-squares the facing (`Roster::select_seq`). Only the double still
// needs the "already selected" test.
roster.select(Some(i));
if was == Some(i) && double {
enter_world = true;
}
}
@@ -121,13 +124,13 @@ pub(super) fn select_input(
let back = keys.just_pressed(KeyCode::ArrowUp) || keys.just_pressed(KeyCode::ArrowLeft);
let fwd = keys.just_pressed(KeyCode::ArrowDown) || keys.just_pressed(KeyCode::ArrowRight);
if back || fwd {
let cur = roster.selected.unwrap_or(0);
let cur = roster.selected().unwrap_or(0);
let sel = if back {
(cur + n - 1) % n
} else {
(cur + 1) % n
};
roster.selected = Some(sel);
roster.select(Some(sel));
}
}
@@ -148,15 +151,17 @@ pub(super) fn rotate_model(
panes: Query<(&Interaction, &SelectAction)>,
motion: Res<AccumulatedMouseMotion>,
time: Res<Time>,
window: Query<&Window, With<bevy::window::PrimaryWindow>>,
mut preview: ResMut<GluePreview>,
) {
let window = window.single().ok();
for (interaction, action) in &panes {
if *interaction != Interaction::Pressed {
continue;
}
match action {
SelectAction::Scene if motion.delta.x != 0.0 => {
preview.yaw += motion.delta.x * ROTATION_PER_PX;
preview.yaw += drag_yaw(motion.delta.x, window);
}
SelectAction::RotateLeft => preview.yaw -= ROTATE_RATE * time.delta_secs(),
SelectAction::RotateRight => preview.yaw += ROTATE_RATE * time.delta_secs(),
+65 -10
View File
@@ -152,7 +152,17 @@ 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>,
/// **Write it through [`Roster::select`]**, never directly — see [`Roster::select_seq`].
selected: Option<usize>,
/// Bumped by every [`Roster::select`], whether or not the row changed.
///
/// The ref's `SelectCharacter` zeroes the select facing **unconditionally**: `0x472950`'s
/// `mov ds:0xb4217c, 0` sits one instruction *above* the already-built discriminator, so it
/// dominates both legs, and the merged tail re-applies it geometrically — so re-clicking the
/// row you are already on snaps the character square again (wow-re
/// `glue/scratch/glue-preview-facing-law.md`, 1533). A counter rather than change-detection on
/// `selected`, because "the same index again" is a selection.
pub(super) select_seq: u64,
/// 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
@@ -214,11 +224,23 @@ impl Roster {
.iter()
.position(|c| c.name.eq_ignore_ascii_case(name))
{
self.selected = Some(row);
self.select(Some(row));
self.just_created = None;
}
}
/// Select a row — the ref's `SelectCharacter`. Every selection goes through here so the facing
/// reset it owes cannot be forgotten at one of the seven call sites (see [`Self::select_seq`]).
pub(super) fn select(&mut self, row: Option<usize>) {
self.selected = row;
self.select_seq = self.select_seq.wrapping_add(1);
}
/// The selected row, if any.
pub(super) fn selected(&self) -> Option<usize> {
self.selected
}
/// The selected character, if any.
pub(super) fn selected_char(&self) -> Option<&Character> {
self.selected.and_then(|i| self.chars.get(i))
@@ -316,14 +338,15 @@ fn apply_roster_policy(
warn!(
"char select: created {name:?} is not on the fresh roster — selecting the last row"
);
roster.selected = roster.chars.len().checked_sub(1);
let last = roster.chars.len().checked_sub(1);
roster.select(last);
}
}
if roster.chars.is_empty() {
roster.selected = None;
roster.select(None);
} else {
let sel = roster.selected.unwrap_or(0).min(roster.chars.len() - 1);
roster.selected = Some(sel);
let sel = roster.selected().unwrap_or(0).min(roster.chars.len() - 1);
roster.select(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
@@ -338,7 +361,7 @@ fn apply_roster_policy(
{
Some(row) => {
info!("char select: WOW_CHARSELECT_PICK={name} — selecting row {row}");
roster.selected = Some(row);
roster.select(Some(row));
}
None => warn!("char select: WOW_CHARSELECT_PICK={name} not on this account"),
}
@@ -493,6 +516,14 @@ fn debug_glue_roundtrip(
/// 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.
///
/// **It ends the way a player ends a session** — by closing the window, not by writing an
/// `AppExit` of its own (decision 1528). That is not cosmetic: the close is the *latest*
/// announcement Bevy makes (`exit_on_all_closed` in `PostUpdate`, a frame after the request), so
/// it is the only exit that actually tests whether the shutdown tail is reachable. Exiting by a
/// hand-written `AppExit` from `Update` skipped that question for this smoke's whole life, and the
/// bug it would have caught — every saved variable, every addon file and the camera pose lost on
/// every window close — shipped underneath it.
#[allow(clippy::too_many_arguments)] // a smoke test that drives the whole round trip
fn debug_logout_smoke(
state: Res<State<ClientState>>,
@@ -503,6 +534,8 @@ fn debug_logout_smoke(
streamer: Res<benilla_world::terrain_stream::TerrainStreamer>,
time: Res<Time>,
mut exit: MessageWriter<AppExit>,
mut close: MessageWriter<bevy::window::WindowCloseRequested>,
windows: Query<Entity, With<bevy::window::PrimaryWindow>>,
mut phase: Local<u8>,
mut mark: Local<f32>,
) {
@@ -551,15 +584,34 @@ fn debug_logout_smoke(
}
},
4 if *state.get() == ClientState::InWorld && player.active && now - *mark > 3.0 => {
// **The suppressor reading is the point of the second entry, not a decoration on it**
// (B306, decision 1542). This leg has crossed the boundary on every run since 1291 and
// could only ever report that it *happened* — tiles, UI rebuilds, error counts — none
// of which a character who re-entered unable to move would disturb. `scripts/smoke.sh`
// fails on anything but `none`, and separately reports whether the run's logout was
// the rooted kind at all: a GM probe gets vmangos's instant path, which is B306's
// precondition missing, so a green here is not a B306 regression on its own.
info!(
"logout-smoke: re-entered — {} tiles resident, done",
streamer.residency().1
"logout-smoke: re-entered — {} tiles resident, suppressors: {}, done",
streamer.residency().1,
player.movement_suppressors()
);
*phase = 5;
}
5 => {
info!("logout-smoke: done");
exit.write(AppExit::Success);
// The player's exit: request the window close and let `bevy_window` take it from
// there. A run with no window (there is none in the headless harness) has no such
// path, so it says so and falls back rather than hanging until the timeout.
match windows.single() {
Ok(window) => {
close.write(bevy::window::WindowCloseRequested { window });
}
Err(_) => {
warn!("logout-smoke: no primary window — exiting by AppExit instead");
exit.write(AppExit::Success);
}
}
*phase = 6;
}
_ => {}
@@ -741,6 +793,9 @@ mod tests {
},
flags: 0,
equipment: [benilla_protocol::CharEnumItem::default(); 19],
pet_display_id: 0,
pet_level: 0,
pet_family: 0,
}
}
+18 -2
View File
@@ -142,7 +142,7 @@ pub(super) fn refresh_banner_and_buttons(
let SelectAction::Row(i) = action else {
continue;
};
let lit = roster.selected == Some(*i) || *interaction != Interaction::None;
let lit = roster.selected() == Some(*i) || *interaction != Interaction::None;
for child in children {
if let Ok(mut vis) = hilights.get_mut(*child) {
*vis = if lit {
@@ -182,7 +182,23 @@ pub(super) fn refresh_banner_and_buttons(
/// race's (the ref's `SetBackgroundModel` on the enum fileString — Orc before a list arrives or
/// with an empty account, the ref's OnLoad default), the look its geared enum record. Runs every
/// frame (cheap compares inside `GluePreview` writers — the builder keys on value change).
pub(super) fn feed_glue_preview(roster: Res<Roster>, mut preview: ResMut<GluePreview>) {
pub(super) fn feed_glue_preview(
roster: Res<Roster>,
mut preview: ResMut<GluePreview>,
mut showing: Local<Option<u64>>,
) {
// **The facing belongs to the displayed character, not to the screen** (1533): turning one
// character must not hand its angle to the next one you click, and a fresh character faces the
// stage's own forward until you turn it. `GluePreview::yaw` is one resource shared by all three
// glue screens, so the reset lives at the selection edge rather than in the resource.
//
// Keyed on the selection **counter**, not on who is shown: the ref's `SelectCharacter` zeroes
// the facing unconditionally (`0x472950`, above the already-built discriminator), so clicking
// the row you are already on re-squares the character too. See `Roster::select_seq`.
if *showing != Some(roster.select_seq) {
*showing = Some(roster.select_seq);
preview.yaw = 0.0;
}
let (race, look) = match roster.selected_char() {
Some(c) => (c.race, Some(GlueLook::Select(SelectLook::from(c)))),
None => (2, None), // UI_Orc — the ref's initial/empty-account scene