world books open, and anvils stop offering a cursor gear

- clicking a book in the world opens it and turns its pages: letters and world books share one reader
- the cursor's object rules are the client's own 31-row strategy table now, so decorative objects stop pretending to be usable
- trainer and tradeskill windows draw each row's icon by the reference's own law, and the trainer's filter dropdown toggles again
- water color follows the zone's light blend, so lakes stop snapping between area tints

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-07 16:03:35 +07:00
co-authored by Claude Fable 5
parent 19461d1e0f
commit d16d33dfca
55 changed files with 2485 additions and 358 deletions
@@ -824,3 +824,69 @@ fn state_events_leave_empty_wells_untinted() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The white-buttons regression (director-reported 2026-08-07, decision 1108): a slot that was
/// OCCUPIED — UpdateUsable painted its icon's 1/1/1 usable tint — then goes EMPTY (the feed's
/// character-switch diff: `set_action(None)` + `ACTIONBAR_SLOT_CHANGED`) kept the tint on the
/// now-artless icon region and drew it as a solid WHITE square. Two laws close it, both asserted
/// here: the empty arm HIDES the icon (ref ActionButton.lua l.168), and the engine emits nothing
/// for a texture-less region whatever its surviving tint (`0x7706e0` — the draw gate is `+0xcc`,
/// never the colour).
#[test]
fn an_occupied_slot_going_empty_leaves_no_white_plate() {
use benilla_ui::script::ActionState;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
s.set_action(
3,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_Nature_HealingTouch".into()),
kind: 0x00,
action: 5185,
count: 0,
}),
);
s.set_action_state(
3,
Some(ActionState {
usable: true,
..Default::default()
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
// The usable pass paints the occupied icon's 1/1/1 usable tint — the tint that survives.
s.fire_event("ACTIONBAR_UPDATE_USABLE", vec![]);
s.resolve();
assert!(
s.extract().iter().any(|q| matches!(&q.content,
QuadContent::Texture { path: Some(p), .. } if p.contains("Spell_Nature_HealingTouch"))),
"the occupied slot draws its icon"
);
// The character switch: the new character's table has nothing in slot 3.
s.set_action(3, None);
s.set_action_state(3, None);
s.fire_event("ACTIONBAR_SLOT_CHANGED", vec![ScriptValue::Int(3)]);
s.resolve();
for q in s.extract() {
match &q.content {
QuadContent::Texture { path: Some(p), .. }
if p.contains("Spell_Nature_HealingTouch") =>
{
panic!("the emptied slot still draws the old icon")
}
QuadContent::Texture {
path: None,
color: Some(c),
..
} if q.rect.is_some_and(|r| r.right - r.left <= 40.0) => {
// Well-sized only, as in the sibling test: page-wide solids are legitimate.
panic!("the emptied slot draws its surviving tint as a solid plate: {c:?}")
}
_ => {}
}
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -3,7 +3,7 @@
//! action-button machinery. Same file, two concerns: the strip along the bar's top is the
//! player-progress readout; the buttons are the input surface.
use benilla_ui::script::{ScriptValue, UiScript, UnitState};
use benilla_ui::script::{QuadContent, ScriptValue, UiScript, UnitState};
/// The XP bar's load set: the manifest prefix it actually needs — fonts, UIParent, the
/// tooltip, `TextStatusBar.xml` (the numerals machinery `BenillaExpBar_OnLoad` wires in),
@@ -251,6 +251,44 @@ fn the_max_level_rail_replaces_the_xp_bar_at_60() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The gryphon end caps stay over the strip and the rail across ANY show/hide history —
/// including the faithful hidden→visible tail re-stamp (0x76ae10 / 0884, which a stale-rail
/// recovery exercises: decision 1106's whole bug walked through here). The protection is
/// 0884's own key order: the caps ride the art frame's OVERLAY and the strip's fill its
/// ARTWORK, and within a `(strata, level)` bucket the LAYER outranks every stamp — no
/// re-stamp can lift a lower layer over them.
#[test]
fn the_gryphons_outrank_the_bars_across_hide_show_cycles() {
let mut s = exp_bar_harness();
s.set_player_xp(300, 400);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
// The relog shape: the strip hidden (a stale max-level rail), then re-shown; the rail's
// own arrival is ALWAYS a hidden→visible re-stamp (it is born hidden).
s.run("BenillaExpBar:Hide() BenillaExpBar:Show()").unwrap();
s.run("BenillaMaxLevelBar:Show()").unwrap();
s.resolve();
let quads = s.extract();
let z_max = |suffix: &str| {
quads
.iter()
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.ends_with(suffix))
})
.map(|q| q.z)
.max()
};
let fill = z_max("UI-StatusBar").expect("the strip's fill");
let rail = z_max("UI-MainMenuBar-MaxLevel").expect("the rail's plates");
let cap = z_max("UI-MainMenuBar-EndCap-Dwarf").expect("the gryphon end caps");
assert!(
fill < cap && rail < cap,
"the end caps must paint over the re-shown bars \
(fill z={fill:#x}, rail z={rail:#x}, cap z={cap:#x})"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The hover numerals (decision 1082, ref TextStatusBar.lua over `MainMenuBarExpText`): entering
/// the strip shows "curr / max" centered on the bar, leaving hides it (the lockShow refcount's
/// 0↔1 edge — the `statusBarText` cvar that pins it on permanently reads OFF here).
@@ -363,6 +363,76 @@ fn filter_hides_a_state_keeping_headers() {
);
}
/// The filter rows toggle **through the real dropdown kit** — the path a mouse takes, which the test
/// above deliberately shortcuts by faking `this`. `UIDropDownMenuButton_OnClick` runs the row's func
/// and only THEN flips the check for a `keepShownOnClick` row, so the func must not repaint the row
/// itself: an in-func `UIDropDownMenu_Initialize` re-derived the check from the fresh engine state and
/// the kit's flip then inverted it straight back — the check never moved on screen, and `this.checked`
/// stuck true, so a filter turned off could never be turned back on. Click, re-click, and re-open all
/// have to agree.
#[test]
fn filter_rows_toggle_through_the_dropdown_kit() {
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(menu()));
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
// Open the menu the way the capsule's arrow does. Row 2 is "Unavailable" (Initialize's order),
// checked because trainer_script() turned every state on.
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let row_checked = |s: &mut UiScript| {
s.eval::<bool>("return DropDownList1Button2Check:IsVisible() and true or false")
.unwrap()
};
assert!(row_checked(&mut s), "Unavailable starts checked");
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 6);
// Click it: the check clears, the engine filter clears, and Cleave (the unavailable service)
// drops out of the tree. Its Arms header stays.
s.run("this = DropDownList1Button2; UIDropDownMenuButton_OnClick()")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(!row_checked(&mut s), "the click clears the row's check");
assert!(
!s.eval::<bool>("return GetTrainerServiceTypeFilter('unavailable') == 1")
.unwrap(),
"and the engine's unavailable filter with it"
);
assert_eq!(
s.eval::<i64>("return GetNumTrainerServices()").unwrap(),
5,
"Cleave is hidden"
);
// Click it again: back on, both on screen and in the engine — the case the old code could never
// reach, because `this.checked` never went false.
s.run("this = DropDownList1Button2; UIDropDownMenuButton_OnClick()")
.unwrap();
assert!(row_checked(&mut s), "the re-click restores the check");
assert!(
s.eval::<bool>("return GetTrainerServiceTypeFilter('unavailable') == 1")
.unwrap(),
"and re-enables the filter"
);
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 6);
// Close and re-open: Initialize re-derives every row from the engine, so the menu agrees with
// what the clicks left behind (the two states left on, "used" still on from trainer_script()).
s.run("this = DropDownList1Button2; UIDropDownMenuButton_OnClick()")
.unwrap();
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
.unwrap(); // same owner → closes
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
.unwrap(); // re-open → re-Initialize
assert!(
!row_checked(&mut s),
"a re-opened menu shows the filter the clicks actually left off"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A menu bigger than the 11 visible rows (one skill line, 15 services → a 16-row tree), so the list
/// scrolls. The wheel spins the list even when the cursor is over a row — the engine bubbles the spin
/// up the parent chain to the window, which drives the faux-scroll bar.