a chat link is one token now, and tooltips print true costs

- a chat link edits as one atomic token: the caret can't land inside it, its colors survive, one keypress removes it whole
- spell tooltips print the resolved mana cost through the reference's own table walk, and the "% of base mana" line dies
- the spirit healer's confirm comes back after a cancel and wears its warning icon
- the dressing room's pane is a live animating model, and a tried-on bow lands in the hand

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-07 16:02:39 +07:00
co-authored by Claude Fable 5
parent d99ad8bc7d
commit cd58f90bec
46 changed files with 4840 additions and 1695 deletions
+24 -10
View File
@@ -276,6 +276,17 @@
return
end
local frame = getglobal("BenillaCharacterFrame")
-- ref CharacterFrame.lua:11, and the line whose absence desynced this window: the tab
-- row is selected HERE, from the page's own id, on EVERY path — including the one that
-- closes the window. Having it only on the tab-button OnClick meant a KEYBIND
-- (`C`/`SHIFT-P`, which call straight in here) switched the page and left the row
-- selecting the old tab; clicking that old tab then took the "already visible" branch
-- and shut the window instead of switching back (director, 2026-08-06). The id is the
-- page's own `id=` attribute — the loader really does apply it (loader/geometry.rs
-- SetID) — and `character_tests.rs` pins every page's id against its slot in
-- BENILLA_CHARACTERFRAME_SUBFRAMES, which is what keeps the two in step the day
-- Reputation lands between them.
PanelTemplates_SetTab(frame, subFrame:GetID())
if frame:IsVisible() then
if subFrame:IsVisible() then
HideUIPanel(frame)
@@ -292,15 +303,14 @@
-- The tab row's own OnClick (ref CharacterFrameTab_OnClick, l.35-48), now id-based (decision
-- 0437 phase 4 grows the row to 2) — the MerchantFrame.xml Merchant/Buyback pair's own shape
-- for a >1-tab window: `id` indexes BOTH the tab button and BENILLA_CHARACTERFRAME_SUBFRAMES
-- 1:1. PanelTemplates_SetTab visually selects the clicked tab (the ref's own
-- PanelTemplates_SetTab(this, subFrame:GetID()) call, dropped by the original phase-1a
-- single-tab comment above — restored now that a second tab exists to disagree with the
-- first). l.47's trailing PlaySound is the ref's OWN redundant second igCharacterInfoTab —
-- ToggleCharacter already plays it on the "switch to this tab" path, so this second call only
-- matters when the window is ALREADY open on this same tab, i.e. the HideUIPanel branch —
-- transcribed verbatim rather than "fixed" since it's the ref's own behavior, not a bug of ours.
-- 1:1. It does NOT select the tab itself — the ref's own tab OnClick doesn't either, because
-- ToggleCharacter is the single place that does it (above), so the click path and the
-- keybind path cannot disagree. l.47's trailing PlaySound is the ref's OWN redundant second
-- igCharacterInfoTab — ToggleCharacter already plays it on the "switch to this tab" path, so
-- this second call only matters when the window is ALREADY open on this same tab, i.e. the
-- HideUIPanel branch — transcribed verbatim rather than "fixed" since it's the ref's own
-- behavior, not a bug of ours.
function BenillaCharacterFrameTab_OnClick(id)
PanelTemplates_SetTab(getglobal("BenillaCharacterFrame"), id)
ToggleCharacter(BENILLA_CHARACTERFRAME_SUBFRAMES[id])
PlaySound("igCharacterInfoTab")
end
@@ -1369,7 +1379,7 @@
exactly fill the parent instead of the ref's setAllPoints="true" — the same effect
(see this file's header: frame-level setAllPoints was a no-op when this was written
and is wired now; the explicit size is equivalent, so it stays). -->
<Frame name="BenillaPaperDollFrame">
<Frame name="BenillaPaperDollFrame" id="1">
<Size><AbsDimension x="384" y="512"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<HitRectInsets><AbsInset left="0" right="30" top="0" bottom="45"/></HitRectInsets>
@@ -1409,7 +1419,11 @@
<Size><AbsDimension x="233" y="224"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="65" y="-78"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="ARTWORK">
<!-- BACKGROUND, not ARTWORK — the booth quad is an OPAQUE stand-in for a
widget the reference renders outside the layer batches, so it must
sit as low as a region goes or it swallows same-level siblings
(decision 1070; the pet page's diet icon is where that bit). -->
<Layer level="BACKGROUND">
<Texture name="$parentTexture">
<Size><AbsDimension x="233" y="224"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
+14 -4
View File
@@ -24,9 +24,13 @@
player" and "put the player's own gear back on it" are the same effect.
- The ref's `scale="2.0"` on the model widget is a 3D-widget zoom with no meaning for a
baked pane — the booth frames the body from its own bounds (`framing::body_frame`), the
same law the paper doll is framed by.
- The pane animates in the ref (a live widget playing Stand); ours is the frozen bake the
paper doll beside it is (decision 0822 — the same look call, taken the same way).
same law the paper doll is framed by. The bake is framed at THIS pane's 316×351 aspect,
not square, or the UI's stretch would leave every character 11% too tall (decision 1069).
- The pane ANIMATES, as the ref's live widget does — Stand looping (decision 1069; the look
call 0822 §5 left open, made by the director). Its booth is TRANSPARENT for the same
reason a widget is: the dark room behind the character is the `DressUpBackground-<Race>`
art below, and Reset/Close sit on that art *inside* this pane's rect (both anchor sets
transcribed byte-identically from the ref, which is why the overlap is the ref's too).
-->
<Script><![CDATA[
-- GlobalStrings this window uses (ref-GlobalStrings.lua, cited per line; the
@@ -245,7 +249,13 @@
<Size><AbsDimension x="316" y="351"/></Size>
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="-11" y="85"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="ARTWORK">
<!-- BACKGROUND, not ARTWORK — the booth quad is an OPAQUE stand-in for a widget
the reference renders outside the layer batches, so it must sit as low as a
region goes or it swallows same-level siblings (decision 1070; the pet
page's diet icon is where that bit). This window is the one where the
change is visible: the CLOSE/RESET buttons are declared BEFORE the pane and
overlap its lower edge, so at ARTWORK the pane was painting over them. -->
<Layer level="BACKGROUND">
<Texture name="$parentTexture">
<Size><AbsDimension x="316" y="351"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
@@ -477,7 +477,11 @@
<Size><AbsDimension x="233" y="300"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="65" y="-78"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="ARTWORK">
<!-- BACKGROUND, not ARTWORK — the booth quad is an OPAQUE stand-in for a
widget the reference renders outside the layer batches, so it must
sit as low as a region goes or it swallows same-level siblings
(decision 1070; the pet page's diet icon is where that bit). -->
<Layer level="BACKGROUND">
<Texture name="$parentTexture">
<Size><AbsDimension x="233" y="300"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
@@ -401,7 +401,18 @@
<Size><AbsDimension x="318" y="224"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="25" y="-78"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="ARTWORK">
<!-- BACKGROUND, not ARTWORK — see decision 1070. The booth quad stands in for a
<PlayerModel> WIDGET, whose 3D content the reference renders outside the
layer batches entirely and with a transparent background; ours is one
OPAQUE texture inside them. The draw layer is BUCKET-WIDE and outranks the
frame (0884), so at ARTWORK this quad painted over every same-level sibling
region below OVERLAY — which is exactly how the diet icon two frames down
(BACKGROUND, the reference's own layer for it) became a mouse-enabled hole
with a working tooltip and nothing drawn (director, 2026-08-06). Sitting at
BACKGROUND, the pane's single region ties with such siblings and the link
stamp decides, restoring the reference's declaration order. A pane can only
ever stop covering things this way, never start. -->
<Layer level="BACKGROUND">
<Texture name="$parentTexture">
<Size><AbsDimension x="318" y="224"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
+6 -1
View File
@@ -432,7 +432,12 @@
<NormalFont inherits="GameFontHighlight" justifyH="LEFT"/>
</Button>
<Frame name="BenillaSkillFrame" enableMouse="true" parent="BenillaCharacterFrame" hidden="true" id="4">
<!-- id=3, NOT the ref's 4: a page's id is the slot it occupies in the WINDOW'S OWN tab row, and
ToggleCharacter selects the tab from it (ref CharacterFrame.lua:11). Ours is 3rd because
Reputation (the ref's 3) is not built — the same deviation CharacterFrame.xml's tab row
already flags. `character_tests.rs` pins every page's id against its slot in
BENILLA_CHARACTERFRAME_SUBFRAMES, so the day Reputation lands this is loud, not silent. -->
<Frame name="BenillaSkillFrame" enableMouse="true" parent="BenillaCharacterFrame" hidden="true" id="3">
<!-- The real 384×512 page. No <Anchors relativeTo> substitute for true parenting exists
(this file's header comment) — pinned to BenillaCharacterFrame's own TOPLEFT by name. -->
<Size><AbsDimension x="384" y="512"/></Size>
+26 -6
View File
@@ -685,14 +685,14 @@
-- OnHide(data), OnAccept(data, data2) (truthy return = keep open — RECOVER_CORPSE
-- waits for the server, XP_LOSS's first Accept re-asks), OnCancel(data, reason)
-- (reason ∈ "clicked"|"timeout"|"override"), OnUpdate(elapsed, dialog), timeout,
-- StartDelay, delayText, sound, whileDead, hideOnEscape, cancels.
-- StartDelay, delayText, sound, whileDead, hideOnEscape, cancels, showAlert (the
-- 420 widen + DialogAlertIcon, decision 1068).
-- · hasEditBox + maxLetters + EditBoxOnEnterPressed are live (decision 0668's
-- ADD_FRIEND/ADD_IGNORE are the first customers): the box shows, caps its length,
-- opens empty, and re-anchors the buttons under itself.
-- · Accepted but v1-inert: exclusive, showAlert (no alert icon/420-width yet),
-- interruptCinematic (cinematics are acked instantly — 0196), notClosableByLogout,
-- preferredIndex, hasWideEditBox (guild MOTD/notes — the guild arc), and the money
-- variant.
-- · Accepted but v1-inert: exclusive, interruptCinematic (cinematics are acked
-- instantly — 0196), notClosableByLogout, preferredIndex, hasWideEditBox (guild
-- MOTD/notes — the guild arc), and the money variant.
-- · text_arg1/2 stash on the DIALOG frame (the ref stashes them on the Text region;
-- our regions don't carry Lua fields).
-- · GetText("SECONDS"/"MINUTES") localization collapses to English singular/plural.
@@ -837,6 +837,17 @@
text:SetText(format(info.text, text_arg1, text_arg2))
end
-- The alert dress (ref l.1579-1589): every Show resets width 320 / icon hidden, then
-- a showAlert entry widens to 420 and shows the icon. The ref's neighbouring arms —
-- the guild-MOTD 420 widen and HELP_TICKET's 350 — wait for their dialogs.
local alertIcon = getglobal(dialog:GetName() .. "AlertIcon")
alertIcon:Hide()
dialog:SetWidth(320)
if info.showAlert then
dialog:SetWidth(420)
alertIcon:Show()
end
-- The edit box (ref l.1599-1624): shown only for a hasEditBox entry, capped at the
-- entry's maxLetters, and always empty on open — a name left over from the last time
-- the dialog ran would be sent by a reflexive Enter. The wide variant is guild-only
@@ -1062,7 +1073,8 @@
-- Ref StaticPopup.lua l.659-679, verbatim — its own OnUpdate poll (cursor emptied by any
-- other path → hide) replaces the old bespoke `deleteItemFlow` CURSOR_UPDATE driver.
-- showAlert/exclusive are v1-inert (engine header note).
-- exclusive is v1-inert (engine header note); showAlert is live since 1068 — the ref's
-- own "display the error image" case is this very dialog.
StaticPopupDialogs["DELETE_ITEM"] = {
text = DELETE_ITEM_TEXT,
button1 = YES_TEXT,
@@ -1580,6 +1592,14 @@
<Size><AbsDimension x="290" y="0"/></Size>
<Anchors><Anchor point="TOP" relativePoint="TOP"><Offset><AbsDimension x="0" y="-16"/></Offset></Anchor></Anchors>
</FontString>
<!-- The showAlert icon (ref template l.58-67 verbatim: 64x64 DialogAlertIcon at
LEFT +12). Show resets it hidden and shows it only for a showAlert entry —
the ref keeps the text 290 wide and centered even at the 420 width, so the
icon owning the left margin IS the reference look, not an overlap bug. -->
<Texture name="$parentAlertIcon" file="Interface\DialogFrame\DialogAlertIcon">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
+11
View File
@@ -182,6 +182,17 @@ fn probe_key_by_name(name: &str) -> Option<KeyCode> {
"F" => KeyCode::KeyF,
"Ctrl" => KeyCode::ControlLeft,
"Shift" => KeyCode::ShiftLeft,
// The text-editing keys. Added for decision 1077's hyperlink-atomicity law, whose whole
// observable — "one BACKSPACE removes a whole item link" — is a keypress no chat command
// and no Lua chunk can reach (`EditBox` has no Lua deletion API; the law lives behind the
// host's chord table). Without these a caret/deletion defect could only be reproduced by
// asking the director to type, which is the asymmetry probes exist to remove.
"Backspace" => KeyCode::Backspace,
"Delete" => KeyCode::Delete,
"Left" => KeyCode::ArrowLeft,
"Right" => KeyCode::ArrowRight,
"Home" => KeyCode::Home,
"End" => KeyCode::End,
_ => return None,
})
}
+16 -9
View File
@@ -70,6 +70,10 @@ pub(crate) struct DeathNet {
/// The spirit healer whose confirm (`SMSG_SPIRIT_HEALER_CONFIRM`) is awaiting the XP_LOSS
/// two-step; its Accept sends `CMSG_SPIRIT_HEALER_ACTIVATE` with this guid.
pub(crate) spirit_healer: Option<u64>,
/// Bumped on every `SMSG_SPIRIT_HEALER_CONFIRM` — the confirm announces per MESSAGE, not per
/// Some-edge (decision 1068): a Cancel sends nothing and clears nothing, so only a fresh
/// message can re-show the dialog, and the healer re-sends one on every gossip ask.
pub(crate) confirm_generation: u32,
/// Water-walking is granted on our mover (the ghost's walk-on-water). Mirrored for the swim
/// arc's future mover regime (0308 defers the actual water-surface walk); the ack itself is
/// the controller's.
@@ -99,8 +103,12 @@ struct DeathFeedState {
/// The pending resurrect offer has been announced to the UI (`RESURRECT_REQUEST` fired) —
/// held back while a player-caster's name is still resolving through the name cache.
offer_announced: bool,
/// The spirit-healer confirm has been announced (`CONFIRM_XP_LOSS` fired).
confirm_announced: bool,
/// The [`DeathNet::confirm_generation`] this latch last announced — a fresh
/// `SMSG_SPIRIT_HEALER_CONFIRM` re-fires `CONFIRM_XP_LOSS` through the latch (decision 1068;
/// the reclaim-latch pattern above). The old Some-edge latch here was B80's deadlock: it
/// reset only when `spirit_healer` went `None`, which nothing but Accept ever does, so one
/// Cancel swallowed every later confirm.
confirm_generation: u32,
}
/// The three corpse-range verdicts the reference's events distinguish (RECOVER_CORPSE /
@@ -245,13 +253,12 @@ fn feed_death(
None => feed.offer_announced = false,
_ => {}
}
match death_net.spirit_healer {
Some(_) if !feed.confirm_announced => {
feed.confirm_announced = true;
script.fire_event("CONFIRM_XP_LOSS", vec![]);
}
None => feed.confirm_announced = false,
_ => {}
// The confirm is message-fired, not state-edged (decision 1068): each SMSG bumps the
// generation, so asking the healer again after a Cancel brings the dialog back.
if death_net.spirit_healer.is_some() && feed.confirm_generation != death_net.confirm_generation
{
feed.confirm_generation = death_net.confirm_generation;
script.fire_event("CONFIRM_XP_LOSS", vec![]);
}
// ── The corpse-run range gate (0308 §5): fires the reference's range events on the edges. ──
@@ -108,6 +108,20 @@ pub(in crate::entities) struct PreviewSpec {
pub(in crate::entities) equipment: [CharEnumItem; 19],
/// `CHARACTER_FLAG_*` bits; only hide-helm / hide-cloak are read here.
pub(in crate::entities) flags: u32,
/// **Whose held law this look follows** (decision 1076) — the one place the two previews
/// genuinely differ, and a *verified* difference rather than a convenience.
///
/// `false` — the **character-select mannequin** (`0x472950`), whose equipment loop skips
/// `EQUIPMENT_SLOT_RANGED` outright (`0x472bfe cmp esi,0x11; je`) and forces SheatheType 0.
///
/// `true` — the **dressing room** (`DressUpModel::TryOn 0x504d90`), which does not skip it: its
/// remap table (`0x504b7c` → `0x504b44`) sends `INVTYPE_RANGED` down the same held arm as a
/// weapon, and `sheathFlag = 0` is pushed literally at `0x504adc`, so a tried-on ranged weapon
/// is installed **at a hand and never at a sheath point, whatever its SheatheType** — by the
/// world's own split (bow → HandLeft, gun/crossbow/wand/thrown → HandRight). Which is exactly
/// why this rides [`placement`]'s existing, already-verified ranged arm rather than growing a
/// second copy of that mapping.
pub(in crate::entities) ranged_in_hand: bool,
}
/// What one [`assemble`] produced — the content half of a preview bake, in the booth's own shape.
@@ -240,6 +254,8 @@ pub(in crate::entities) fn build_glue_preview(
facial_hair,
equipment,
flags,
// The mannequin's own law: the select build skips EQUIPMENT_SLOT_RANGED (0x472bfe).
ranged_in_hand: false,
};
let Some(a) = assemble(
&spec,
@@ -350,6 +366,11 @@ pub(in crate::entities) fn build_dressup_preview(
// The world path honours no hide-helm/hide-cloak flag for a live player either (the helm
// comes straight off the visible-item slot), so the preview of that player carries none.
flags: 0,
// The widget's law, not the mannequin's (decision 1076). Unconditional, because the ranged
// slot of a dressing-room look is only ever filled by a TRY-ON: `crate::ui_dressup` leaves
// a worn ranged weapon out, since the reference's Dress()/SetUnit clones the live world
// model and a ranged weapon shows there only while ranged-drawn.
ranged_in_hand: true,
};
let Some(a) = assemble(
&spec,
@@ -432,7 +453,7 @@ fn assemble(spec: &PreviewSpec, ctx: &mut PreviewCtx) -> Option<Assembled> {
} else {
equipment[ENUM_HELM].display_id
};
let mut held = held_wants(&equipment, helm, race, sex);
let mut held = held_wants(&equipment, helm, race, sex, spec.ranged_in_hand);
// Per-hand grip `[right, left]`: a hand whose attach point holds a weapon curls into `HandsClosed`
// (wow-re `hand-grip-mechanism.md` — the ref's paperdoll rule `0x5059a0`: attach-point occupancy, per
// hand). Resolved here because the flat rider list drops each held item's attach id. A shield sits on
@@ -692,7 +713,18 @@ struct HeldWant {
/// (its ranged arm only yields while ranged-drawn). A held off-hand frill (INVTYPE_HOLDABLE)
/// rides the same hand law — the verdict enumerates weapons/shields only, and the world's held
/// placement is the natural reading for the rest.
fn held_wants(equipment: &[CharEnumItem; 19], helm: u32, race: u8, sex: u8) -> Vec<HeldWant> {
///
/// **`ranged_in_hand` is the dressing room's departure from that** (decision 1076): the dress-up
/// widget does *not* skip the ranged slot, and installs it at a hand. That is the same mapping
/// [`placement`]'s **ranged-drawn** arm already carries, so the flag simply chooses which sheath
/// state the ranged slot is asked with — the melee pair is always asked drawn.
fn held_wants(
equipment: &[CharEnumItem; 19],
helm: u32,
race: u8,
sex: u8,
ranged_in_hand: bool,
) -> Vec<HeldWant> {
let mut wants = Vec::new();
if helm != 0 {
wants.push(HeldWant {
@@ -721,7 +753,16 @@ fn held_wants(equipment: &[CharEnumItem; 19], helm: u32, race: u8, sex: u8) -> V
if item.display_id == 0 {
continue;
}
let Some(attach) = placement(held_slot, item.inventory_type as u32, 0, 1) else {
// Per-slot, because the two arms want opposite sheath states: the melee pair yields while
// melee-drawn (1), the ranged slot only while ranged-drawn (2). The mannequin asks every
// slot drawn-melee, which is its own ranged skip; the dressing room asks the ranged slot
// drawn-ranged, which is the widget's hand install (decision 1076).
let unit_sheath = if held_slot == 2 && ranged_in_hand {
2
} else {
1
};
let Some(attach) = placement(held_slot, item.inventory_type as u32, 0, unit_sheath) else {
continue;
};
let kind = if item.inventory_type == 14 {
@@ -813,4 +854,65 @@ mod tests {
}
}
}
/// **The two held laws, side by side** (decision 1076). The character-select mannequin skips
/// `EQUIPMENT_SLOT_RANGED` outright (`0x472bfe`); the dressing-room widget installs it at a
/// hand, by the world's own split — bow (INVTYPE_RANGED 15) to HandLeft, gun/crossbow/wand
/// (26) and thrown (25) to HandRight.
///
/// Asserted per inventory type rather than once, because a one-sided implementation — placing
/// every ranged type at the same hand — is the plausible wrong answer and it still shows *a*
/// bow, which is all the report asked for.
#[test]
fn the_dressing_room_hands_a_ranged_weapon_where_the_mannequin_drops_it() {
let ranged = |inv: u8| {
let mut e = [CharEnumItem::default(); 19];
e[ENUM_HELD[2]] = CharEnumItem {
display_id: 8500,
inventory_type: inv,
};
e
};
for (inv, hand, what) in [
(15u8, attach_id::HAND_LEFT, "bow"),
(26, attach_id::HAND_RIGHT, "gun/crossbow/wand"),
(25, attach_id::HAND_RIGHT, "thrown"),
] {
let e = ranged(inv);
assert!(
held_wants(&e, 0, 1, 0, false).is_empty(),
"the select mannequin skips the ranged slot ({what})"
);
let wants = held_wants(&e, 0, 1, 0, true);
assert_eq!(wants.len(), 1, "the dressing room holds the {what}");
assert_eq!(wants[0].attach, hand, "{what} rides the right hand point");
assert_eq!(wants[0].display, 8500);
}
}
/// …and the flag touches ONLY the ranged slot: the melee pair is asked drawn either way, so a
/// dressing-room look still hands a sword to HandRight and a shield to the Shield point.
#[test]
fn the_ranged_flag_leaves_the_melee_pair_alone() {
let mut e = [CharEnumItem::default(); 19];
e[ENUM_HELD[0]] = CharEnumItem {
display_id: 5500,
inventory_type: 21, // WEAPONMAINHAND
};
e[ENUM_HELD[1]] = CharEnumItem {
display_id: 5600,
inventory_type: 14, // SHIELD
};
for ranged_in_hand in [false, true] {
let wants = held_wants(&e, 0, 1, 0, ranged_in_hand);
let at = |display: u32| {
wants
.iter()
.find(|w| w.display == display)
.map(|w| w.attach)
};
assert_eq!(at(5500), Some(attach_id::HAND_RIGHT));
assert_eq!(at(5600), Some(attach_id::SHIELD));
}
}
}
-941
View File
@@ -1,941 +0,0 @@
//! World interaction foundation — *what is the player pointing at?*
//!
//! This is the shared base for every "point at the world" feature: the debug **object inspector**
//! (today), and tomorrow hover tooltips, the contextual cursor (gear over objects, sword over
//! attackable units), mouseover-targeting, and right-click-to-interact. Built once so we don't build
//! picking + identity twice.
//!
//! Three pieces:
//! - [`WorldObject`] — an **identity** component on every pickable world entity (a doodad/WMO model, a
//! creature, a GameObject): its kind, a human label (model path or unit name), an id, and an optional
//! detail line. Attached at the spawn sites.
//! - [`MouseoverTarget`] — the resource [`update_mouseover`] fills each frame with the nearest
//! `WorldObject` under the cursor, found by ray-casting against the **actual mesh geometry** (so it
//! works on colliderless props — most doodads, including the campfire — which a physics raycast
//! misses).
//! - the **inspector surface** ([`inspect_ui`]) — a standalone, key-toggleable overlay (**the dev
//! chord + `I`**): a weak "armed" pill plus a small identity card that follows the cursor over any
//! picked object. It's its own surface, *not* a section of the dev-chord debug panel, so
//! identifying a thing costs one chord and no panel.
//! - the **cast journal** ([`journal`]) — the *temporal* half of the same instrument: a spell is an
//! event, gone before a cursor could reach it, so every cast edge is recorded as it flows past and
//! the same inspector overlay lists the recent ones, click-to-copy.
//!
//! Picking only runs while [`InspectMode`] is on (today's sole consumer, driven by the inspector
//! chord); when real player-facing consumers arrive it simply runs whenever any of them is active.
use benilla_assets::coords::wow_to_bevy;
use bevy::camera::primitives::Aabb;
use bevy::platform::collections::HashSet;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use bevy_egui::{egui, EguiContexts, EguiPrimaryContextPass};
use crate::debug_panel::{overlay_text, ModelKind, DEV_CHORD, OVERLAY_FILL, OVERLAY_TEXT_DIM};
use crate::net::ObjectStore;
use crate::player::WorldCamera;
use crate::ui_script::PointerOverUi;
mod journal;
/// The resident **pick geometry** of one drawn batch: the model's decoded `RenderSubmesh`, `Arc`-shared
/// with the model asset itself (decision 0857). The render forms are `RENDER_WORLD`-only since 0834 —
/// their main-world vertex data is gone after extract, which is why Bevy's `MeshRayCast` silently
/// stopped hitting every static model (the GO hover, the inspector, `WOW_PICK`). So the pickers read
/// triangles from THIS instead, through the same WoW→Bevy bake the render form was built with
/// (`submesh_to_static_mesh`: `wow_to_bevy` per vertex, billboard cards centred at their pivot).
/// Attached beside `Mesh3d` at every spawn site that also attaches a pick key ([`WorldObject`] /
/// `ModelPart`). A keyed entity carrying neither this nor [`PickBox`] is **not pickable** — pick
/// geometry is declared, never inferred from a bound (decision 0929).
#[derive(Component, Clone)]
pub struct PickMesh(pub std::sync::Arc<benilla_formats::RenderSubmesh>);
/// "My `Aabb` **is** my pick geometry" — the model-less cube fallback ([`crate::entities::attach`]),
/// whose cuboid is exactly its bound, so a slab test is the exact answer and 12 triangles would only
/// be a slower way to say it.
///
/// It exists as a **positive declaration** because the alternative — inferring it from the *absence*
/// of a [`PickMesh`] — silently promoted every other keyed-but-mesh-less drawn entity into a solid
/// invisible box. That is what broke the inspector inside a city WMO (decision 0929): a WMO group's
/// MLIQ pool is a drawn `Mesh3d` with no resident geometry, it inherits the building's
/// [`WorldObject`] from the placement's blanket tag, and its render mesh keeps the **whole** MLIQ
/// vertex grid — dry cells included, sitting at height 0 while the drawn lava is 86–127 yd below.
/// Ironforge's thirteen pools therefore hung ~100-yard invisible boxes over the entire city, and the
/// box-entry hit won every pick: every hover, on anything, answered "Wmo · ironforge.wmo".
#[derive(Component, Clone, Copy)]
pub struct PickBox;
/// One triangle-accurate hit from [`cast_pick_ray`]: the world-space point, the hit triangle's
/// geometric normal (two-sided — the orientation is the authored winding's), and the distance along
/// the ray.
pub struct RayHit {
pub point: Vec3,
pub normal: Vec3,
pub distance: f32,
}
/// The one query every mesh picker casts against: a part's resident geometry, its world pose, the
/// render world's own visibility verdict (the cast honours it, as `RayCastVisibility::VisibleInView`
/// did), its bound for the broad phase, and whether that bound is itself the pick shape
/// ([`PickBox`]).
pub type PickParts<'w, 's> = Query<
'w,
's,
(
Option<&'static PickMesh>,
&'static GlobalTransform,
&'static ViewVisibility,
Option<&'static Aabb>,
Has<PickBox>,
),
>;
/// The identity of a pickable world thing, read by the inspector now and by tooltips/cursor/targeting
/// later. Attached to a thing's renderable mesh entities at spawn.
#[derive(Component, Clone)]
pub struct WorldObject {
pub kind: ModelKind,
/// Primary label — a model path (doodads/WMOs/GameObjects) or a unit name. Shown to the player/dev.
pub label: String,
/// Identifier — placement uniqueId, server guid, or display id (`0` if none).
pub id: u32,
/// Optional second line of kind-specific detail (e.g. `"emitters: 2"`); shown when non-empty.
pub detail: String,
}
/// A clean left-*click* in the world — a press + release with no drag. Emitted by
/// [`crate::player::control`], which is the single arbiter of click-vs-drag because it owns the
/// left-drag camera orbit: a left *drag* engages the orbit and emits nothing, a left *click* emits this.
/// World-interaction consumers (target selection) read it instead of re-deciding the gesture; *where*
/// the cursor is is already tracked continuously (the hover pick), so this carries no position.
#[derive(Message, Clone, Copy)]
pub struct WorldClick;
/// A clean right-*click* in the world — same arbiter and drag test as [`WorldClick`], for the right
/// button (whose *drag* is the character turn). Vanilla's context action: attack a hostile under the
/// cursor (later: interact/gossip on a friendly).
#[derive(Message, Clone, Copy)]
pub struct WorldRightClick;
/// The right button's **DOWN edge** in the world — emitted at the press, before the click-vs-drag
/// test even starts, whenever the press belongs to the world (in the viewport off the UI, or any
/// press while a look session already owns the hidden cursor). The reference's
/// `CGWorldFrame::OnMouseDown 0x483c40` analogue: ground-targeting's right-click cancel hangs off
/// this edge (`0x492c20`, wow-re `world-click-targeting.md` Q3), which fires whether the press
/// becomes a click OR a turn-drag, and consumes nothing (the ref handler returns 0, so the
/// BUTTON2 turn and the release's context click still run).
#[derive(Message, Clone, Copy)]
pub struct WorldRightPress;
/// Whether mouseover picking runs. Today it's armed/disarmed by the **dev chord + `I`** inspector toggle
/// ([`toggle_inspect`]).
#[derive(Resource, Default)]
pub struct InspectMode {
pub enabled: bool,
}
/// The nearest [`WorldObject`] under the cursor this frame, or `None`. Consumers read `entity` and look
/// up its [`WorldObject`]; `point`/`distance` are the world-space hit.
#[derive(Resource, Default)]
pub struct MouseoverTarget {
pub entity: Option<Entity>,
pub point: Vec3,
pub distance: f32,
}
/// Ray-cast from the cursor into the world and record the nearest [`WorldObject`] hit. Restricted to
/// entities carrying `WorldObject` (so terrain, particle billboards, and other un-identified meshes are
/// transparent to the pick), and skipped entirely unless inspection is active.
fn update_mouseover(
inspect: Res<InspectMode>,
pointer_over_ui: Res<PointerOverUi>,
mut target: ResMut<MouseoverTarget>,
window: Query<&Window, With<PrimaryWindow>>,
camera: Query<(&Camera, &GlobalTransform), With<WorldCamera>>,
objects: Query<Entity, With<WorldObject>>,
parts: PickParts,
) {
if !inspect.enabled {
if target.entity.is_some() {
*target = MouseoverTarget::default();
}
return;
}
target.entity = None;
// The pointer is over the dev UI (e.g. the now-overlaid debug panel), not the world — don't pick
// behind it. This replaces the old "is the cursor in the inset world viewport?" test, which no
// longer means anything now the panel overlays a full-screen view.
if pointer_over_ui.0 {
return;
}
let Ok((camera, cam_tf)) = camera.single() else {
return;
};
let Ok(window) = window.single() else {
return;
};
let Some(cursor) = window.cursor_position() else {
return; // cursor left the window, or we're in mouselook (hidden)
};
let identified: HashSet<Entity> = objects.iter().collect();
if let Some((entity, point, distance)) =
pick_at_cursor(cursor, camera, cam_tf, &identified, &parts)
{
target.entity = Some(entity);
target.point = point;
target.distance = distance;
}
}
/// Cast a ray from the logical `cursor` position into the world and return the nearest hit among
/// `pickable`: `(entity, world point, distance)`. The `pickable` set restricts the cast (terrain,
/// particle billboards, and other un-identified meshes stay transparent), so callers choose *what* is
/// pickable while sharing *how* — the inspector's per-frame mouseover passes every [`WorldObject`], the
/// target picker passes only unit meshes (so a doodad in front doesn't block a click on a mob).
pub fn pick_at_cursor(
cursor: Vec2,
camera: &Camera,
cam_tf: &GlobalTransform,
pickable: &HashSet<Entity>,
parts: &PickParts,
) -> Option<(Entity, Vec3, f32)> {
let ray = camera.viewport_to_world(cam_tf, cursor).ok()?;
cast_pick_ray(ray, pickable, parts, false)
.into_iter()
.next()
.map(|(e, hit)| (e, hit.point, hit.distance))
}
/// Cast `ray` against `pickable`'s **resident geometry** ([`PickMesh`] — decision 0857: the render
/// meshes are `RENDER_WORLD`-only, so there is no main-world mesh data to cast against) and return
/// the hits nearest-first: one per entity (its nearest triangle), the whole list with `all_hits`
/// (the `WOW_PICK` probe's everything-along-the-ray reading) or just the front entity without.
///
/// Broad phase: a world-space slab test on each candidate's `Aabb` (entry distance), nearest entry
/// first, so the narrow walk stops as soon as a confirmed hit is closer than every remaining box. A
/// part with no bound (a `NoFrustumCulling` fx part) is narrow-tested unconditionally; a
/// [`PickBox`] entity takes its box entry as the hit, its cuboid being exactly its bound.
/// Triangles test **two-sided**, like the unit picker's narrow phase — a generous pick beats a
/// strict one at silhouette edges.
///
/// **Pick geometry is required, never inferred** (decision 0929): an entity that is neither a
/// [`PickMesh`] nor a [`PickBox`] is not pickable, however identified or bounded it is. The box hit
/// used to be the fallback for *any* keyed entity with an `Aabb` and no mesh, which quietly turned
/// a WMO's MLIQ pool — a drawn mesh with no resident geometry, wearing its building's identity —
/// into a city-sized invisible occluder. See [`PickBox`].
pub fn cast_pick_ray(
ray: Ray3d,
pickable: &HashSet<Entity>,
parts: &PickParts,
all_hits: bool,
) -> Vec<(Entity, RayHit)> {
let (origin, dir) = (ray.origin, *ray.direction);
let mut candidates: Vec<(f32, Entity)> = Vec::new();
for &entity in pickable {
let Ok((mesh, gt, vis, aabb, is_box)) = parts.get(entity) else {
continue;
};
if !vis.get() {
continue; // not drawn in any view this frame — the old cast's VisibleInView rule
}
let entry = match (aabb, mesh, is_box) {
(Some(aabb), Some(_), _) | (Some(aabb), None, true) => {
let (min, max) = world_aabb(aabb, gt);
match ray_aabb(origin, dir, min, max) {
Some(t) => t,
None => continue,
}
}
(None, Some(_), _) => 0.0,
// No pick geometry — a drawn mesh nobody armed for the ray (a liquid surface), or a
// `PickBox` with no bound to be. Not pickable, rather than pickable as its bound.
(_, None, _) => continue,
};
candidates.push((entry, entity));
}
candidates.sort_unstable_by(|a, b| a.0.total_cmp(&b.0));
let mut hits: Vec<(Entity, RayHit)> = Vec::new();
let mut best = f32::INFINITY;
for (entry, entity) in candidates {
if !all_hits && best < entry {
break; // every remaining box starts beyond the confirmed nearest hit
}
let Ok((mesh, gt, _, _, _)) = parts.get(entity) else {
continue;
};
let hit = match mesh {
Some(m) => ray_pick_mesh(m, gt, origin, dir),
// A `PickBox` (the broad phase admitted no other mesh-less candidate): its box entry is
// exactly its surface, the cuboid BEING its Aabb.
None => Some(RayHit {
point: origin + dir * entry,
normal: -dir,
distance: entry,
}),
};
if let Some(h) = hit {
best = best.min(h.distance);
hits.push((entity, h));
}
}
hits.sort_unstable_by(|a, b| a.1.distance.total_cmp(&b.1.distance));
if !all_hits {
hits.truncate(1);
}
hits
}
/// The narrow phase for one part: ray-test every triangle of its resident geometry, in **model-local
/// space** — the world ray mapped through the part's inverse affine, under which the ray parameter is
/// unchanged, so a local `t` (against the world-normalized direction's image) *is* the world distance.
/// Vertices go through the render form's own bake (`build_submesh_mesh`): `wow_to_bevy` per vertex,
/// a billboard card centred at its pivot — so the pick tests exactly the surface the part draws
/// (a card's live camera-facing rotation rides its `GlobalTransform`, shared here too).
fn ray_pick_mesh(mesh: &PickMesh, gt: &GlobalTransform, origin: Vec3, dir: Vec3) -> Option<RayHit> {
let geo = &*mesh.0;
let inv = gt.affine().inverse();
let local_origin = inv.transform_point3(origin);
let local_dir = inv.transform_vector3(dir);
let center = geo
.billboard
.as_ref()
.map_or(Vec3::ZERO, |b| wow_to_bevy(b.pivot));
let pos = |i: u32| Some(wow_to_bevy(*geo.positions.get(i as usize)?) - center);
let mut nearest: Option<(f32, [Vec3; 3])> = None;
for t in geo.indices.chunks_exact(3) {
let (Some(a), Some(b), Some(c)) = (pos(t[0]), pos(t[1]), pos(t[2])) else {
continue; // an out-of-range index — corrupt authoring; skip the triangle, not the model
};
let tri = [a, b, c];
if let Some(d) = ray_triangle(local_origin, local_dir, &tri) {
if nearest.is_none_or(|(nd, _)| d < nd) {
nearest = Some((d, tri));
}
}
}
let (t, tri) = nearest?;
let world = tri.map(|v| gt.transform_point(v));
let normal = (world[1] - world[0])
.cross(world[2] - world[0])
.normalize_or_zero();
Some(RayHit {
point: origin + dir * t,
normal,
distance: t,
})
}
/// Ray–triangle intersection (Möller–Trumbore), **two-sided**, returning `t ≥ 0` along `dir`
/// (unnormalized is fine — `t` is in `dir` lengths) or `None` on miss/parallel. Two-sided because a
/// posed mesh can present back faces at silhouette edges and a generous pick beats a strict one.
pub(crate) fn ray_triangle(origin: Vec3, dir: Vec3, tri: &[Vec3; 3]) -> Option<f32> {
let (e1, e2) = (tri[1] - tri[0], tri[2] - tri[0]);
let p = dir.cross(e2);
let det = e1.dot(p);
if det.abs() < 1e-8 {
return None;
}
let inv = 1.0 / det;
let s = origin - tri[0];
let u = s.dot(p) * inv;
if !(0.0..=1.0).contains(&u) {
return None;
}
let q = s.cross(e1);
let v = dir.dot(q) * inv;
if v < 0.0 || u + v > 1.0 {
return None;
}
let t = e2.dot(q) * inv;
(t >= 0.0).then_some(t)
}
/// A mesh's model-local [`Aabb`] transformed into a world-space axis-aligned box `(min, max)` — its 8
/// corners run through the entity's world transform, then min/max'd. (An AABB of the rotated box: a hair
/// larger than the true oriented box, which only makes the hover more forgiving.)
pub(crate) fn world_aabb(aabb: &Aabb, gt: &GlobalTransform) -> (Vec3, Vec3) {
let center = Vec3::from(aabb.center);
let he = Vec3::from(aabb.half_extents);
let mut min = Vec3::splat(f32::INFINITY);
let mut max = Vec3::splat(f32::NEG_INFINITY);
for sx in [-1.0, 1.0] {
for sy in [-1.0, 1.0] {
for sz in [-1.0, 1.0] {
let world = gt.transform_point(center + he * Vec3::new(sx, sy, sz));
min = min.min(world);
max = max.max(world);
}
}
}
(min, max)
}
/// Ray vs axis-aligned box (the slab test): the entry distance along `dir` if the ray hits, else `None`.
/// `dir` need not be normalized; a zero component is handled by the infinities `recip` produces.
pub(crate) fn ray_aabb(origin: Vec3, dir: Vec3, min: Vec3, max: Vec3) -> Option<f32> {
let inv = dir.recip();
let t1 = (min - origin) * inv;
let t2 = (max - origin) * inv;
let tmin = t1.min(t2).max_element();
let tmax = t1.max(t2).min_element();
(tmax >= tmin.max(0.0)).then_some(tmin.max(0.0))
}
/// The **dev chord + `I`** (for *inspect*) arms/disarms the inspector — off the bare-letter plane the
/// game's own bindings own (0585), on whichever plane this OS leaves free (0867). Unmistakable as a
/// chord, so unlike the old bare `i` it needs no chat-bar/EditBox gate.
fn toggle_inspect(keys: Res<ButtonInput<KeyCode>>, mut inspect: ResMut<InspectMode>) {
if crate::debug_panel::dev_chord(&keys, KeyCode::KeyI) {
inspect.enabled = !inspect.enabled;
}
}
/// How long the inspector card shows its "copied to clipboard" confirmation after a left-click.
const COPY_FLASH_SECS: f32 = 1.2;
/// A per-kind accent so the card's header is glanceable (which *sort* of thing am I over?) before you
/// even read the label.
fn kind_color(kind: ModelKind) -> egui::Color32 {
match kind {
ModelKind::Doodad => egui::Color32::from_rgb(140, 220, 140), // green — props/trees
ModelKind::Wmo => egui::Color32::from_rgb(150, 185, 240), // blue — buildings
ModelKind::Creature => egui::Color32::from_rgb(240, 205, 130), // gold — NPCs
ModelKind::GameObject => egui::Color32::from_rgb(220, 165, 220), // violet — GameObjects
}
}
/// The inspector's GameObject collision readout (decision 0763): does a hull exist, is it disabled
/// right now, and what stored state does the passability gate see. Named so the bundled `stores`
/// param stays readable.
type GoCollisionReadout = (
Has<avian3d::prelude::Collider>,
Has<avian3d::prelude::ColliderDisabled>,
Option<&'static crate::go_anim::GoAnim>,
);
/// The inspector's entity LIGHT readout (decision 0776): the lane this object's parts render
/// under, and which attach found the room — "two identical GameObjects a few yards apart, one lit
/// like the room and one like the street" is the report that made this a card line rather than a
/// rebuild with `WOW_INTERIOR_LOG`.
type EntityLightReadout = (
&'static crate::interior::InteriorAnchor,
Has<crate::interior::ContainmentAttach>,
);
/// The inspector overlay, drawn only while armed: a weak top-centre "armed" pill (so it's obvious the
/// mode is on and how to leave it) and, whenever the cursor is over an identified object, a compact
/// identity card pinned to the cursor. No chrome, no panel — its own lightweight surface.
#[allow(clippy::too_many_arguments)]
fn inspect_ui(
mut contexts: EguiContexts,
inspect: Res<InspectMode>,
mouseover: Res<MouseoverTarget>,
objects: Query<&WorldObject>,
// The pickable mesh is a child of the net entity; its descriptor store (`ObjectStore`) lives on the
// parent, so the readout hops child → parent.
parents: Query<&ChildOf>,
// Bundled into one param (the same arity ceiling as `click_input` below): the descriptor
// store + the coarse kind the line gates go by.
stores: (
Query<&ObjectStore>,
Query<&crate::net::NetEntity>,
// The GameObject collision readout (decision 0763): whether a hull exists, whether it is
// currently disabled, and the client's stored state the gate reads.
Query<GoCollisionReadout>,
Query<EntityLightReadout>,
),
guids: Query<&crate::net::Guid>,
castings: Query<&crate::creature_anim::Casting>,
drivers: Query<&crate::creature_anim::AnimDriver>,
anim_data: Option<Res<crate::creature_anim::AnimData>>,
spells: Option<Res<crate::ui_action::Spells>>,
mut names: ResMut<crate::names::NameCache>,
net_commands: Res<crate::net::NetCommands>,
// Bundled into one param (Bevy's system-function arity ceiling): the copy-click button, and
// the flag it must yield to — a left press this frame the UI already consumed as a
// cursor-payload world drop (0216 §3) must not ALSO land as an inspector copy-click, the same
// yield every other world left-press consumer gives it (see `PointerOverUi` above for the
// hover-time twin).
click_input: (
Res<ButtonInput<MouseButton>>,
Res<crate::ui_script::PlayerUiClickConsumed>,
),
time: Res<Time>,
mut copied_at: Local<Option<f32>>,
) -> Result {
let (buttons, click_consumed) = (&click_input.0, &click_input.1);
if !inspect.enabled {
return Ok(());
}
let ctx = contexts.ctx_mut()?;
// Armed indicator — small + dim, so it states "inspect is on" without competing with the world.
egui::Area::new(egui::Id::new("inspect_armed"))
.anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 8.0))
.show(ctx, |ui| {
egui::Frame::NONE
.inner_margin(egui::Margin::symmetric(8, 4))
.corner_radius(5.0)
.fill(OVERLAY_FILL)
.show(ui, |ui| {
overlay_text(ui);
// Spelled out, not ⌃⌘ — egui's default font stack has no glyph for U+2303 and
// would draw tofu.
ui.label(
egui::RichText::new(format!("inspect · {DEV_CHORD}+I to exit")).small(),
);
});
});
// The identity card: only when hovering a picked object, pinned just off the cursor tip.
let Some(obj) = mouseover.entity.and_then(|e| objects.get(e).ok()) else {
return Ok(());
};
let Some(cursor) = ctx.pointer_latest_pos() else {
return Ok(());
};
// A unit's decoded server vitals from its descriptor store (`ObjectStore`), if the picked mesh's
// parent has them — proof the descriptor pipeline (UpdateFields → ObjectValues → ECS) reached it.
let net_entity = mouseover
.entity
.and_then(|e| parents.get(e).ok())
.map(|c| c.parent());
let (stores, kinds, collision, lit) = (&stores.0, &stores.1, &stores.2, &stores.3);
let store = net_entity.and_then(|p| stores.get(p).ok());
// The unit's server name through the query cache — asks on first hover, fills on a later frame
// (the same ask-once path the unit frames use).
let name_line = net_entity
.and_then(|p| guids.get(p).ok())
.and_then(|g| names.resolve(g.0, &net_commands))
.map(str::to_string);
// Line gates go by the entity's KIND, not field presence: a create-seeded store answers every
// field (absent = 0, the descriptor truth), so "is the health field there" stopped meaning
// "is this a unit".
let kind = net_entity.and_then(|p| kinds.get(p).ok()).map(|n| n.kind);
let is_unit = matches!(
kind,
Some(benilla_protocol::EntityKind::Unit | benilla_protocol::EntityKind::Player)
);
let is_player = kind == Some(benilla_protocol::EntityKind::Player);
// The GameObject collision + state readout (decision 0763) — the line that closes the loop on
// "this door is drawn open but I can't walk through it". It answers, for the object under the
// cursor, the three facts the passability gate turns on: the client's stored `GAMEOBJECT_STATE`
// (`go_anim::go_state`, i.e. what we believe open/closed is), whether a collision hull exists at
// all, and whether it is currently disabled. A door reading `state 0 open · SOLID` is the bug
// live on screen; `state 0 open · passable` says the gate ran and something else is blocking.
let go_line = store
.filter(|_| kind == Some(benilla_protocol::EntityKind::GameObject))
.map(|s| {
let (has_hull, disabled, anim) = net_entity
.and_then(|p| collision.get(p).ok())
.unwrap_or((false, false, None));
let state = crate::go_anim::go_state(anim, s);
let word = match state {
0 => "open(ACTIVE)",
1 => "closed(READY)",
2 => "alt(DESTROYED)",
_ => "?",
};
let solidity = match (has_hull, disabled) {
(false, _) => "no hull",
(true, true) => "passable",
(true, false) => "SOLID",
};
let flags = s.0.gameobject_flags();
let mut named = Vec::new();
for (bit, name) in [
(0x1, "IN_USE"),
(0x2, "LOCKED"),
(0x4, "INTERACT_COND"),
(0x10, "NO_INTERACT"),
] {
if flags & bit != 0 {
named.push(name);
}
}
let flag_text = if named.is_empty() {
String::new()
} else {
format!(" [{}]", named.join("|"))
};
format!(
"go type {} · state {state} {word} · {solidity} · flags {flags:#x}{flag_text}",
s.0.gameobject_type_id()
)
});
let vitals_line = store.filter(|_| is_unit).map(|s| {
format!(
"hp {}/{} · level {}",
s.0.unit_health().unwrap_or(0),
s.0.unit_max_health().unwrap_or(0),
s.0.unit_level().unwrap_or(0)
)
});
// Raw bytes (not name-mapped): creature race/class don't share the player-race enum, so a label
// would mislead. The character model will name-map these for players specifically.
let appearance_line = store.filter(|_| is_unit).map(|s| {
format!(
"race {} · class {} · sex {}",
s.0.unit_race().unwrap_or(0),
s.0.unit_class().unwrap_or(0),
s.0.unit_gender().unwrap_or(0)
)
});
// Player-only customization: the compositor's input, shown raw so we can
// confirm the PLAYER_BYTES decode against an in-game character.
let customization_line = store.filter(|_| is_player).map(|s| {
format!(
"skin {} · face {} · hair {}/{} · facial {}",
s.0.player_skin().unwrap_or(0),
s.0.player_face().unwrap_or(0),
s.0.player_hair_style().unwrap_or(0),
s.0.player_hair_color().unwrap_or(0),
s.0.player_facial_hair().unwrap_or(0)
)
});
// A unit mid-cast (`SMSG_SPELL_START` .. GO — the `Casting` wire seam): which spell, by id and
// display name. The director's "what is it casting?" answered on hover; the finished cast's
// trail lives in the journal.
let casting_line = net_entity.and_then(|p| castings.get(p).ok()).map(|c| {
match spells.as_ref().and_then(|s| s.catalog.get(c.spell_id)) {
Some(d) => format!("casting {} \"{}\"", c.spell_id, d.name),
None => format!("casting {}", c.spell_id),
}
});
// The animation slots this frame (requested `AnimationData` ids — the selector's choice,
// before missing-clip substitution): the full-body base + any masked upper-body overlay.
let anim_line = net_entity.and_then(|p| drivers.get(p).ok()).map(|d| {
let fmt = |id: u16| match anim_data.as_ref().and_then(|a| a.0.name(id)) {
Some(name) => format!("{name}({id})"),
None => format!("{id}"),
};
let (base, overlay) = d.playing();
let base = base.map(&fmt).unwrap_or_else(|| "—".into());
// The base slot's live playback rate (decision 0903) — `speed / (moveSpeed · modelScale)`
// on a locomotion clip, a flat 1× on everything else. Shown so a "its walk is too fast"
// report is a hover away from a number instead of a hand-worked divisor; suppressed at
// exactly 1× so the ordinary case doesn't carry noise.
let rate = d.rate();
let rate = if (rate - 1.0).abs() > 1e-3 {
format!(" · rate {rate:.2}×")
} else {
String::new()
};
match overlay {
Some(o) => format!("anim {base} + overlay {}{rate}", fmt(o)),
None => format!("anim {base}{rate}"),
}
});
// The light lane + the attach that found it (decision 0776). Absent until the classifier has
// resolved the anchor once — a freshly streamed object shows no line rather than a wrong one.
let light_line = net_entity
.and_then(|p| lit.get(p).ok())
.map(|(anchor, containment)| {
format!(
"light {} · {} attach",
anchor.law_label(),
if containment {
"containment"
} else {
"down-ray"
}
)
});
// The lines shown in the card — also exactly what a left-click copies to the clipboard.
let mut lines = vec![format!("{:?}", obj.kind), obj.label.clone()];
if let Some(name) = &name_line {
lines.push(format!("\"{name}\""));
}
if obj.id != 0 {
lines.push(format!("id {}", obj.id));
}
if !obj.detail.is_empty() {
lines.push(obj.detail.clone());
}
if let Some(line) = &go_line {
lines.push(line.clone());
}
if let Some(line) = &vitals_line {
lines.push(line.clone());
}
if let Some(line) = &appearance_line {
lines.push(line.clone());
}
if let Some(line) = &customization_line {
lines.push(line.clone());
}
if let Some(line) = &casting_line {
lines.push(line.clone());
}
if let Some(line) = &anim_line {
lines.push(line.clone());
}
if let Some(line) = &light_line {
lines.push(line.clone());
}
lines.push(format!("{:.1} yd away", mouseover.distance));
// The inspector owns left-click while armed (player::control suppresses left-orbit during inspect),
// so a press over the hovered object copies the whole card to the clipboard.
if buttons.just_pressed(MouseButton::Left) && !click_consumed.0 {
ctx.copy_text(lines.join("\n"));
*copied_at = Some(time.elapsed_secs());
}
let just_copied = copied_at.is_some_and(|t| time.elapsed_secs() - t < COPY_FLASH_SECS);
egui::Area::new(egui::Id::new("inspect_card"))
.fixed_pos(cursor + egui::vec2(18.0, 18.0))
.show(ctx, |ui| {
egui::Frame::NONE
.inner_margin(egui::Margin::symmetric(8, 6))
.corner_radius(5.0)
.fill(OVERLAY_FILL)
.show(ui, |ui| {
overlay_text(ui);
ui.colored_label(kind_color(obj.kind), format!("{:?}", obj.kind));
ui.label(egui::RichText::new(&obj.label).monospace());
if obj.id != 0 {
ui.label(
egui::RichText::new(format!("id {}", obj.id)).color(OVERLAY_TEXT_DIM),
);
}
if !obj.detail.is_empty() {
ui.label(egui::RichText::new(&obj.detail).color(OVERLAY_TEXT_DIM));
}
if let Some(line) = &go_line {
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
}
if let Some(line) = &vitals_line {
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
}
if let Some(line) = &appearance_line {
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
}
if let Some(line) = &customization_line {
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
}
if let Some(line) = &casting_line {
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
}
if let Some(line) = &anim_line {
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
}
if let Some(line) = &light_line {
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
}
ui.label(
egui::RichText::new(format!("{:.1} yd away", mouseover.distance))
.color(OVERLAY_TEXT_DIM),
);
// Copy affordance, swapped for a brief confirmation after a left-click.
if just_copied {
ui.label(
egui::RichText::new("copied to clipboard")
.small()
.color(egui::Color32::from_rgb(140, 220, 140)),
);
} else {
ui.label(
egui::RichText::new("left-click to copy")
.small()
.color(OVERLAY_TEXT_DIM),
);
}
});
});
Ok(())
}
/// Registers the mouseover foundation (the [`MouseoverTarget`] + [`InspectMode`] resources and the
/// per-frame pick), the standalone dev-chord `I` inspector surface, and the cast journal (recording
/// always, drawing under the same toggle).
pub struct InteractPlugin;
impl Plugin for InteractPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<MouseoverTarget>()
.init_resource::<InspectMode>()
.init_resource::<journal::CastJournal>()
.add_message::<WorldClick>()
.add_message::<WorldRightClick>()
.add_message::<WorldRightPress>()
// After the UI keyboard feed because `update_mouseover` reads `PointerOverUi`, whose
// player-UI half `UiInput` writes — the pick must see this frame's hover, not last
// frame's. (`toggle_inspect` itself no longer needs the ordering: its dev chord
// can't be typed text, so it reads no keyboard-capture flag — decision 0585.)
.add_systems(
Update,
(toggle_inspect, update_mouseover)
.chain()
.after(crate::ui_script::UiInput),
)
// Always recording (messages persist two frames — no ordering constraint needed).
.add_systems(Update, journal::record_casts)
.add_systems(EguiPrimaryContextPass, (inspect_ui, journal::journal_ui));
}
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_formats::RenderSubmesh;
const TRI: [Vec3; 3] = [
Vec3::new(-1.0, 0.0, -1.0),
Vec3::new(1.0, 0.0, -1.0),
Vec3::new(0.0, 0.0, 1.0),
];
#[test]
fn ray_triangle_hits_at_the_right_distance() {
let t = ray_triangle(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y, &TRI).expect("hit");
assert!((t - 5.0).abs() < 1e-5);
}
#[test]
fn ray_triangle_is_two_sided() {
// Same triangle hit from below (reversed winding relative to the ray) still connects.
assert!(ray_triangle(Vec3::new(0.0, -5.0, 0.0), Vec3::Y, &TRI).is_some());
}
#[test]
fn ray_triangle_misses_outside_and_behind() {
// Outside the triangle's extent.
assert!(ray_triangle(Vec3::new(3.0, 5.0, 0.0), Vec3::NEG_Y, &TRI).is_none());
// Triangle behind the ray origin (t < 0).
assert!(ray_triangle(Vec3::new(0.0, 5.0, 0.0), Vec3::Y, &TRI).is_none());
// Parallel to the plane.
assert!(ray_triangle(Vec3::new(0.0, 5.0, 0.0), Vec3::X, &TRI).is_none());
}
/// [`TRI`]'s pre-image under `wow_to_bevy` (`bevy = (−y, z, −x)` ⇒ `wow = (−bz, −bx, by)`):
/// a submesh authored with THESE WoW-space positions must be picked exactly where the render
/// form draws it, i.e. at [`TRI`] in model-local Bevy space.
fn wow_tri() -> RenderSubmesh {
RenderSubmesh {
positions: vec![[1.0, 1.0, 0.0], [1.0, -1.0, 0.0], [-1.0, 0.0, 0.0]],
indices: vec![0, 1, 2],
..Default::default()
}
}
/// The picker ↔ render-form bake contract (decision 0857): [`ray_pick_mesh`] must test the
/// resident WoW-axes geometry through the SAME `wow_to_bevy` bake `submesh_to_static_mesh`
/// builds the drawn mesh with, under the part's world transform — the render mesh itself is
/// `RENDER_WORLD`-only (0834), so this path is the only thing keeping static models pickable.
#[test]
fn resident_geometry_picks_where_the_render_form_draws() {
let mesh = PickMesh(std::sync::Arc::new(wow_tri()));
// A translated + uniformly scaled part: the triangle spans x∈[8,12], z∈[-2,2] at y=1.
let gt =
GlobalTransform::from(Transform::from_xyz(10.0, 1.0, 0.0).with_scale(Vec3::splat(2.0)));
let hit = ray_pick_mesh(&mesh, &gt, Vec3::new(10.0, 6.0, 0.0), Vec3::NEG_Y)
.expect("the baked triangle must be hit under its world transform");
assert!((hit.distance - 5.0).abs() < 1e-4);
assert!(hit.point.abs_diff_eq(Vec3::new(10.0, 1.0, 0.0), 1e-4));
assert!(hit.normal.abs_diff_eq(Vec3::Y, 1e-4) || hit.normal.abs_diff_eq(-Vec3::Y, 1e-4));
// …and a miss stays a miss.
assert!(ray_pick_mesh(&mesh, &gt, Vec3::new(20.0, 6.0, 0.0), Vec3::NEG_Y).is_none());
}
/// Cast a ray at a hand-built world and report `(entity, distance)` for every hit, nearest
/// first — the whole ray, so a candidate that was silently dropped is distinguishable from one
/// that merely lost.
fn cast_in(world: &mut World, origin: Vec3, dir: Vec3) -> Vec<(Entity, f32)> {
use bevy::ecs::system::RunSystemOnce;
fn cast(
In((origin, dir)): In<(Vec3, Vec3)>,
ids: Query<Entity, With<ViewVisibility>>,
parts: PickParts,
) -> Vec<(Entity, f32)> {
let all: HashSet<Entity> = ids.iter().collect();
let ray = Ray3d::new(origin, Dir3::new(dir).expect("a non-zero ray"));
cast_pick_ray(ray, &all, &parts, true)
.into_iter()
.map(|(e, h)| (e, h.distance))
.collect()
}
world
.run_system_once_with(cast, (origin, dir))
.expect("cast")
}
/// Everything a drawn, bounded, visible part needs to be a pick candidate — minus the geometry,
/// which each test supplies (or deliberately withholds).
fn drawn(world: &mut World, at: Vec3, half: Vec3) -> EntityWorldMut<'_> {
use bevy::camera::visibility::SetViewVisibility;
let mut e = world.spawn((
GlobalTransform::from(Transform::from_translation(at)),
Aabb::from_min_max(-half, half),
ViewVisibility::HIDDEN,
));
e.get_mut::<ViewVisibility>()
.expect("just spawned")
.set_visible();
e
}
/// **Decision 0929.** A drawn, identified, bounded entity with NO pick geometry — a WMO group's
/// MLIQ pool, whose render mesh keeps the whole (mostly dry) vertex grid and so bounds a box
/// ~100 yd tall over a city — must be transparent to the ray. It used to be picked at its box
/// entry, which put an invisible occluder in front of everything: inside Ironforge every hover,
/// on any NPC or GameObject, answered "Wmo · ironforge.wmo".
#[test]
fn a_bounded_mesh_less_entity_is_not_a_pick_occluder() {
let mut world = World::new();
// The pool's box straddles the camera (entry 0.0 — it wins any distance sort it enters).
let pool = drawn(&mut world, Vec3::ZERO, Vec3::new(60.0, 60.0, 60.0)).id();
// The NPC behind it: real geometry at 10 yd (`wow_tri` spans x,z ∈ [-1,1] at y = 0).
let npc = drawn(&mut world, Vec3::new(0.0, -10.0, 0.0), Vec3::splat(1.0))
.insert(PickMesh(std::sync::Arc::new(wow_tri())))
.id();
let hits = cast_in(&mut world, Vec3::ZERO, Vec3::NEG_Y);
assert!(
!hits.iter().any(|(e, _)| *e == pool),
"the mesh-less pool must not be pickable at all, yet it hit: {hits:?}",
);
assert_eq!(hits.first().map(|(e, _)| *e), Some(npc));
}
/// The other half of the same law: an entity that DECLARES its box is its shape ([`PickBox`] —
/// the model-less cube fallback) is still picked, at the box entry.
#[test]
fn a_declared_pick_box_is_picked_at_its_box_entry() {
let mut world = World::new();
let cube = drawn(&mut world, Vec3::new(0.0, -10.0, 0.0), Vec3::splat(2.0))
.insert(PickBox)
.id();
let hits = cast_in(&mut world, Vec3::ZERO, Vec3::NEG_Y);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].0, cube);
assert!((hits[0].1 - 8.0).abs() < 1e-4, "{hits:?}"); // the box's near face
}
/// A billboard card's render form is centred at its pivot (`build_submesh_mesh`); the pick must
/// subtract the same pivot or it tests the card a whole pivot-offset away from where it draws.
#[test]
fn billboard_card_picks_pivot_centred() {
let mut sub = wow_tri();
// Pivot at WoW (0, 0, 3) → Bevy (0, 3, 0): the baked card shifts down 3 in model-local y.
sub.billboard = Some(benilla_formats::Billboard {
pivot: [0.0, 0.0, 3.0],
bone: 0,
kind: benilla_formats::BillboardKind::Spherical,
scale_anim: None,
seq_translations: Vec::new(),
});
let mesh = PickMesh(std::sync::Arc::new(sub));
// The card entity sits AT the pivot's world spot (the spawn sites bake
// `transform_point(pivot)` into its translation), so the authored triangle — 3 below the
// pivot in model-local Bevy y — draws at world y = 0: a straight-down ray from y = 8 hits
// at distance 8. Without the pivot subtraction it would (wrongly) hit at y = 3.
let gt = GlobalTransform::from(Transform::from_xyz(0.0, 3.0, 0.0));
let hit = ray_pick_mesh(&mesh, &gt, Vec3::new(0.0, 8.0, 0.0), Vec3::NEG_Y)
.expect("the pivot-centred card must be hit where it draws");
assert!((hit.distance - 8.0).abs() < 1e-4);
assert!(hit.point.abs_diff_eq(Vec3::ZERO, 1e-4));
}
}
+380
View File
@@ -0,0 +1,380 @@
//! The **inspector surface** — the dev-chord `I` overlay: an "armed" pill plus a compact identity
//! card that follows the cursor over whatever [`MouseoverTarget`] picked. Split from the module
//! face for size only; the toggle, the card, and its readout lines live here unchanged.
use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts};
use crate::debug_panel::{overlay_text, ModelKind, DEV_CHORD, OVERLAY_FILL, OVERLAY_TEXT_DIM};
use crate::net::ObjectStore;
use super::{InspectMode, MouseoverTarget, WorldObject};
/// The **dev chord + `I`** (for *inspect*) arms/disarms the inspector — off the bare-letter plane the
/// game's own bindings own (0585), on whichever plane this OS leaves free (0867). Unmistakable as a
/// chord, so unlike the old bare `i` it needs no chat-bar/EditBox gate.
pub(super) fn toggle_inspect(keys: Res<ButtonInput<KeyCode>>, mut inspect: ResMut<InspectMode>) {
if crate::debug_panel::dev_chord(&keys, KeyCode::KeyI) {
inspect.enabled = !inspect.enabled;
}
}
/// How long the inspector card shows its "copied to clipboard" confirmation after a left-click.
/// Shared with the journal's row-copy flash ([`super::journal`]).
pub(super) const COPY_FLASH_SECS: f32 = 1.2;
/// A per-kind accent so the card's header is glanceable (which *sort* of thing am I over?) before you
/// even read the label.
fn kind_color(kind: ModelKind) -> egui::Color32 {
match kind {
ModelKind::Doodad => egui::Color32::from_rgb(140, 220, 140), // green — props/trees
ModelKind::Wmo => egui::Color32::from_rgb(150, 185, 240), // blue — buildings
ModelKind::Creature => egui::Color32::from_rgb(240, 205, 130), // gold — NPCs
ModelKind::GameObject => egui::Color32::from_rgb(220, 165, 220), // violet — GameObjects
}
}
/// The inspector's GameObject collision readout (decision 0763): does a hull exist, is it disabled
/// right now, and what stored state does the passability gate see. Named so the bundled `stores`
/// param stays readable.
type GoCollisionReadout = (
Has<avian3d::prelude::Collider>,
Has<avian3d::prelude::ColliderDisabled>,
Option<&'static crate::go_anim::GoAnim>,
);
/// The inspector's entity LIGHT readout (decision 0776): the lane this object's parts render
/// under, and which attach found the room — "two identical GameObjects a few yards apart, one lit
/// like the room and one like the street" is the report that made this a card line rather than a
/// rebuild with `WOW_INTERIOR_LOG`.
type EntityLightReadout = (
&'static crate::interior::InteriorAnchor,
Has<crate::interior::ContainmentAttach>,
);
/// The inspector overlay, drawn only while armed: a weak top-centre "armed" pill (so it's obvious the
/// mode is on and how to leave it) and, whenever the cursor is over an identified object, a compact
/// identity card pinned to the cursor. No chrome, no panel — its own lightweight surface.
#[allow(clippy::too_many_arguments)]
pub(super) fn inspect_ui(
mut contexts: EguiContexts,
inspect: Res<InspectMode>,
mouseover: Res<MouseoverTarget>,
objects: Query<&WorldObject>,
// The pickable mesh is a child of the net entity; its descriptor store (`ObjectStore`) lives on the
// parent, so the readout hops child → parent.
parents: Query<&ChildOf>,
// Bundled into one param (the same arity ceiling as `click_input` below): the descriptor
// store + the coarse kind the line gates go by.
stores: (
Query<&ObjectStore>,
Query<&crate::net::NetEntity>,
// The GameObject collision readout (decision 0763): whether a hull exists, whether it is
// currently disabled, and the client's stored state the gate reads.
Query<GoCollisionReadout>,
Query<EntityLightReadout>,
),
guids: Query<&crate::net::Guid>,
castings: Query<&crate::creature_anim::Casting>,
drivers: Query<&crate::creature_anim::AnimDriver>,
anim_data: Option<Res<crate::creature_anim::AnimData>>,
spells: Option<Res<crate::ui_action::Spells>>,
mut names: ResMut<crate::names::NameCache>,
net_commands: Res<crate::net::NetCommands>,
// Bundled into one param (Bevy's system-function arity ceiling): the copy-click button, and
// the flag it must yield to — a left press this frame the UI already consumed as a
// cursor-payload world drop (0216 §3) must not ALSO land as an inspector copy-click, the same
// yield every other world left-press consumer gives it (see `PointerOverUi` above for the
// hover-time twin).
click_input: (
Res<ButtonInput<MouseButton>>,
Res<crate::ui_script::PlayerUiClickConsumed>,
),
time: Res<Time>,
mut copied_at: Local<Option<f32>>,
) -> Result {
let (buttons, click_consumed) = (&click_input.0, &click_input.1);
if !inspect.enabled {
return Ok(());
}
let ctx = contexts.ctx_mut()?;
// Armed indicator — small + dim, so it states "inspect is on" without competing with the world.
egui::Area::new(egui::Id::new("inspect_armed"))
.anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 8.0))
.show(ctx, |ui| {
egui::Frame::NONE
.inner_margin(egui::Margin::symmetric(8, 4))
.corner_radius(5.0)
.fill(OVERLAY_FILL)
.show(ui, |ui| {
overlay_text(ui);
// Spelled out, not ⌃⌘ — egui's default font stack has no glyph for U+2303 and
// would draw tofu.
ui.label(
egui::RichText::new(format!("inspect · {DEV_CHORD}+I to exit")).small(),
);
});
});
// The identity card: only when hovering a picked object, pinned just off the cursor tip.
let Some(obj) = mouseover.entity.and_then(|e| objects.get(e).ok()) else {
return Ok(());
};
let Some(cursor) = ctx.pointer_latest_pos() else {
return Ok(());
};
// A unit's decoded server vitals from its descriptor store (`ObjectStore`), if the picked mesh's
// parent has them — proof the descriptor pipeline (UpdateFields → ObjectValues → ECS) reached it.
let net_entity = mouseover
.entity
.and_then(|e| parents.get(e).ok())
.map(|c| c.parent());
let (stores, kinds, collision, lit) = (&stores.0, &stores.1, &stores.2, &stores.3);
let store = net_entity.and_then(|p| stores.get(p).ok());
// The unit's server name through the query cache — asks on first hover, fills on a later frame
// (the same ask-once path the unit frames use).
let name_line = net_entity
.and_then(|p| guids.get(p).ok())
.and_then(|g| names.resolve(g.0, &net_commands))
.map(str::to_string);
// Line gates go by the entity's KIND, not field presence: a create-seeded store answers every
// field (absent = 0, the descriptor truth), so "is the health field there" stopped meaning
// "is this a unit".
let kind = net_entity.and_then(|p| kinds.get(p).ok()).map(|n| n.kind);
let is_unit = matches!(
kind,
Some(benilla_protocol::EntityKind::Unit | benilla_protocol::EntityKind::Player)
);
let is_player = kind == Some(benilla_protocol::EntityKind::Player);
// The GameObject collision + state readout (decision 0763) — the line that closes the loop on
// "this door is drawn open but I can't walk through it". It answers, for the object under the
// cursor, the three facts the passability gate turns on: the client's stored `GAMEOBJECT_STATE`
// (`go_anim::go_state`, i.e. what we believe open/closed is), whether a collision hull exists at
// all, and whether it is currently disabled. A door reading `state 0 open · SOLID` is the bug
// live on screen; `state 0 open · passable` says the gate ran and something else is blocking.
let go_line = store
.filter(|_| kind == Some(benilla_protocol::EntityKind::GameObject))
.map(|s| {
let (has_hull, disabled, anim) = net_entity
.and_then(|p| collision.get(p).ok())
.unwrap_or((false, false, None));
let state = crate::go_anim::go_state(anim, s);
let word = match state {
0 => "open(ACTIVE)",
1 => "closed(READY)",
2 => "alt(DESTROYED)",
_ => "?",
};
let solidity = match (has_hull, disabled) {
(false, _) => "no hull",
(true, true) => "passable",
(true, false) => "SOLID",
};
let flags = s.0.gameobject_flags();
let mut named = Vec::new();
for (bit, name) in [
(0x1, "IN_USE"),
(0x2, "LOCKED"),
(0x4, "INTERACT_COND"),
(0x10, "NO_INTERACT"),
] {
if flags & bit != 0 {
named.push(name);
}
}
let flag_text = if named.is_empty() {
String::new()
} else {
format!(" [{}]", named.join("|"))
};
format!(
"go type {} · state {state} {word} · {solidity} · flags {flags:#x}{flag_text}",
s.0.gameobject_type_id()
)
});
let vitals_line = store.filter(|_| is_unit).map(|s| {
format!(
"hp {}/{} · level {}",
s.0.unit_health().unwrap_or(0),
s.0.unit_max_health().unwrap_or(0),
s.0.unit_level().unwrap_or(0)
)
});
// Raw bytes (not name-mapped): creature race/class don't share the player-race enum, so a label
// would mislead. The character model will name-map these for players specifically.
let appearance_line = store.filter(|_| is_unit).map(|s| {
format!(
"race {} · class {} · sex {}",
s.0.unit_race().unwrap_or(0),
s.0.unit_class().unwrap_or(0),
s.0.unit_gender().unwrap_or(0)
)
});
// Player-only customization: the compositor's input, shown raw so we can
// confirm the PLAYER_BYTES decode against an in-game character.
let customization_line = store.filter(|_| is_player).map(|s| {
format!(
"skin {} · face {} · hair {}/{} · facial {}",
s.0.player_skin().unwrap_or(0),
s.0.player_face().unwrap_or(0),
s.0.player_hair_style().unwrap_or(0),
s.0.player_hair_color().unwrap_or(0),
s.0.player_facial_hair().unwrap_or(0)
)
});
// A unit mid-cast (`SMSG_SPELL_START` .. GO — the `Casting` wire seam): which spell, by id and
// display name. The director's "what is it casting?" answered on hover; the finished cast's
// trail lives in the journal.
let casting_line = net_entity.and_then(|p| castings.get(p).ok()).map(|c| {
match spells.as_ref().and_then(|s| s.catalog.get(c.spell_id)) {
Some(d) => format!("casting {} \"{}\"", c.spell_id, d.name),
None => format!("casting {}", c.spell_id),
}
});
// The animation slots this frame (requested `AnimationData` ids — the selector's choice,
// before missing-clip substitution): the full-body base + any masked upper-body overlay.
let anim_line = net_entity.and_then(|p| drivers.get(p).ok()).map(|d| {
let fmt = |id: u16| match anim_data.as_ref().and_then(|a| a.0.name(id)) {
Some(name) => format!("{name}({id})"),
None => format!("{id}"),
};
let (base, overlay) = d.playing();
let base = base.map(&fmt).unwrap_or_else(|| "—".into());
// The base slot's live playback rate (decision 0903) — `speed / (moveSpeed · modelScale)`
// on a locomotion clip, a flat 1× on everything else. Shown so a "its walk is too fast"
// report is a hover away from a number instead of a hand-worked divisor; suppressed at
// exactly 1× so the ordinary case doesn't carry noise.
let rate = d.rate();
let rate = if (rate - 1.0).abs() > 1e-3 {
format!(" · rate {rate:.2}×")
} else {
String::new()
};
match overlay {
Some(o) => format!("anim {base} + overlay {}{rate}", fmt(o)),
None => format!("anim {base}{rate}"),
}
});
// The light lane + the attach that found it (decision 0776). Absent until the classifier has
// resolved the anchor once — a freshly streamed object shows no line rather than a wrong one.
let light_line = net_entity
.and_then(|p| lit.get(p).ok())
.map(|(anchor, containment)| {
format!(
"light {} · {} attach",
anchor.law_label(),
if containment {
"containment"
} else {
"down-ray"
}
)
});
// The lines shown in the card — also exactly what a left-click copies to the clipboard.
let mut lines = vec![format!("{:?}", obj.kind), obj.label.clone()];
if let Some(name) = &name_line {
lines.push(format!("\"{name}\""));
}
if obj.id != 0 {
lines.push(format!("id {}", obj.id));
}
if !obj.detail.is_empty() {
lines.push(obj.detail.clone());
}
if let Some(line) = &go_line {
lines.push(line.clone());
}
if let Some(line) = &vitals_line {
lines.push(line.clone());
}
if let Some(line) = &appearance_line {
lines.push(line.clone());
}
if let Some(line) = &customization_line {
lines.push(line.clone());
}
if let Some(line) = &casting_line {
lines.push(line.clone());
}
if let Some(line) = &anim_line {
lines.push(line.clone());
}
if let Some(line) = &light_line {
lines.push(line.clone());
}
lines.push(format!("{:.1} yd away", mouseover.distance));
// The inspector owns left-click while armed (player::control suppresses left-orbit during inspect),
// so a press over the hovered object copies the whole card to the clipboard.
if buttons.just_pressed(MouseButton::Left) && !click_consumed.0 {
ctx.copy_text(lines.join("\n"));
*copied_at = Some(time.elapsed_secs());
}
let just_copied = copied_at.is_some_and(|t| time.elapsed_secs() - t < COPY_FLASH_SECS);
egui::Area::new(egui::Id::new("inspect_card"))
.fixed_pos(cursor + egui::vec2(18.0, 18.0))
.show(ctx, |ui| {
egui::Frame::NONE
.inner_margin(egui::Margin::symmetric(8, 6))
.corner_radius(5.0)
.fill(OVERLAY_FILL)
.show(ui, |ui| {
overlay_text(ui);
ui.colored_label(kind_color(obj.kind), format!("{:?}", obj.kind));
ui.label(egui::RichText::new(&obj.label).monospace());
if obj.id != 0 {
ui.label(
egui::RichText::new(format!("id {}", obj.id)).color(OVERLAY_TEXT_DIM),
);
}
if !obj.detail.is_empty() {
ui.label(egui::RichText::new(&obj.detail).color(OVERLAY_TEXT_DIM));
}
if let Some(line) = &go_line {
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
}
if let Some(line) = &vitals_line {
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
}
if let Some(line) = &appearance_line {
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
}
if let Some(line) = &customization_line {
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
}
if let Some(line) = &casting_line {
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
}
if let Some(line) = &anim_line {
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
}
if let Some(line) = &light_line {
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
}
ui.label(
egui::RichText::new(format!("{:.1} yd away", mouseover.distance))
.color(OVERLAY_TEXT_DIM),
);
// Copy affordance, swapped for a brief confirmation after a left-click.
if just_copied {
ui.label(
egui::RichText::new("copied to clipboard")
.small()
.color(egui::Color32::from_rgb(140, 220, 140)),
);
} else {
ui.label(
egui::RichText::new("left-click to copy")
.small()
.color(OVERLAY_TEXT_DIM),
);
}
});
});
Ok(())
}
+1 -1
View File
@@ -277,7 +277,7 @@ pub(super) fn journal_ui(
}
// The same confirmation the card gives, per row.
if copied.is_some_and(|(at, t)| {
at == r.at && time.elapsed_secs() - t < super::COPY_FLASH_SECS
at == r.at && time.elapsed_secs() - t < super::inspect::COPY_FLASH_SECS
}) {
ui.label(
egui::RichText::new("copied to clipboard")
+174
View File
@@ -0,0 +1,174 @@
//! World interaction foundation — *what is the player pointing at?*
//!
//! This is the shared base for every "point at the world" feature: the debug **object inspector**,
//! hover tooltips, the contextual cursor (gear over objects, sword over attackable units),
//! mouseover-targeting, and right-click-to-interact. Built once so we don't build picking +
//! identity twice.
//!
//! The pieces:
//! - [`WorldObject`] — an **identity** component on every pickable world entity (a doodad/WMO model, a
//! creature, a GameObject): its kind, a human label (model path or unit name), an id, and an optional
//! detail line. Attached at the spawn sites.
//! - the **ray caster** ([`pick`]) — the pick-geometry declarations ([`PickMesh`] / [`PickBox`]) and
//! the shared triangle-accurate cast against the **actual resident mesh geometry** (so it works on
//! colliderless props — most doodads, including the campfire — which a physics raycast misses),
//! plus the generous inflated pass the mouse pick retries with (decision 1071).
//! - [`MouseoverTarget`] — the resource [`update_mouseover`] fills each frame with the nearest
//! `WorldObject` under the cursor, for the inspector.
//! - the **inspector surface** ([`inspect`]) — a standalone, key-toggleable overlay (**the dev
//! chord + `I`**): a weak "armed" pill plus a small identity card that follows the cursor over any
//! picked object. It's its own surface, *not* a section of the dev-chord debug panel, so
//! identifying a thing costs one chord and no panel.
//! - the **cast journal** ([`journal`]) — the *temporal* half of the same instrument: a spell is an
//! event, gone before a cursor could reach it, so every cast edge is recorded as it flows past and
//! the same inspector overlay lists the recent ones, click-to-copy.
//!
//! The inspector's own picking only runs while [`InspectMode`] is on (the inspector chord); the
//! player-facing consumers (`crate::target`) run their picks through [`pick`] unconditionally.
use bevy::platform::collections::HashSet;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use bevy_egui::EguiPrimaryContextPass;
use crate::debug_panel::ModelKind;
use crate::player::WorldCamera;
use crate::ui_script::PointerOverUi;
mod inspect;
mod journal;
mod pick;
pub use pick::{
cast_pick_ray, cast_pick_ray_inflated, pick_at_cursor, PickBox, PickMesh, PickParts,
};
pub(crate) use pick::{ray_aabb, ray_triangle, world_aabb};
/// The identity of a pickable world thing, read by the inspector now and by tooltips/cursor/targeting
/// later. Attached to a thing's renderable mesh entities at spawn.
#[derive(Component, Clone)]
pub struct WorldObject {
pub kind: ModelKind,
/// Primary label — a model path (doodads/WMOs/GameObjects) or a unit name. Shown to the player/dev.
pub label: String,
/// Identifier — placement uniqueId, server guid, or display id (`0` if none).
pub id: u32,
/// Optional second line of kind-specific detail (e.g. `"emitters: 2"`); shown when non-empty.
pub detail: String,
}
/// A clean left-*click* in the world — a press + release with no drag. Emitted by
/// [`crate::player::control`], which is the single arbiter of click-vs-drag because it owns the
/// left-drag camera orbit: a left *drag* engages the orbit and emits nothing, a left *click* emits this.
/// World-interaction consumers (target selection) read it instead of re-deciding the gesture; *where*
/// the cursor is is already tracked continuously (the hover pick), so this carries no position.
#[derive(Message, Clone, Copy)]
pub struct WorldClick;
/// A clean right-*click* in the world — same arbiter and drag test as [`WorldClick`], for the right
/// button (whose *drag* is the character turn). Vanilla's context action: attack a hostile under the
/// cursor (later: interact/gossip on a friendly).
#[derive(Message, Clone, Copy)]
pub struct WorldRightClick;
/// The right button's **DOWN edge** in the world — emitted at the press, before the click-vs-drag
/// test even starts, whenever the press belongs to the world (in the viewport off the UI, or any
/// press while a look session already owns the hidden cursor). The reference's
/// `CGWorldFrame::OnMouseDown 0x483c40` analogue: ground-targeting's right-click cancel hangs off
/// this edge (`0x492c20`, wow-re `world-click-targeting.md` Q3), which fires whether the press
/// becomes a click OR a turn-drag, and consumes nothing (the ref handler returns 0, so the
/// BUTTON2 turn and the release's context click still run).
#[derive(Message, Clone, Copy)]
pub struct WorldRightPress;
/// Whether mouseover picking runs. Today it's armed/disarmed by the **dev chord + `I`** inspector toggle
/// ([`inspect::toggle_inspect`]).
#[derive(Resource, Default)]
pub struct InspectMode {
pub enabled: bool,
}
/// The nearest [`WorldObject`] under the cursor this frame, or `None`. Consumers read `entity` and look
/// up its [`WorldObject`]; `point`/`distance` are the world-space hit.
#[derive(Resource, Default)]
pub struct MouseoverTarget {
pub entity: Option<Entity>,
pub point: Vec3,
pub distance: f32,
}
/// Ray-cast from the cursor into the world and record the nearest [`WorldObject`] hit. Restricted to
/// entities carrying `WorldObject` (so terrain, particle billboards, and other un-identified meshes are
/// transparent to the pick), and skipped entirely unless inspection is active.
fn update_mouseover(
inspect: Res<InspectMode>,
pointer_over_ui: Res<PointerOverUi>,
mut target: ResMut<MouseoverTarget>,
window: Query<&Window, With<PrimaryWindow>>,
camera: Query<(&Camera, &GlobalTransform), With<WorldCamera>>,
objects: Query<Entity, With<WorldObject>>,
parts: PickParts,
) {
if !inspect.enabled {
if target.entity.is_some() {
*target = MouseoverTarget::default();
}
return;
}
target.entity = None;
// The pointer is over the dev UI (e.g. the now-overlaid debug panel), not the world — don't pick
// behind it. This replaces the old "is the cursor in the inset world viewport?" test, which no
// longer means anything now the panel overlays a full-screen view.
if pointer_over_ui.0 {
return;
}
let Ok((camera, cam_tf)) = camera.single() else {
return;
};
let Ok(window) = window.single() else {
return;
};
let Some(cursor) = window.cursor_position() else {
return; // cursor left the window, or we're in mouselook (hidden)
};
let identified: HashSet<Entity> = objects.iter().collect();
if let Some((entity, point, distance)) =
pick_at_cursor(cursor, camera, cam_tf, &identified, &parts)
{
target.entity = Some(entity);
target.point = point;
target.distance = distance;
}
}
/// Registers the mouseover foundation (the [`MouseoverTarget`] + [`InspectMode`] resources and the
/// per-frame pick), the standalone dev-chord `I` inspector surface, and the cast journal (recording
/// always, drawing under the same toggle).
pub struct InteractPlugin;
impl Plugin for InteractPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<MouseoverTarget>()
.init_resource::<InspectMode>()
.init_resource::<journal::CastJournal>()
.add_message::<WorldClick>()
.add_message::<WorldRightClick>()
.add_message::<WorldRightPress>()
// After the UI keyboard feed because `update_mouseover` reads `PointerOverUi`, whose
// player-UI half `UiInput` writes — the pick must see this frame's hover, not last
// frame's. (`toggle_inspect` itself no longer needs the ordering: its dev chord
// can't be typed text, so it reads no keyboard-capture flag — decision 0585.)
.add_systems(
Update,
(inspect::toggle_inspect, update_mouseover)
.chain()
.after(crate::ui_script::UiInput),
)
// Always recording (messages persist two frames — no ordering constraint needed).
.add_systems(Update, journal::record_casts)
.add_systems(
EguiPrimaryContextPass,
(inspect::inspect_ui, journal::journal_ui),
);
}
}
+546
View File
@@ -0,0 +1,546 @@
//! The shared **ray caster** — the pick-geometry declarations and the triangle-accurate cast every
//! "what is under the cursor" consumer runs through: the inspector's mouseover, the target module's
//! GameObject hover, the `WOW_PICK` probe. Casts against **resident geometry** ([`PickMesh`] —
//! decision 0857), because the render meshes are `RENDER_WORLD`-only (0834) and a physics ray
//! misses colliderless props. Pick geometry is **declared, never inferred** (decision 0929).
use benilla_assets::coords::wow_to_bevy;
use bevy::camera::primitives::Aabb;
use bevy::platform::collections::HashSet;
use bevy::prelude::*;
/// The resident **pick geometry** of one drawn batch: the model's decoded `RenderSubmesh`, `Arc`-shared
/// with the model asset itself (decision 0857). The render forms are `RENDER_WORLD`-only since 0834 —
/// their main-world vertex data is gone after extract, which is why Bevy's `MeshRayCast` silently
/// stopped hitting every static model (the GO hover, the inspector, `WOW_PICK`). So the pickers read
/// triangles from THIS instead, through the same WoW→Bevy bake the render form was built with
/// (`submesh_to_static_mesh`: `wow_to_bevy` per vertex, billboard cards centred at their pivot).
/// Attached beside `Mesh3d` at every spawn site that also attaches a pick key
/// ([`super::WorldObject`] / `ModelPart`). A keyed entity carrying neither this nor [`PickBox`] is
/// **not pickable** — pick geometry is declared, never inferred from a bound (decision 0929).
#[derive(Component, Clone)]
pub struct PickMesh(pub std::sync::Arc<benilla_formats::RenderSubmesh>);
/// "My `Aabb` **is** my pick geometry" — the model-less cube fallback ([`crate::entities::attach`]),
/// whose cuboid is exactly its bound, so a slab test is the exact answer and 12 triangles would only
/// be a slower way to say it.
///
/// It exists as a **positive declaration** because the alternative — inferring it from the *absence*
/// of a [`PickMesh`] — silently promoted every other keyed-but-mesh-less drawn entity into a solid
/// invisible box. That is what broke the inspector inside a city WMO (decision 0929): a WMO group's
/// MLIQ pool is a drawn `Mesh3d` with no resident geometry, it inherits the building's
/// [`super::WorldObject`] from the placement's blanket tag, and its render mesh keeps the **whole**
/// MLIQ vertex grid — dry cells included, sitting at height 0 while the drawn lava is 86–127 yd
/// below. Ironforge's thirteen pools therefore hung ~100-yard invisible boxes over the entire city,
/// and the box-entry hit won every pick: every hover, on anything, answered "Wmo · ironforge.wmo".
#[derive(Component, Clone, Copy)]
pub struct PickBox;
/// One triangle-accurate hit from [`cast_pick_ray`]: the world-space point, the hit triangle's
/// geometric normal (two-sided — the orientation is the authored winding's), and the distance along
/// the ray.
pub struct RayHit {
pub point: Vec3,
pub normal: Vec3,
pub distance: f32,
}
/// The one query every mesh picker casts against: a part's resident geometry, its world pose, the
/// render world's own visibility verdict (the cast honours it, as `RayCastVisibility::VisibleInView`
/// did), its bound for the broad phase, and whether that bound is itself the pick shape
/// ([`PickBox`]).
pub type PickParts<'w, 's> = Query<
'w,
's,
(
Option<&'static PickMesh>,
&'static GlobalTransform,
&'static ViewVisibility,
Option<&'static Aabb>,
Has<PickBox>,
),
>;
/// Cast a ray from the logical `cursor` position into the world and return the nearest hit among
/// `pickable`: `(entity, world point, distance)`. The `pickable` set restricts the cast (terrain,
/// particle billboards, and other un-identified meshes stay transparent), so callers choose *what* is
/// pickable while sharing *how* — the inspector's per-frame mouseover passes every
/// [`super::WorldObject`], the target picker passes only unit meshes (so a doodad in front doesn't
/// block a click on a mob).
pub fn pick_at_cursor(
cursor: Vec2,
camera: &Camera,
cam_tf: &GlobalTransform,
pickable: &HashSet<Entity>,
parts: &PickParts,
) -> Option<(Entity, Vec3, f32)> {
let ray = camera.viewport_to_world(cam_tf, cursor).ok()?;
cast_pick_ray(ray, pickable, parts, false)
.into_iter()
.next()
.map(|(e, hit)| (e, hit.point, hit.distance))
}
/// Cast `ray` against `pickable`'s **resident geometry** ([`PickMesh`] — decision 0857: the render
/// meshes are `RENDER_WORLD`-only, so there is no main-world mesh data to cast against) and return
/// the hits nearest-first: one per entity (its nearest triangle), the whole list with `all_hits`
/// (the `WOW_PICK` probe's everything-along-the-ray reading) or just the front entity without.
///
/// Broad phase: a world-space slab test on each candidate's `Aabb` (entry distance), nearest entry
/// first, so the narrow walk stops as soon as a confirmed hit is closer than every remaining box. A
/// part with no bound (a `NoFrustumCulling` fx part) is narrow-tested unconditionally; a
/// [`PickBox`] entity takes its box entry as the hit, its cuboid being exactly its bound.
/// Triangles test **two-sided**, like the unit picker's narrow phase — a generous pick beats a
/// strict one at silhouette edges.
///
/// **Pick geometry is required, never inferred** (decision 0929): an entity that is neither a
/// [`PickMesh`] nor a [`PickBox`] is not pickable, however identified or bounded it is. The box hit
/// used to be the fallback for *any* keyed entity with an `Aabb` and no mesh, which quietly turned
/// a WMO's MLIQ pool — a drawn mesh with no resident geometry, wearing its building's identity —
/// into a city-sized invisible occluder. See [`PickBox`].
pub fn cast_pick_ray(
ray: Ray3d,
pickable: &HashSet<Entity>,
parts: &PickParts,
all_hits: bool,
) -> Vec<(Entity, RayHit)> {
cast_pick_ray_impl(ray, pickable, parts, all_hits, false)
}
/// [`cast_pick_ray`]'s **generous second pass** (decision 1071 — wow-re object-layer mouse-pick,
/// resolve `0x7089c0` pass 2, mouse-pick only): every vertex displaced by its **authored normal,
/// added raw** — 1 model-unit (× the part's world scale) outward, the same halo the unit picker
/// builds from skinned normals. A part without authored normals cannot build the halo and stays
/// exact-only, like the unit path. A [`PickBox`] inflates its cuboid by the same 1 model-unit —
/// the cube fallback stands in for a model, so the halo applies to it too. Returns **all** hits
/// (the caller ranks them by the reference's pass-2 priority ladder, not pure distance). The
/// reference clips its halo to the sequence bounds *sphere*; our broad bound is the tight mesh
/// `Aabb` inflated by the same 1 model-unit — strictly more permissive, never less (the target
/// module's standing direction of error).
pub fn cast_pick_ray_inflated(
ray: Ray3d,
pickable: &HashSet<Entity>,
parts: &PickParts,
) -> Vec<(Entity, RayHit)> {
cast_pick_ray_impl(ray, pickable, parts, true, true)
}
fn cast_pick_ray_impl(
ray: Ray3d,
pickable: &HashSet<Entity>,
parts: &PickParts,
all_hits: bool,
inflate: bool,
) -> Vec<(Entity, RayHit)> {
let (origin, dir) = (ray.origin, *ray.direction);
let mut candidates: Vec<(f32, Entity)> = Vec::new();
for &entity in pickable {
let Ok((mesh, gt, vis, aabb, is_box)) = parts.get(entity) else {
continue;
};
if !vis.get() {
continue; // not drawn in any view this frame — the old cast's VisibleInView rule
}
let entry = match (aabb, mesh, is_box) {
(Some(aabb), Some(_), _) | (Some(aabb), None, true) => {
// Inflated cast: the halo displaces at most 1 model-unit (local space), so the
// local bound grown by exactly that can never clip it.
let bound = if inflate {
Aabb {
center: aabb.center,
half_extents: aabb.half_extents + bevy::math::Vec3A::ONE,
}
} else {
*aabb
};
let (min, max) = world_aabb(&bound, gt);
match ray_aabb(origin, dir, min, max) {
Some(t) => t,
None => continue,
}
}
(None, Some(_), _) => 0.0,
// No pick geometry — a drawn mesh nobody armed for the ray (a liquid surface), or a
// `PickBox` with no bound to be. Not pickable, rather than pickable as its bound.
(_, None, _) => continue,
};
candidates.push((entry, entity));
}
candidates.sort_unstable_by(|a, b| a.0.total_cmp(&b.0));
let mut hits: Vec<(Entity, RayHit)> = Vec::new();
let mut best = f32::INFINITY;
for (entry, entity) in candidates {
if !all_hits && best < entry {
break; // every remaining box starts beyond the confirmed nearest hit
}
let Ok((mesh, gt, _, _, _)) = parts.get(entity) else {
continue;
};
let hit = match mesh {
Some(m) => ray_pick_mesh(m, gt, origin, dir, inflate),
// A `PickBox` (the broad phase admitted no other mesh-less candidate): its box entry is
// exactly its surface, the cuboid BEING its Aabb (grown by the halo when inflated).
None => Some(RayHit {
point: origin + dir * entry,
normal: -dir,
distance: entry,
}),
};
if let Some(h) = hit {
best = best.min(h.distance);
hits.push((entity, h));
}
}
hits.sort_unstable_by(|a, b| a.1.distance.total_cmp(&b.1.distance));
if !all_hits {
hits.truncate(1);
}
hits
}
/// The narrow phase for one part: ray-test every triangle of its resident geometry, in **model-local
/// space** — the world ray mapped through the part's inverse affine, under which the ray parameter is
/// unchanged, so a local `t` (against the world-normalized direction's image) *is* the world distance.
/// Vertices go through the render form's own bake (`build_submesh_mesh`): `wow_to_bevy` per vertex,
/// a billboard card centred at its pivot — so the pick tests exactly the surface the part draws
/// (a card's live camera-facing rotation rides its `GlobalTransform`, shared here too). With
/// `inflate` (the generous pass 2, decision 1071), each vertex is additionally displaced by its
/// authored normal, raw — 1 model-unit in local space, which the world transform then scales,
/// exactly the reference's `skinned_pos + rot·normal` with no extra constant. `wow_to_bevy` is a
/// pure axis permutation with sign flips (orthonormal), so applying it to the normal is exact. A
/// submesh authored without normals can't build the halo and reports a miss (the exact pass
/// already had its say).
fn ray_pick_mesh(
mesh: &PickMesh,
gt: &GlobalTransform,
origin: Vec3,
dir: Vec3,
inflate: bool,
) -> Option<RayHit> {
let geo = &*mesh.0;
if inflate && geo.normals.len() != geo.positions.len() {
return None; // no authored normals — no halo to build
}
let inv = gt.affine().inverse();
let local_origin = inv.transform_point3(origin);
let local_dir = inv.transform_vector3(dir);
let center = geo
.billboard
.as_ref()
.map_or(Vec3::ZERO, |b| wow_to_bevy(b.pivot));
let pos = |i: u32| {
let p = wow_to_bevy(*geo.positions.get(i as usize)?) - center;
Some(if inflate {
p + wow_to_bevy(geo.normals[i as usize])
} else {
p
})
};
let mut nearest: Option<(f32, [Vec3; 3])> = None;
for t in geo.indices.chunks_exact(3) {
let (Some(a), Some(b), Some(c)) = (pos(t[0]), pos(t[1]), pos(t[2])) else {
continue; // an out-of-range index — corrupt authoring; skip the triangle, not the model
};
let tri = [a, b, c];
if let Some(d) = ray_triangle(local_origin, local_dir, &tri) {
if nearest.is_none_or(|(nd, _)| d < nd) {
nearest = Some((d, tri));
}
}
}
let (t, tri) = nearest?;
let world = tri.map(|v| gt.transform_point(v));
let normal = (world[1] - world[0])
.cross(world[2] - world[0])
.normalize_or_zero();
Some(RayHit {
point: origin + dir * t,
normal,
distance: t,
})
}
/// Ray–triangle intersection (Möller–Trumbore), **two-sided**, returning `t ≥ 0` along `dir`
/// (unnormalized is fine — `t` is in `dir` lengths) or `None` on miss/parallel. Two-sided because a
/// posed mesh can present back faces at silhouette edges and a generous pick beats a strict one.
pub(crate) fn ray_triangle(origin: Vec3, dir: Vec3, tri: &[Vec3; 3]) -> Option<f32> {
let (e1, e2) = (tri[1] - tri[0], tri[2] - tri[0]);
let p = dir.cross(e2);
let det = e1.dot(p);
if det.abs() < 1e-8 {
return None;
}
let inv = 1.0 / det;
let s = origin - tri[0];
let u = s.dot(p) * inv;
if !(0.0..=1.0).contains(&u) {
return None;
}
let q = s.cross(e1);
let v = dir.dot(q) * inv;
if v < 0.0 || u + v > 1.0 {
return None;
}
let t = e2.dot(q) * inv;
(t >= 0.0).then_some(t)
}
/// A mesh's model-local [`Aabb`] transformed into a world-space axis-aligned box `(min, max)` — its 8
/// corners run through the entity's world transform, then min/max'd. (An AABB of the rotated box: a hair
/// larger than the true oriented box, which only makes the hover more forgiving.)
pub(crate) fn world_aabb(aabb: &Aabb, gt: &GlobalTransform) -> (Vec3, Vec3) {
let center = Vec3::from(aabb.center);
let he = Vec3::from(aabb.half_extents);
let mut min = Vec3::splat(f32::INFINITY);
let mut max = Vec3::splat(f32::NEG_INFINITY);
for sx in [-1.0, 1.0] {
for sy in [-1.0, 1.0] {
for sz in [-1.0, 1.0] {
let world = gt.transform_point(center + he * Vec3::new(sx, sy, sz));
min = min.min(world);
max = max.max(world);
}
}
}
(min, max)
}
/// Ray vs axis-aligned box (the slab test): the entry distance along `dir` if the ray hits, else `None`.
/// `dir` need not be normalized; a zero component is handled by the infinities `recip` produces.
pub(crate) fn ray_aabb(origin: Vec3, dir: Vec3, min: Vec3, max: Vec3) -> Option<f32> {
let inv = dir.recip();
let t1 = (min - origin) * inv;
let t2 = (max - origin) * inv;
let tmin = t1.min(t2).max_element();
let tmax = t1.max(t2).min_element();
(tmax >= tmin.max(0.0)).then_some(tmin.max(0.0))
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_formats::RenderSubmesh;
const TRI: [Vec3; 3] = [
Vec3::new(-1.0, 0.0, -1.0),
Vec3::new(1.0, 0.0, -1.0),
Vec3::new(0.0, 0.0, 1.0),
];
#[test]
fn ray_triangle_hits_at_the_right_distance() {
let t = ray_triangle(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y, &TRI).expect("hit");
assert!((t - 5.0).abs() < 1e-5);
}
#[test]
fn ray_triangle_is_two_sided() {
// Same triangle hit from below (reversed winding relative to the ray) still connects.
assert!(ray_triangle(Vec3::new(0.0, -5.0, 0.0), Vec3::Y, &TRI).is_some());
}
#[test]
fn ray_triangle_misses_outside_and_behind() {
// Outside the triangle's extent.
assert!(ray_triangle(Vec3::new(3.0, 5.0, 0.0), Vec3::NEG_Y, &TRI).is_none());
// Triangle behind the ray origin (t < 0).
assert!(ray_triangle(Vec3::new(0.0, 5.0, 0.0), Vec3::Y, &TRI).is_none());
// Parallel to the plane.
assert!(ray_triangle(Vec3::new(0.0, 5.0, 0.0), Vec3::X, &TRI).is_none());
}
/// [`TRI`]'s pre-image under `wow_to_bevy` (`bevy = (−y, z, −x)` ⇒ `wow = (−bz, −bx, by)`):
/// a submesh authored with THESE WoW-space positions must be picked exactly where the render
/// form draws it, i.e. at [`TRI`] in model-local Bevy space.
fn wow_tri() -> RenderSubmesh {
RenderSubmesh {
positions: vec![[1.0, 1.0, 0.0], [1.0, -1.0, 0.0], [-1.0, 0.0, 0.0]],
indices: vec![0, 1, 2],
..Default::default()
}
}
/// The picker ↔ render-form bake contract (decision 0857): [`ray_pick_mesh`] must test the
/// resident WoW-axes geometry through the SAME `wow_to_bevy` bake `submesh_to_static_mesh`
/// builds the drawn mesh with, under the part's world transform — the render mesh itself is
/// `RENDER_WORLD`-only (0834), so this path is the only thing keeping static models pickable.
#[test]
fn resident_geometry_picks_where_the_render_form_draws() {
let mesh = PickMesh(std::sync::Arc::new(wow_tri()));
// A translated + uniformly scaled part: the triangle spans x∈[8,12], z∈[-2,2] at y=1.
let gt =
GlobalTransform::from(Transform::from_xyz(10.0, 1.0, 0.0).with_scale(Vec3::splat(2.0)));
let hit = ray_pick_mesh(&mesh, &gt, Vec3::new(10.0, 6.0, 0.0), Vec3::NEG_Y, false)
.expect("the baked triangle must be hit under its world transform");
assert!((hit.distance - 5.0).abs() < 1e-4);
assert!(hit.point.abs_diff_eq(Vec3::new(10.0, 1.0, 0.0), 1e-4));
assert!(hit.normal.abs_diff_eq(Vec3::Y, 1e-4) || hit.normal.abs_diff_eq(-Vec3::Y, 1e-4));
// …and a miss stays a miss.
assert!(ray_pick_mesh(&mesh, &gt, Vec3::new(20.0, 6.0, 0.0), Vec3::NEG_Y, false).is_none());
}
/// [`wow_tri`] with every vertex normal authored as model-local Bevy `+X` (WoW pre-image
/// `(0, −1, 0)`): the inflated pass displaces the whole triangle +1 model-unit in x.
fn wow_tri_with_x_normals() -> RenderSubmesh {
RenderSubmesh {
normals: vec![[0.0, -1.0, 0.0]; 3],
..wow_tri()
}
}
/// **Decision 1071 — the generous pass.** The inflated narrow phase displaces each vertex by
/// its authored normal, raw: 1 model-unit in local space, scaled by the part's world transform
/// (the reference's `skinned_pos + rot·normal`, no extra constant). A ray 1 world-unit beyond
/// the drawn silhouette misses the exact pass and hits the halo; the halo is 2 world-units wide
/// here because the part's scale is 2.
#[test]
fn inflated_pick_hits_the_one_model_unit_halo() {
let mesh = PickMesh(std::sync::Arc::new(wow_tri_with_x_normals()));
// Scale 2: the exact triangle spans x∈[8,12] at y=1; the halo shifts it to x∈[10,14].
let gt =
GlobalTransform::from(Transform::from_xyz(10.0, 1.0, 0.0).with_scale(Vec3::splat(2.0)));
// x=13: 1 world-unit past the exact edge (12), inside the ×2-scaled halo (14).
assert!(ray_pick_mesh(&mesh, &gt, Vec3::new(13.0, 6.0, 0.0), Vec3::NEG_Y, false).is_none());
let hit = ray_pick_mesh(&mesh, &gt, Vec3::new(13.0, 6.0, 0.0), Vec3::NEG_Y, true)
.expect("the halo must catch a ray 1 world-unit past the silhouette");
assert!((hit.distance - 5.0).abs() < 1e-4);
// …and the halo has its own edge: 2 world-units past the ×2 halo is still a miss.
assert!(ray_pick_mesh(&mesh, &gt, Vec3::new(16.0, 6.0, 0.0), Vec3::NEG_Y, true).is_none());
}
/// A submesh authored without normals cannot build the halo: the inflated pass reports a miss
/// (never a panic, never a silently un-displaced hit) — the exact pass already had its say.
#[test]
fn no_authored_normals_means_no_halo() {
let mesh = PickMesh(std::sync::Arc::new(wow_tri()));
let gt = GlobalTransform::from(Transform::from_xyz(10.0, 1.0, 0.0));
assert!(ray_pick_mesh(&mesh, &gt, Vec3::new(10.0, 6.0, 0.0), Vec3::NEG_Y, true).is_none());
}
/// Cast a ray at a hand-built world and report `(entity, distance)` for every hit, nearest
/// first — the whole ray, so a candidate that was silently dropped is distinguishable from one
/// that merely lost. `inflate` runs the generous pass instead of the exact one.
fn cast_in(world: &mut World, origin: Vec3, dir: Vec3, inflate: bool) -> Vec<(Entity, f32)> {
use bevy::ecs::system::RunSystemOnce;
fn cast(
In((origin, dir, inflate)): In<(Vec3, Vec3, bool)>,
ids: Query<Entity, With<ViewVisibility>>,
parts: PickParts,
) -> Vec<(Entity, f32)> {
let all: HashSet<Entity> = ids.iter().collect();
let ray = Ray3d::new(origin, Dir3::new(dir).expect("a non-zero ray"));
cast_pick_ray_impl(ray, &all, &parts, true, inflate)
.into_iter()
.map(|(e, h)| (e, h.distance))
.collect()
}
world
.run_system_once_with(cast, (origin, dir, inflate))
.expect("cast")
}
/// Everything a drawn, bounded, visible part needs to be a pick candidate — minus the geometry,
/// which each test supplies (or deliberately withholds).
fn drawn(world: &mut World, at: Vec3, half: Vec3) -> EntityWorldMut<'_> {
use bevy::camera::visibility::SetViewVisibility;
let mut e = world.spawn((
GlobalTransform::from(Transform::from_translation(at)),
Aabb::from_min_max(-half, half),
ViewVisibility::HIDDEN,
));
e.get_mut::<ViewVisibility>()
.expect("just spawned")
.set_visible();
e
}
/// **Decision 0929.** A drawn, identified, bounded entity with NO pick geometry — a WMO group's
/// MLIQ pool, whose render mesh keeps the whole (mostly dry) vertex grid and so bounds a box
/// ~100 yd tall over a city — must be transparent to the ray. It used to be picked at its box
/// entry, which put an invisible occluder in front of everything: inside Ironforge every hover,
/// on any NPC or GameObject, answered "Wmo · ironforge.wmo".
#[test]
fn a_bounded_mesh_less_entity_is_not_a_pick_occluder() {
let mut world = World::new();
// The pool's box straddles the camera (entry 0.0 — it wins any distance sort it enters).
let pool = drawn(&mut world, Vec3::ZERO, Vec3::new(60.0, 60.0, 60.0)).id();
// The NPC behind it: real geometry at 10 yd (`wow_tri` spans x,z ∈ [-1,1] at y = 0).
let npc = drawn(&mut world, Vec3::new(0.0, -10.0, 0.0), Vec3::splat(1.0))
.insert(PickMesh(std::sync::Arc::new(wow_tri())))
.id();
let hits = cast_in(&mut world, Vec3::ZERO, Vec3::NEG_Y, false);
assert!(
!hits.iter().any(|(e, _)| *e == pool),
"the mesh-less pool must not be pickable at all, yet it hit: {hits:?}",
);
assert_eq!(hits.first().map(|(e, _)| *e), Some(npc));
}
/// The other half of the same law: an entity that DECLARES its box is its shape ([`PickBox`] —
/// the model-less cube fallback) is still picked, at the box entry.
#[test]
fn a_declared_pick_box_is_picked_at_its_box_entry() {
let mut world = World::new();
let cube = drawn(&mut world, Vec3::new(0.0, -10.0, 0.0), Vec3::splat(2.0))
.insert(PickBox)
.id();
let hits = cast_in(&mut world, Vec3::ZERO, Vec3::NEG_Y, false);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].0, cube);
assert!((hits[0].1 - 8.0).abs() < 1e-4, "{hits:?}"); // the box's near face
}
/// **Decision 1071, the broad phase.** The inflated cast must grow the candidate's bound by the
/// same 1 model-unit as the halo, or the slab test rejects the entity before its halo is ever
/// narrow-tested — a mesh whose bound the ray misses by half a unit must still halo-hit. And a
/// [`PickBox`] inflates its declared cuboid the same way (its box IS its geometry).
#[test]
fn inflated_cast_grows_the_broad_bound_with_the_halo() {
let mut world = World::new();
// The mesh part: exact triangle spans x∈[-1,1] (bound half 1); +X normals → halo to x=2.
let herb = drawn(&mut world, Vec3::new(0.0, -10.0, 0.0), Vec3::splat(1.0))
.insert(PickMesh(std::sync::Arc::new(wow_tri_with_x_normals())))
.id();
// x=1.5: outside the exact bound (the uninflated cast drops the candidate entirely)…
assert!(cast_in(&mut world, Vec3::new(1.5, 0.0, 0.0), Vec3::NEG_Y, false).is_empty());
// …inside the halo (and the inflated bound that must admit it).
let hits = cast_in(&mut world, Vec3::new(1.5, 0.0, 0.0), Vec3::NEG_Y, true);
assert_eq!(hits.first().map(|(e, _)| *e), Some(herb));
// The cube fallback: box spans x∈[-2,2] at y∈[-12,-8]; a ray at x=2.5 misses the exact
// cuboid, hits the 1-unit-inflated one — at its grown near face (y=-7 → entry 7).
let mut world = World::new();
let cube = drawn(&mut world, Vec3::new(0.0, -10.0, 0.0), Vec3::splat(2.0))
.insert(PickBox)
.id();
assert!(cast_in(&mut world, Vec3::new(2.5, 0.0, 0.0), Vec3::NEG_Y, false).is_empty());
let hits = cast_in(&mut world, Vec3::new(2.5, 0.0, 0.0), Vec3::NEG_Y, true);
assert_eq!(hits.first().map(|(e, _)| *e), Some(cube));
assert!((hits[0].1 - 7.0).abs() < 1e-4, "{hits:?}");
}
/// A billboard card's render form is centred at its pivot (`build_submesh_mesh`); the pick must
/// subtract the same pivot or it tests the card a whole pivot-offset away from where it draws.
#[test]
fn billboard_card_picks_pivot_centred() {
let mut sub = wow_tri();
// Pivot at WoW (0, 0, 3) → Bevy (0, 3, 0): the baked card shifts down 3 in model-local y.
sub.billboard = Some(benilla_formats::Billboard {
pivot: [0.0, 0.0, 3.0],
bone: 0,
kind: benilla_formats::BillboardKind::Spherical,
scale_anim: None,
seq_translations: Vec::new(),
});
let mesh = PickMesh(std::sync::Arc::new(sub));
// The card entity sits AT the pivot's world spot (the spawn sites bake
// `transform_point(pivot)` into its translation), so the authored triangle — 3 below the
// pivot in model-local Bevy y — draws at world y = 0: a straight-down ray from y = 8 hits
// at distance 8. Without the pivot subtraction it would (wrongly) hit at y = 3.
let gt = GlobalTransform::from(Transform::from_xyz(0.0, 3.0, 0.0));
let hit = ray_pick_mesh(&mesh, &gt, Vec3::new(0.0, 8.0, 0.0), Vec3::NEG_Y, false)
.expect("the pivot-centred card must be hit where it draws");
assert!((hit.distance - 8.0).abs() < 1e-4);
assert!(hit.point.abs_diff_eq(Vec3::ZERO, 1e-4));
}
}
+5 -1
View File
@@ -78,9 +78,13 @@ pub(super) fn resurrect_request(
});
}
/// `SMSG_SPIRIT_HEALER_CONFIRM` — the healer awaiting the XP-loss two-step's Accept.
/// `SMSG_SPIRIT_HEALER_CONFIRM` — the healer awaiting the XP-loss two-step's Accept. The message
/// IS the announce (decision 1068): the healer's gossip re-sends it on every ask, and the
/// reference fires `CONFIRM_XP_LOSS` per arrival — so the generation bump is what re-shows a
/// cancelled confirm, exactly the `SMSG_CORPSE_RECLAIM_DELAY` re-fire pattern above.
pub(super) fn spirit_healer_confirm(npc: u64, death_net: &mut DeathNet) {
death_net.spirit_healer = Some(npc);
death_net.confirm_generation = death_net.confirm_generation.wrapping_add(1);
}
/// `SMSG_DURABILITY_DAMAGE_DEATH` — the red line, verbatim GlobalStrings `DURABILITYDAMAGE_DEATH`
+30 -10
View File
@@ -99,8 +99,8 @@ pub(crate) struct DressUpBake {
}
/// Stand the dressing-room booth up beside the two body panes (called from [`super::setup_booths`]).
/// Same off-screen pipeline as those — [`PAPERDOLL_SIZE`]² target, near-black backdrop, decode but
/// no glow (decision 0638) — on its own layer, framed per-bake.
/// Same off-screen pipeline as those — [`PAPERDOLL_SIZE`]² target, decode but no glow (decision
/// 0638) — on its own layer, framed per-bake, and **transparent** (see the clear below).
pub(super) fn spawn_dressup_booth(
commands: &mut Commands,
images: &mut Assets<Image>,
@@ -120,7 +120,19 @@ pub(super) fn spawn_dressup_booth(
super::booth_view_shape(),
Camera {
order: -100 + DRESSUP_LAYER as isize,
clear_color: ClearColorConfig::Custom(Color::srgb(0.055, 0.045, 0.04)),
// **Transparent**, unlike the paper doll's opaque near-black slab (decision 1069). The
// reference's `<DressUpModel>` is a widget that draws only its model: the dark room
// behind the character is the window's own `DressUpBackground-<Race>` art, and an
// opaque bake hid it completely — the pane's rect covers all but a 32 px strip of it.
//
// 1070 lowered this quad to BACKGROUND, which is what un-covered the Reset/Close
// buttons; it cannot un-cover the race art, because that art is on the WINDOW (level 0)
// and the level term outranks the layer (0884). Only compositing can. The glue booth
// has done exactly this since 0818 — FfxGlow's combine carries the scene alpha through.
//
// The other three panes keep their opaque near-black: nothing is behind them to reveal,
// and their look is the director's approved one.
clear_color: ClearColorConfig::Custom(Color::NONE),
..default()
},
bevy::camera::RenderTarget::Image(image.clone().into()),
@@ -146,6 +158,7 @@ pub(super) fn spawn_dressup_booth(
wake: 0,
live: false,
pending: Vec::new(),
aspect: 1.0,
},
);
}
@@ -163,6 +176,7 @@ pub(super) fn sync_dressup_booth(
preview: Res<DressUpPreview>,
bake: Res<DressUpBake>,
mut booths: ResMut<Booths>,
panes: Res<super::BoothPanes>,
mut booth_light: ResMut<BoothLight>,
mut materials: ResMut<Assets<WowModelMaterial>>,
creatures: Option<Res<Creatures>>,
@@ -182,11 +196,15 @@ pub(super) fn sync_dressup_booth(
let Some(booth) = booths.0.get_mut(DRESSUP_SLOT) else {
return;
};
// The destination pane's aspect, latched while it is on screen (decision 1069) — the dressing
// room's is 316×351, so baking square made every character 11% too tall.
let aspect = panes.0.get(DRESSUP_SLOT).copied().unwrap_or(booth.aspect);
let (last_rev, last_yaw) = last.unwrap_or((u64::MAX, f32::NAN));
let rebake = last_rev != bake.revision;
let rebake = last_rev != bake.revision || booth.aspect != aspect;
if !rebake && last_yaw == preview.yaw {
return;
}
booth.aspect = aspect;
if rebake {
// Nothing to show (the window is closed, or the assembly cleared) → empty the stage and
@@ -269,9 +287,9 @@ pub(super) fn sync_dressup_booth(
.as_ref()
.map(|ibp| (rig.skeleton, ibp, rig.animations)),
anim_data.as_deref().map(|a| &a.0),
// Frozen at Stand, like the paper doll beside it — matching the reference widget's live
// animation is the same look call there and here (decision 0822).
BoothMotion::Frozen,
// Stand LOOPING, like the paper doll beside it — the reference's `<DressUpModel>` is a
// live-rendering widget (0822 §4) and the director called the pose (decision 1069).
BoothMotion::Loop,
bake.grip,
&booth_billboards,
);
@@ -290,15 +308,17 @@ pub(super) fn sync_dressup_booth(
})
.collect::<Vec<_>>(),
);
booth.live = fx_emitters > 0;
// The bake animates, so its camera can't sleep — `gate_booth_cameras` runs it every frame
// the window is drawing this pane, and none once it closes.
booth.live = true;
*staged = true;
aim(&mut cams, DRESSUP_SLOT, &body_frame(&anchors));
aim(&mut cams, DRESSUP_SLOT, &body_frame(&anchors, aspect));
// `WOW_BOOTH_LOG=1` — one line per committed bake, the same instrument the mirrored booths
// carry (`super::log_bake`, whose signature is the mirrored part types'). It is what
// separates "the pane is black because nothing baked" from "…because the camera is wrong".
if super::booth_log() {
eprintln!(
"[booth] dressup bake parts={} riders={} billboards={} fx={} rev={}",
"[booth] dressup bake parts={} riders={} billboards={} fx={} rev={} aspect={aspect:.3}",
booth_parts.len(),
booth_riders.len(),
booth_billboards.len(),
+122 -52
View File
@@ -16,9 +16,9 @@ pub(super) const PORTRAIT_FOV: f32 = 0.5;
/// `1/√(aspect²+1)` at the diagonal-FOV convention's aspect 4/3 = 3/5 exactly — the record-fov →
/// vertical-half-angle factor (the community's "fov × 0.6" legend, emergent not prescaled). The
/// 4/3 enters ONLY this crop factor now: the projection matrix itself runs aspect 1.0 (square-true
/// proportions — the director's ref A/B falsified the on-screen anamorphic squeeze; wow-re §4
/// reconciliation dispatched).
/// 4/3 enters ONLY this crop factor: the projection matrix itself carries no anamorphic squeeze
/// (the director's ref A/B falsified it on screen; wow-re §4 reconciliation dispatched) — its
/// `aspect` is the DESTINATION PANE's, and exists purely to cancel the UI's stretch (1069).
pub(super) const DIAG_TO_VERT: f32 = 0.6;
/// The real client's portrait **projection** (wow-re portrait-render §4, corrected `aa186e79`):
@@ -30,7 +30,7 @@ pub(super) const DIAG_TO_VERT: f32 = 0.6;
///
/// Carried as a custom [`CameraProjection`] rather than a `PerspectiveProjection` because the
/// camera system re-derives `aspect_ratio` from the (square) render target on every projection
/// write — it would stomp the 4/3. `update` is a deliberate no-op: the ref mapping is
/// write — it would stomp ours. `update` is a deliberate no-op: the ref mapping is
/// target-size-independent too (its 64×64 surface is forced by a hardcoded scissor).
#[derive(Debug, Clone)]
pub(super) struct WowPortraitProjection {
@@ -38,6 +38,18 @@ pub(super) struct WowPortraitProjection {
pub(super) fov: f32,
pub(super) near: f32,
pub(super) far: f32,
/// The **destination pane's** width ÷ height.
///
/// A booth renders into a *square* target that the UI then stretches to fill whatever rect the
/// sampling region resolves to (`extract`'s `UvRect::FULL`). So the on-screen proportions are
/// only true when the projection runs at the **pane's** aspect: the projection squeezes by
/// `1/aspect` horizontally, the stretch undoes it exactly, and a sphere lands round. Rendering
/// at 1.0 into a 316×351 pane is what made every dressing-room character 11% too tall
/// (director report, 2026-08-06 — decision 1069).
///
/// 1.0 for a round portrait: its region is square by construction (the circular mask is
/// inscribed in it), so there is nothing to cancel.
pub(super) aspect: f32,
}
impl WowPortraitProjection {
@@ -49,14 +61,16 @@ impl WowPortraitProjection {
impl CameraProjection for WowPortraitProjection {
fn get_clip_from_view(&self) -> Mat4 {
// Aspect 1.0 — square-true proportions. The 4/3 anamorphic squeeze wow-re's §4 verdict
// prescribed rendered visibly TALLER faces than the reference's on-screen portrait (the
// director's A/B, 2026-07-06) — the director's capture is ground truth, so the squeeze is
// out pending wow-re's reconciliation (their first reading WAS aspect ≈ 1; the display
// path may also compensate). The 0.6 diag→vert factor stays: the crop tightness matched
// refs and is aspect-derivation-independent on screen. Bevy-native reverse-z infinite
// depth (the record far — 27.8 — never clips a model-local portrait).
Mat4::perspective_infinite_reverse_rh(self.fovy(), 1.0, self.near)
// No anamorphic squeeze — square-true proportions ON SCREEN, which is what `aspect` (the
// destination pane's, not the render target's) buys: it cancels the UI's stretch rather
// than adding a distortion of its own. The 4/3 squeeze wow-re's §4 verdict prescribed
// rendered visibly TALLER faces than the reference's on-screen portrait (the director's
// A/B, 2026-07-06) — the director's capture is ground truth, so the squeeze is out pending
// wow-re's reconciliation (their first reading WAS aspect ≈ 1; the display path may also
// compensate). The 0.6 diag→vert factor stays: the crop tightness matched refs and is
// aspect-derivation-independent on screen. Bevy-native reverse-z infinite depth (the
// record far — 27.8 — never clips a model-local portrait).
Mat4::perspective_infinite_reverse_rh(self.fovy(), self.aspect, self.near)
}
fn get_clip_from_view_for_sub(&self, _sub_view: &SubCameraView) -> Mat4 {
@@ -70,19 +84,22 @@ impl CameraProjection for WowPortraitProjection {
}
fn get_frustum_corners(&self, z_near: f32, z_far: f32) -> [Vec3A; 8] {
// Mirrors PerspectiveProjection's corner layout at our fovy + fixed 4/3 aspect.
// Mirrors PerspectiveProjection's corner layout at our fovy, widened by `aspect` in x
// exactly as the matrix is — a corner set that disagreed with the matrix would cull models
// that do render (and vice versa).
let tan_half_fovy = (self.fovy() * 0.5).tan();
let a = z_near.abs() * tan_half_fovy;
let b = z_far.abs() * tan_half_fovy;
let (ax, bx) = (a * self.aspect, b * self.aspect);
[
Vec3A::new(a, -a, z_near), // bottom right
Vec3A::new(a, a, z_near), // top right
Vec3A::new(-a, a, z_near), // top left
Vec3A::new(-a, -a, z_near), // bottom left
Vec3A::new(b, -b, z_far), // bottom right
Vec3A::new(b, b, z_far), // top right
Vec3A::new(-b, b, z_far), // top left
Vec3A::new(-b, -b, z_far), // bottom left
Vec3A::new(ax, -a, z_near), // bottom right
Vec3A::new(ax, a, z_near), // top right
Vec3A::new(-ax, a, z_near), // top left
Vec3A::new(-ax, -a, z_near), // bottom left
Vec3A::new(bx, -b, z_far), // bottom right
Vec3A::new(bx, b, z_far), // top right
Vec3A::new(-bx, b, z_far), // top left
Vec3A::new(-bx, -b, z_far), // bottom left
]
}
}
@@ -180,6 +197,9 @@ pub(super) fn frame(a: &PortraitAnchors) -> (Transform, Projection) {
fov: cam.fov,
near: cam.near,
far: cam.far,
// A round portrait's sampling region is square by construction (the mask is the
// inscribed circle) — nothing to cancel.
aspect: 1.0,
}),
);
}
@@ -244,7 +264,11 @@ const BODY_WIDTH_MARGIN: f32 = 1.15;
/// through [`WowPortraitProjection`]'s vertical opening (half-angle `0.3·BODY_FOV`, computed the
/// same way here so the fit is exact), looking at the span's centre. Projection reused from the
/// portrait path for module consistency — square-true, no anamorphic squeeze (decision 0116).
pub(super) fn body_frame(a: &PortraitAnchors) -> (Transform, Projection) {
///
/// `aspect` is the **destination pane's** width ÷ height ([`WowPortraitProjection::aspect`]): it
/// widens the horizontal opening, so the figure keeps its proportions once the UI stretches the
/// square bake into a non-square pane (decision 1069).
pub(super) fn body_frame(a: &PortraitAnchors, aspect: f32) -> (Transform, Projection) {
// Head/neck height signal (feet at Y=0). The neck pivot (attach-17.z, every character carries
// it), the authored bust camera's look target, and the head-bone anchor all land near the
// throat/face — take the tallest, floored off zero for a hypothetical bounds-less display.
@@ -255,10 +279,10 @@ pub(super) fn body_frame(a: &PortraitAnchors) -> (Transform, Projection) {
.max(0.1);
let top = BODY_HEADROOM * head_signal;
let bottom = -BODY_FOOTROOM * head_signal;
// The square target renders aspect 1.0 (WowPortraitProjection), so the horizontal opening equals
// the vertical — floor the fitted height by the footprint width so a wide model can't out-reach
// the sides.
let window = (top - bottom).max(2.0 * BODY_WIDTH_MARGIN * a.ground_radius);
// The horizontal opening is `aspect ×` the vertical, so a width that must fit costs
// `width / aspect` of vertical window — floor the fitted height by it, so a wide model can't
// out-reach the sides (and a *narrow* pane, aspect < 1, gets the extra room it needs).
let window = (top - bottom).max(2.0 * BODY_WIDTH_MARGIN * a.ground_radius / aspect.max(1e-3));
let center_y = 0.5 * (top + bottom);
// Distance that exactly fits `window` through the projection's vertical opening. Half-angle is
@@ -274,6 +298,7 @@ pub(super) fn body_frame(a: &PortraitAnchors) -> (Transform, Projection) {
fov: BODY_FOV,
near: 0.02,
far: 100.0,
aspect,
}),
)
}
@@ -282,11 +307,15 @@ pub(super) fn body_frame(a: &PortraitAnchors) -> (Transform, Projection) {
mod tests {
use super::*;
/// Perspective-divided clip-space Y of a model-local point through a `body_frame` rig — the value
/// the rasteriser clips against (inside the frame ⇔ `|ndc_y| < 1`).
fn ndc_y(transform: &Transform, proj: &WowPortraitProjection, p: Vec3) -> f32 {
/// Perspective-divided clip-space XY of a model-local point through a `body_frame` rig — the
/// values the rasteriser clips against (inside the frame ⇔ both `|ndc| < 1`).
fn ndc(transform: &Transform, proj: &WowPortraitProjection, p: Vec3) -> Vec2 {
let clip = proj.get_clip_from_view() * transform.to_matrix().inverse() * p.extend(1.0);
clip.y / clip.w
Vec2::new(clip.x / clip.w, clip.y / clip.w)
}
fn ndc_y(transform: &Transform, proj: &WowPortraitProjection, p: Vec3) -> f32 {
ndc(transform, proj, p).y
}
/// The bounds→camera fit must keep both **feet and crown** inside the frame with air to spare,
@@ -294,40 +323,81 @@ mod tests {
/// and the distance math against a regression (a flipped axis or a wrong half-angle would crop).
#[test]
fn body_frame_never_crops_feet_or_crown() {
for &signal in &[0.88_f32, 1.90, 2.60] {
// Every pane aspect the shipped windows use: the paper doll's 233×224, the dressing room's
// 316×351, and the square default a booth runs at before its pane has been seen.
for &aspect in &[1.0_f32, 233.0 / 224.0, 316.0 / 351.0] {
for &signal in &[0.88_f32, 1.90, 2.60] {
let anchors = PortraitAnchors {
camera: None,
head: None,
pivot_height: signal,
ground_radius: 0.35,
};
let (transform, projection) = body_frame(&anchors, aspect);
let proj = WowPortraitProjection {
fov: BODY_FOV,
near: 0.02,
far: 100.0,
aspect,
};
// Feet at the origin; a conservatively-high crown estimate (throat/face signal is
// ~0.9 of standing height, so the crown sits a little above `signal`).
let feet = ndc_y(&transform, &proj, Vec3::ZERO);
let crown = ndc_y(&transform, &proj, Vec3::new(0.0, 1.12 * signal, 0.0));
assert!(
feet < 0.0,
"feet should sit below frame centre, got ndc_y {feet}"
);
assert!(
crown > 0.0,
"crown should sit above frame centre, got ndc_y {crown}"
);
assert!(
feet.abs() < 0.95,
"feet cropped / no ground room (ndc_y {feet}) at signal {signal}, aspect {aspect}"
);
assert!(
crown < 0.95,
"crown cropped / no headroom (ndc_y {crown}) at signal {signal}, aspect {aspect}"
);
let _ = projection; // the rig carries the same projection the test rebuilds
}
}
}
/// **The 1069 defect, pinned.** A booth bakes into a *square* target that the UI stretches into
/// the pane's rect, so a figure only keeps its proportions when the projection runs at the
/// pane's aspect. Measured the way the eye does: screen pixels per model yard, sideways vs
/// upward, at the framed figure's own centre. Before 1069 the dressing room's 316×351 pane read
/// 1.111 — an 11% vertical stretch, which is exactly what the director saw.
#[test]
fn a_pane_shows_the_figure_unstretched_at_any_aspect() {
for &(w, h) in &[(316.0_f32, 351.0_f32), (233.0, 224.0), (512.0, 512.0)] {
let aspect = w / h;
let anchors = PortraitAnchors {
camera: None,
head: None,
pivot_height: signal,
pivot_height: 1.90,
ground_radius: 0.35,
};
let (transform, projection) = body_frame(&anchors);
let (transform, _) = body_frame(&anchors, aspect);
let proj = WowPortraitProjection {
fov: BODY_FOV,
near: 0.02,
far: 100.0,
aspect,
};
// Feet at the origin; a conservatively-high crown estimate (throat/face signal is ~0.9 of
// standing height, so the crown sits a little above `signal`).
let feet = ndc_y(&transform, &proj, Vec3::ZERO);
let crown = ndc_y(&transform, &proj, Vec3::new(0.0, 1.12 * signal, 0.0));
// A small cross at the framing centre — the on-screen size of each arm, in pane pixels.
let mid = Vec3::new(0.0, transform.translation.y, 0.0);
let step = 0.05_f32;
let o = ndc(&transform, &proj, mid);
let px_x = (ndc(&transform, &proj, mid + Vec3::X * step).x - o.x).abs() * 0.5 * w;
let px_y = (ndc(&transform, &proj, mid + Vec3::Y * step).y - o.y).abs() * 0.5 * h;
let stretch = px_y / px_x;
assert!(
feet < 0.0,
"feet should sit below frame centre, got ndc_y {feet}"
(stretch - 1.0).abs() < 0.005,
"a {w}×{h} pane stretches the figure by {stretch} (1.0 = round)"
);
assert!(
crown > 0.0,
"crown should sit above frame centre, got ndc_y {crown}"
);
assert!(
feet.abs() < 0.95,
"feet cropped / no ground room (ndc_y {feet}) at signal {signal}"
);
assert!(
crown < 0.95,
"crown cropped / no headroom (ndc_y {crown}) at signal {signal}"
);
let _ = projection; // the rig carries the same projection the test rebuilds
}
}
}
@@ -425,6 +425,7 @@ pub(super) fn spawn_glue_booth(
// flag: the glue booth is live from the moment a screen shows, before any bake.
live: false,
pending: Vec::new(),
aspect: 1.0,
},
);
// The background scene's own root (the character root above yaws; the scene never does).
@@ -950,7 +951,10 @@ pub(super) fn sync_glue_booth(
// body-frame transform with an aspect-aware projection, because the booth target is
// window-sized now (the square-true `WowPortraitProjection` would stretch on it). While a
// scene shows, `sync_glue_scene` re-aims onto the authored camera the same frame.
let (t, _) = body_frame(&anchors);
// Aspect 1.0 into `body_frame`: only its TRANSFORM is kept here — the projection
// beside it is the window-sized one this booth needs (decision 1069's aspect rides on
// the discarded half).
let (t, _) = body_frame(&anchors, 1.0);
aim(
&mut cams,
GLUE_SLOT,
+93 -22
View File
@@ -410,15 +410,20 @@ struct Booth {
/// empty, framing/yaw write); 0 with `pending` drained = the camera sleeps and the target
/// keeps the last render — a still costs nothing per frame.
wake: u32,
/// The bake standing in this booth contains **live** content — particle emitters
/// ([`booth::spawn_booth_effects`]) — so its camera never sleeps: a still of a cloud mid-birth
/// is not what the pane shows. The generalization of the glue booth's own always-on rule
/// (`live_scene` below, decision 0540); only the body panes can set it (decision 0822).
/// The bake standing in this booth is a **live widget**, not a still: its Stand loops and its
/// item emitters run ([`booth::spawn_booth_effects`]), so it must re-render every frame — but
/// only while something is drawing it, which [`BoothPanes`] answers (decision 1069). The
/// generalization of the glue booth's own always-on rule (`live_scene` below, decision 0540);
/// only the body panes set it (decision 0822 §4 — the round portraits are a one-shot bake).
live: bool,
/// Textures the last bake referenced that were not yet resident: the camera stays awake
/// until each lands (an `mpq://` image arriving after the bake would otherwise be frozen
/// OUT of the still forever), then renders one final resident frame.
pending: Vec<Handle<Image>>,
/// The **destination pane's** aspect this booth's camera is currently framed for
/// ([`framing::WowPortraitProjection::aspect`], decision 1069) — 1.0 until the UI has drawn the
/// pane once, then sticky: a hidden window must not re-frame the bake back to square.
aspect: f32,
}
/// How many frames a content edge keeps a booth camera rendering ([`Booth::wake`]): covers the
@@ -525,6 +530,40 @@ fn wake_booth<'a>(
#[derive(Resource, Default)]
struct Booths(HashMap<String, Booth>);
/// Where each booth's bake is actually being **sampled on screen this frame**: slot token → the
/// destination region's aspect (width ÷ height). Published by the UI extract for every
/// `BenillaSetBoothTexture` binding it emits — the *square* portrait binding (decision 0208 §5); a
/// round `SetPortraitTexture` unit portrait is not a pane and never appears here.
///
/// Two things need it, and neither can be a constant (decision 1069):
///
/// - **Shape.** A booth renders into a square target that the UI stretches to fill the pane's rect
/// (`extract`'s `UvRect::FULL`), so the projection has to run at the *pane's* aspect for the
/// stretch to cancel. Rendering at 1.0 into the dressing room's 316×351 pane made every
/// character 11% too tall (director report, 2026-08-06).
/// - **Liveness.** A body pane's bake *animates* ([`BoothMotion::Loop`] — the reference's
/// `<PlayerModel>` widgets render live, decision 0822 §4), so its camera renders every frame it
/// is on screen — and, now that this resource can say so, **none** when it is not. That last
/// half is also a strict win for the pre-1069 emitter case, which used to render forever behind
/// a closed window.
///
/// One frame stale by construction (the extract runs after the booth syncs), which is why the
/// aspect is latched into [`Booth::aspect`] rather than read fresh: a window that closes must not
/// re-frame the bake it left standing.
#[derive(Resource, Default)]
pub(crate) struct BoothPanes(pub(crate) HashMap<String, f32>);
/// Both directions of the booth↔UI bridge in one system param: the bake a bound region **samples**
/// ([`PortraitImages`]) and the pane geometry the extract **publishes** back ([`BoothPanes`]).
///
/// They travel together because they are the same seam, and because `drive_script` had already
/// reached Bevy's 16-parameter ceiling — two more `Res`es there is one too many.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct BoothBridge<'w> {
pub(crate) images: Res<'w, PortraitImages>,
pub(crate) panes: ResMut<'w, BoothPanes>,
}
/// Tags a booth camera with its slot token, so the model-sync pass can re-frame it per model.
/// (`BoothCam`, not `PortraitCamera` — that name is the authored M2 rig, `benilla_assets::PortraitCamera`.)
/// (`pub(crate)` for the particle census, which reports draws per booth token — decision 0775.)
@@ -545,6 +584,7 @@ impl Plugin for PortraitPlugin {
.init_resource::<dressup::DressUpPreview>()
.init_resource::<dressup::DressUpBake>()
.init_resource::<Booths>()
.init_resource::<BoothPanes>()
.init_resource::<BoothLight>()
.init_resource::<crate::ui_session::InteractNpc>()
.add_systems(Startup, setup_booths)
@@ -671,6 +711,8 @@ pub(crate) fn spawn_warm_booth(
fov: framing::BODY_FOV,
near: 0.02,
far: 100.0,
// The warm pass compiles PIPELINES, which the aspect does not key.
aspect: 1.0,
}),
layer.clone(),
))
@@ -775,6 +817,7 @@ fn setup_booths(
wake: 0,
live: false,
pending: Vec::new(),
aspect: 1.0,
},
);
}
@@ -838,6 +881,7 @@ fn setup_booths(
wake: 0,
live: false,
pending: Vec::new(),
aspect: 1.0,
},
);
}
@@ -1150,9 +1194,8 @@ fn sync_portraits(
///
/// **What re-bakes.** A parts-key change respawns the posed instance and re-aims the (yaw-
/// independent) camera; a bare yaw change only re-rotates the root — neither happens on an unchanged
/// frame. Like the portrait slots, the booth renders *unconditionally* once the player model exists
/// (no draw-gating on whether the pane is sampled) — one continuous 512² pass; gating it on window
/// visibility is a noted follow-up, not done here to match the existing slots' simplicity.
/// frame. The bake stands ready whether or not the window is open, but the 512² *pass* only runs
/// while the pane is being drawn ([`BoothPanes`], decision 1069).
#[allow(clippy::too_many_arguments)]
fn sync_paperdoll(
mut commands: Commands,
@@ -1169,6 +1212,7 @@ fn sync_paperdoll(
mut env_cache: Local<Option<bool>>,
mut last_yaw: Local<Option<f32>>,
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
panes: Res<BoothPanes>,
anim_data: Option<Res<crate::creature_anim::AnimData>>,
) {
if test_mode(&mut env_cache) {
@@ -1190,6 +1234,7 @@ fn sync_paperdoll(
&mut wow_mats,
&mut cams,
anim_data.as_deref(),
&panes,
);
}
@@ -1210,6 +1255,7 @@ fn sync_inspect_booth(
mut env_cache: Local<Option<bool>>,
mut last_yaw: Local<Option<f32>>,
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
panes: Res<BoothPanes>,
anim_data: Option<Res<crate::creature_anim::AnimData>>,
) {
if test_mode(&mut env_cache) {
@@ -1231,6 +1277,7 @@ fn sync_inspect_booth(
&mut wow_mats,
&mut cams,
anim_data.as_deref(),
&panes,
);
}
@@ -1251,6 +1298,7 @@ fn sync_petdoll_booth(
mut env_cache: Local<Option<bool>>,
mut last_yaw: Local<Option<f32>>,
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
panes: Res<BoothPanes>,
anim_data: Option<Res<crate::creature_anim::AnimData>>,
) {
if test_mode(&mut env_cache) {
@@ -1272,6 +1320,7 @@ fn sync_petdoll_booth(
&mut wow_mats,
&mut cams,
anim_data.as_deref(),
&panes,
);
}
@@ -1295,10 +1344,15 @@ fn sync_body_booth(
wow_mats: &mut Assets<WowModelMaterial>,
cams: &mut Query<(&BoothCam, &mut Transform, &mut Projection)>,
anim_data: Option<&crate::creature_anim::AnimData>,
panes: &BoothPanes,
) {
let Some(booth) = booths.0.get_mut(slot) else {
return;
};
// Latch the pane's aspect while it is on screen (decision 1069). Sticky: a closed window
// publishes nothing, and re-framing the standing bake back to square on the way out would be a
// visible pop on the way back in.
let aspect = panes.0.get(slot).copied().unwrap_or(booth.aspect);
// There is no 2D stand-in for a body pane — the bridge always points at the live target (an
// empty booth just renders the dark backdrop until the unit's model attaches).
let live = PortraitSource::Live(booth.target.clone());
@@ -1328,8 +1382,11 @@ fn sync_body_booth(
}
let unit = unit.expect("unit present — parts came from its descendants");
let key = LookKey::build(&parts, &riders, &billboards, &effects);
let parts_changed = booth.baked.as_ref() != Some(&key);
// A changed pane aspect re-runs the same path: the fit and the projection both depend on it,
// and it only ever moves once — the first frame the window is drawn.
let parts_changed = booth.baked.as_ref() != Some(&key) || booth.aspect != aspect;
if parts_changed {
booth.aspect = aspect;
let display_id = ent_q.get(unit).ok().and_then(|n| n.display_id);
// Anchors first, before any teardown — a still-loading display must not be framed from
// fabricated zero bounds (see the portrait site, and `booth_anchors`).
@@ -1405,27 +1462,31 @@ fn sync_body_booth(
.map(|ibp| (r.skeleton, ibp, r.animations))
}),
anim_data.map(|a| &a.0),
BoothMotion::Frozen,
[false, false], // a still portrait sheaths its weapons — no in-hand grip
// The pane ANIMATES: Stand loops and the global-sequence bones run, which is what the
// reference's `<PlayerModel>` widget does (decision 0822 §4 read it as live-rendering
// and left the pose as a look call; the director made that call — decision 1069).
BoothMotion::Loop,
[false, false], // the pane sheaths its weapons — no in-hand grip
&booth_billboards,
);
// The item effects go up on the posed skeleton's joints — the body pane is the lane that gets
// them (the reference's `<PlayerModel>` widget renders live; the round portraits are a
// one-shot cached bake — [`spawn_booth_effects`]). A booth holding emitters is LIVE: its
// camera must not sleep on a cloud mid-birth, so `wake` alone can't gate it. The body itself
// stays `Frozen` — matching the ref's animating widget is its own look call (decision 0822).
let (fx_emitters, _) = spawn_booth_effects(
// one-shot cached bake — [`spawn_booth_effects`]).
spawn_booth_effects(
commands,
&joints,
&booth.layer,
booth_light.pane.buffer.as_ref(),
&booth_effects,
);
booth.live = fx_emitters > 0;
// Body framing from the display's bounds — the full standing figure, feet-to-crown.
// Resolved before the teardown above; see the portrait site for why it cannot be faked.
log_frame(slot, &anchors, &body_frame(&anchors).0);
aim(cams, slot, &body_frame(&anchors));
// So the whole bake is live, emitters or not — `wake` can't gate a looping animation.
// `gate_booth_cameras` renders it every frame its pane is on screen, and none when it isn't.
booth.live = true;
// Body framing from the display's bounds — the full standing figure, feet-to-crown, at the
// destination pane's aspect. Resolved before the teardown above; see the portrait site for
// why it cannot be faked.
log_frame(slot, &anchors, &body_frame(&anchors, aspect).0);
aim(cams, slot, &body_frame(&anchors, aspect));
log_bake(slot, "bake", &parts, &riders, &billboards, &effects);
wake_booth(
booth,
@@ -1455,17 +1516,23 @@ fn sync_body_booth(
/// something new to show — [`Booth::wake`] frames after a content edge, or a bake texture still
/// in flight ([`Booth::pending`]) — except the booths whose content is **live**, which render
/// continuously: the glue booth, whose whole scene is animated (looping sequences, global-sequence
/// bones, particle emitters) while a glue screen shows, and any booth whose bake spawned particle
/// emitters ([`Booth::live`] — a body pane wearing an effect item, decision 0822). A sleeping camera
/// bones, particle emitters) while a glue screen shows, and a **body pane**, whose bake is a live
/// `<PlayerModel>` widget ([`Booth::live`], decisions 0822 §4 + 1069). A sleeping camera
/// skips its whole pass (clear + model + FFXGlow chain); its target keeps the last render — exactly
/// right for a still (the 0105 bake, frozen at Stand).
///
/// A live pane renders **only while it is on screen** ([`BoothPanes`], decision 1069): a character
/// window nobody opened costs nothing, which is the follow-up [`sync_paperdoll`] named and 0822's
/// unconditional `live` never had.
/// With `WOW_PORTRAIT_TEST` set the gate stands down (the eyeball harness wants live cameras).
/// The pipeline warm pass is demand too (decision 0938): its menagerie duplicates rigs onto a
/// booth layer so the booths' `Msaa::Off` pipeline twins compile behind the entry cover — which
/// only works if the booth cameras render during the warm window.
#[allow(clippy::too_many_arguments)] // a Bevy system: each param is one resource/query
fn gate_booth_cameras(
mut booths: ResMut<Booths>,
preview: Res<GluePreview>,
panes: Res<BoothPanes>,
images: Res<Assets<Image>>,
warm: Res<crate::pipe_warm::WarmPass>,
time: Res<Time<bevy::time::Real>>,
@@ -1487,10 +1554,14 @@ fn gate_booth_cameras(
booth.wake = booth.wake.max(1);
}
let live_scene = token.as_str() == GLUE_SLOT && preview.scene.is_some();
// A live bake renders every frame — but only while the UI is actually drawing its pane.
// (The glue screens sample their booth outside the FrameXML extract, so they publish no
// pane and stay on `live_scene`.)
let live_pane = booth.live && panes.0.contains_key(token.as_str());
let active = test
|| warming
|| live_scene
|| booth.live
|| live_pane
|| booth.wake > 0
|| !booth.pending.is_empty();
// `WOW_BOOTH_LOG=1`: the gate's timeline — every activity flip and every armed frame,
+2 -1
View File
@@ -231,7 +231,8 @@ fn bake_test(
[false, false], // the paper-doll still sheaths its weapons — no in-hand grip
&[], // eye-glow in the paper doll is the same follow-up (see above)
);
aim(cams, PAPERDOLL_SLOT, &body_frame(&anchors));
// The eyeball harness has no live UI publishing a pane, so it bakes square.
aim(cams, PAPERDOLL_SLOT, &body_frame(&anchors, 1.0));
commands
.entity(booth.root)
.insert(Transform::from_rotation(Quat::from_rotation_y(
+4 -2
View File
@@ -340,8 +340,10 @@ const GO_TYPE_FISHINGHOLE: i32 = 25;
/// CURSOR (and, via it, the click) only — the mouseover tooltip is *not* coupled to it
/// (§5-VERIFIED, wow-re 2026-07-20): the pick registers every GO regardless of highlightable and
/// the publisher dispatches the tooltip by kind, so a GENERIC(5) signpost tooltips (0349's
/// reference close-up) while showing no cursor.
fn go_highlightable(store: &ObjectStore, reaction: Option<u8>) -> bool {
/// reference close-up) while showing no cursor. Also the GO pick's **pass-2 priority** (decision
/// 1071): the reference's classify `0x480c90` ranks a highlightable GameObject `1` (via
/// `0x5f8800`, this same strategy `+0x14` predicate), else `0`.
pub(super) fn go_highlightable(store: &ObjectStore, reaction: Option<u8>) -> bool {
highlightable_flags(
store.0.gameobject_type_id(),
store.0.gameobject_flags(),
+90 -20
View File
@@ -301,12 +301,26 @@ pub(super) fn update_hover(
/// Recompute the **GameObject** under the cursor each frame into [`HoveredObject`] (decision 0236):
/// a mesh-accurate ray pick against GameObject parts *only*, reusing the inspector's picker
/// ([`crate::interact::pick_at_cursor`] — the resident-geometry caster of decision 0857, which hits
/// the colliderless props a physics ray misses and the `RENDER_WORLD`-only static forms Bevy's
/// `MeshRayCast` lost at 0834). Kept separate from [`update_hover`]'s unit pick because a GameObject is
/// usable-but-not-*selected*: this drives the Interact cursor and the right-click USE, never
/// selection. Inert while mouse-looking (cursor hidden) or over the dev UI, exactly like the unit
/// pick. Cheap — only the handful of GO parts on screen are in the pick set.
/// (the resident-geometry caster of decision 0857, which hits the colliderless props a physics ray
/// misses and the `RENDER_WORLD`-only static forms Bevy's `MeshRayCast` lost at 0834). Kept
/// separate from [`update_hover`]'s unit pick because a GameObject is usable-but-not-*selected*:
/// this drives the Interact cursor and the right-click USE, never selection. Inert while
/// mouse-looking (cursor hidden) or over the dev UI, exactly like the unit pick. Cheap — only the
/// handful of GO parts on screen are in the pick set.
///
/// **Two passes, like the unit pick** (decision 1071 — GameObjects are the same type-1 candidates
/// as units in the reference's resolve `0x7089c0`, wow-re object-layer mouse-pick): **pass 1** =
/// the exact resident mesh, pure nearest-wins; **pass 2, only when pass 1 hit nothing anywhere**
/// (the mouse pick's generous retry): the same geometry with every vertex displaced +1 model-unit
/// along its authored normal — a ~1-yd halo, which is what makes a wispy herb clickable *around*
/// its leaves instead of only pixel-on-texture. "Nothing anywhere" spans the unit pick too (one
/// resolve in the reference): a frame where [`Hovered`] holds any unit — exact hit or its own
/// halo — never opens the GO halo, because the pass-2 accept ladder ranks every unit (alive 3 /
/// dead 2) above every GameObject (highlightable 1 / else 0). Within the GO halo the same ladder
/// applies: last frame's pick sticks (anti-flicker), else higher priority wins even when farther,
/// ties by distance. Both passes stay world-occlusion-clamped. (Residual, same slice as the
/// eligibility note below: the reference's sticky slot is cross-type, so a stuck GO pick can
/// outrank a unit's halo there; our split picks give the unit that frame instead.)
#[allow(clippy::too_many_arguments)]
pub(super) fn update_hovered_object(
camera: Query<(&Camera, &GlobalTransform), With<WorldCamera>>,
@@ -314,6 +328,9 @@ pub(super) fn update_hovered_object(
rig: Res<CameraControl>,
pointer_over_ui: Res<PointerOverUi>,
occlusion: Res<PickOcclusion>,
// The unit pick's verdict — [`update_hover`] runs earlier in the target chain. Any unit hover
// means pass 1 + the unit halo already own the mouseover, so the GO pass 2 stays shut.
unit_hovered: Res<Hovered>,
mut hovered: ResMut<HoveredObject>,
// Every pickable GameObject part carries `WorldObject { kind: GameObject }` (units/doodads/WMOs
// carry other kinds and are excluded — units have their own posed-mesh pick).
@@ -327,11 +344,15 @@ pub(super) fn update_hovered_object(
factions: Option<Res<super::ring::Factions>>,
self_q: Query<&ObjectStore, With<crate::net::SelfPlayer>>,
parts: PickParts,
// Last frame's GO pick, for pass 2's sticky-hover (the reference's anti-flicker cache, by net
// entity — the same rung the unit picker keeps).
mut last_pick: Local<Option<Entity>>,
) {
hovered.target = None;
hovered.guid = None;
hovered.distance = f32::MAX;
if rig.is_looking() || pointer_over_ui.0 {
*last_pick = None;
return;
}
let (Ok((camera, cam_tf)), Ok(window)) = (camera.single(), window.single()) else {
@@ -340,7 +361,7 @@ pub(super) fn update_hovered_object(
let Some(cursor) = window.cursor_position() else {
return;
};
// `pick_at_cursor` takes bevy's `HashSet` (not the `std` one this module uses elsewhere).
// The caster takes bevy's `HashSet` (not the `std` one this module uses elsewhere).
// A transport-family GO (TRANSPORT 11 / MAP_OBJECT 14 / MO_TRANSPORT 15) never joins the pick
// set: in the reference it has no pick geometry at all — the GO model resolver `0x5f80e0`
// returns 0 for these three types (they render via the WMO/spline path, not an M2) and their
@@ -359,25 +380,74 @@ pub(super) fn update_hovered_object(
.map(|(e, _)| e)
.collect();
if pickable.is_empty() {
*last_pick = None;
return;
}
let Some((hit, _point, distance)) =
crate::interact::pick_at_cursor(cursor, camera, cam_tf, &pickable, &parts)
else {
let Ok(ray) = camera.viewport_to_world(cam_tf, cursor) else {
return;
};
// The occlusion verdict (`0x480df0` @ `0x480eb4`): discard the GO hit iff the world hit is
// STRICTLY nearer — a tie keeps the object. (Monotonic: any other GO hit is farther still.)
if occlusion.distance < distance {
return;
}
// The hit is a mesh part; its GameObject net entity (which carries the `Guid`) is one `ChildOf`
// hop up — the attach convention (the same one `update_hover`'s fallback walks). Guard the rare
// case the pickable itself is the guid-bearing entity.
let net_entity = if guids.contains(hit) {
hit
} else {
child_of.get(hit).map_or(hit, |c| c.parent())
let resolve_net = |e: Entity| {
if guids.contains(e) {
e
} else {
child_of.get(e).map_or(e, |c| c.parent())
}
};
let self_store = self_q.single().ok();
// Pass 1 — the exact resident geometry, pure nearest-wins (priority-independent).
let mut best: Option<(f32, Entity)> =
crate::interact::cast_pick_ray(ray, &pickable, &parts, false)
.into_iter()
.next()
.map(|(e, h)| (h.distance, e));
// Pass 2 — nothing exactly hit anywhere (no GO, and no unit either — the doc above): the
// generous retry over the normal-inflated geometry, accepted by the priority ladder.
if best.is_none() {
if unit_hovered.target.is_some() {
*last_pick = None;
return;
}
let mut best2: Option<(f32, u32, Entity)> = None;
for (part, hit) in crate::interact::cast_pick_ray_inflated(ray, &pickable, &parts) {
let net = resolve_net(part);
let prio = if *last_pick == Some(net) {
u32::MAX // the sticky-hover cache outranks everything
} else {
// The classify priority (`0x480c90`): highlightable GameObject 1, else 0. A store
// that hasn't streamed reads highlightable — the eligibility gate's own
// permissive default, so a fresh spawn isn't a dead zone for its first frames.
stores.get(net).map_or(1, |s| {
let reaction = crate::target::cursor_mode::go_reaction(
factions.as_deref(),
s.0.gameobject_faction(),
self_store,
);
u32::from(crate::target::cursor_mode::go_highlightable(s, reaction))
})
};
let wins =
best2.is_none_or(|(bt, bp, _)| prio > bp || (prio == bp && hit.distance < bt));
if wins {
best2 = Some((hit.distance, prio, part));
}
}
best = best2.map(|(t, _, e)| (t, e));
}
// The occlusion verdict (`0x480df0` @ `0x480eb4`): discard the GO hit iff the world hit is
// STRICTLY nearer — a tie keeps the object.
if best.is_some_and(|(t, _)| occlusion.distance < t) {
best = None;
}
let picked = best.map(|(t, e)| (t, resolve_net(e)));
*last_pick = picked.map(|(_, net)| net);
let Some((distance, net_entity)) = picked else {
return;
};
let Ok(guid) = guids.get(net_entity) else {
return;
@@ -398,7 +468,7 @@ pub(super) fn update_hovered_object(
let reaction = crate::target::cursor_mode::go_reaction(
factions.as_deref(),
store.0.gameobject_faction(),
self_q.single().ok(),
self_store,
);
if !crate::target::cursor_mode::mouseover_eligible(
store.0.gameobject_type_id(),
+31 -14
View File
@@ -198,19 +198,18 @@ pub(crate) fn spell_usable(
(true, false)
}
/// Whether the caster can afford `d`'s power cost — the shared availability-vs-cost compare
/// behind BOTH the usable walk's leg 12 (`0x6e3fba`) and the press-path power gate
/// (`0x6094f0` @ `0x60962c`, decision 0948): raw `UNIT_FIELD_POWER[type]` — ANY negative
/// PowerType reads `UNIT_FIELD_HEALTH` instead (the ref's `jl` at `0x609631`; Bloodrage's −2) —
/// signed-compared against the computed cost. Percent costs scale from base mana (mana spells)
/// / max power (the vmangos CalculatePowerCost basis).
pub(crate) fn can_afford(d: &SpellDisplay, store: &ObjectStore) -> bool {
/// The RESOLVED power cost — `GetPowerCost 0x6e31b0`'s data terms: the flat `manaCost`, the
/// per-level term `(level − spellLevel) · manaCostPerlevel` (signed delta, the vmangos
/// `CalculatePowerCost` shape, clamped at 0 on the way out; every nonzero row is a creature
/// spell, so the clamp choice is dormant for players), and `ManaCostPercentage` of the pool
/// 0948 chose per type (base mana for mana spells — the vmangos basis — max pool otherwise,
/// health included for negative types). One law, every consumer: the usable walk's leg 12, the
/// press-path power gate (0948), and the tooltip's cost cell (1074) — mirroring the byte fn's
/// own caller set (`0x609657`/`0x60968d`/`0x4e5201`/`0x6e3fdb`/`0x52e8ad`/`0x507de3`). The
/// reference's per-school unit mods and spell-mods (talent cost cuts) are not modeled — 0948's
/// standing gap, now stated once here.
pub(crate) fn power_cost(d: &SpellDisplay, store: &ObjectStore) -> u32 {
let power_type = d.power_type as i32;
let avail = if power_type < 0 {
store.0.unit_health().unwrap_or(0)
} else {
store.0.unit_power(power_type as u8).unwrap_or(0)
};
let base = if d.mana_cost_pct == 0 {
0
} else if d.power_type == 0 {
@@ -220,8 +219,26 @@ pub(crate) fn can_afford(d: &SpellDisplay, store: &ObjectStore) -> bool {
} else {
store.0.unit_max_power(power_type as u8).unwrap_or(0)
};
let cost = d.mana_cost + base * d.mana_cost_pct / 100;
avail >= cost
let level_delta = i64::from(store.0.unit_level().unwrap_or(0)) - i64::from(d.spell_level);
let cost = i64::from(d.mana_cost)
+ level_delta * i64::from(d.mana_cost_per_level)
+ i64::from(base) * i64::from(d.mana_cost_pct) / 100;
u32::try_from(cost).unwrap_or(0)
}
/// Whether the caster can afford `d`'s power cost — the shared availability-vs-cost compare
/// behind BOTH the usable walk's leg 12 (`0x6e3fba`) and the press-path power gate
/// (`0x6094f0` @ `0x60962c`, decision 0948): raw `UNIT_FIELD_POWER[type]` — ANY negative
/// PowerType reads `UNIT_FIELD_HEALTH` instead (the ref's `jl` at `0x609631`; Bloodrage's −2) —
/// signed-compared against [`power_cost`]'s number.
pub(crate) fn can_afford(d: &SpellDisplay, store: &ObjectStore) -> bool {
let power_type = d.power_type as i32;
let avail = if power_type < 0 {
store.0.unit_health().unwrap_or(0)
} else {
store.0.unit_power(power_type as u8).unwrap_or(0)
};
avail >= power_cost(d, store)
}
#[cfg(test)]
+216 -2
View File
@@ -15,8 +15,12 @@
//! in chat by someone else.
//! - **Compose the look.** The player's own body + appearance + their `PLAYER_VISIBLE_ITEM_*`
//! displays, with each resolved substitution written into the slot its `InventoryType` maps to
//! (the shared [`equip_slot`] table — the same one the glue/select preview dresses by, so a
//! robe lands on the chest and a wand in the ranged hand here exactly as it does there).
//! (the shared [`equip_slot`] table — the same one the glue/select preview dresses by, so a robe
//! lands on the chest here exactly as it does there). The **held** triple then goes through
//! [`held_lanes`], which is where the two previews stop agreeing: the character-select mannequin
//! drops the ranged slot and this window puts it in a hand (decision 1076). 1060's own record
//! claimed "a wand in the ranged hand here exactly as it does there" — that was wrong in the
//! second half, and the ranged slot was silently discarded until 1076.
//!
//! **What is NOT here:** any notion of *fit*. The reference's dressing room previews whatever it is
//! handed — a plate helm on a mage, a mail chest on a rogue — because it is a look, not an equip:
@@ -37,6 +41,91 @@ use crate::ui_script::UiInput;
/// (`EQUIPMENT_SLOT_*`), which is exactly the set [`equip_slot`] can map an item into.
const LOOK_SLOTS: [u8; 14] = [0, 2, 3, 4, 5, 6, 7, 8, 9, 14, 15, 16, 17, 18];
/// The three **held** equipment slots — main hand · off hand · ranged. The widget keeps only *two*
/// lanes for them, which is the whole reason [`held_lanes`] exists.
const HELD_SLOTS: [usize; 3] = [15, 16, 17];
/// **The dress-up widget has two held lanes, and a ranged weapon takes one of them** (decision
/// 1076; wow-re `ui/scratch/dressup-model-equipment.md`).
///
/// `DressUpModel::TryOn 0x504d90` installs into lane `0x0f` (→ HandRight) or lane `0x10` (→
/// HandLeft, or the Shield point for a shield) — never a third. `INVTYPE_RANGED` (15, bows) lands
/// in the **off** lane; gun/crossbow/wand (26) and thrown (25) land in the **main** one, matching
/// the world's own drawn-ranged split exactly.
///
/// Before installing, `0x504bc0(mainInvType, offInvType, dualWield)` asks whether the two lanes may
/// coexist; if not, the incoming item **evicts** the other. `0x0f` appears on neither side of that
/// test, so a ranged weapon coexists with nothing: a bow always clears the main hand and overwrites
/// the off one, a crossbow always clears the off hand and overwrites the main one. Never both.
///
/// The base the try-ons land on is the player's **melee** pair only. `Dress()` / `SetUnit` do not
/// install gear slot-by-slot at all — `0x5059a0` duplicates the unit's live `CM2Model` and
/// deep-copies its attachment tree (`0x70ea00`) — so the base is literally what the player is
/// showing in the world, where a ranged weapon renders only while ranged-drawn and our booth is
/// frozen melee-drawn (0465 / 1060). A *worn* bow therefore does not appear until it is tried on,
/// which is also the behaviour that shipped, and is why the ranged slot of a dressing-room look is
/// only ever filled by a substitution.
fn held_lanes(equipment: &mut [CharEnumItem; 19], room: &DressUpRoom) {
// The player's own melee pair — anything the room substituted is replayed below instead.
let base = |slot: usize| {
(room.worn[slot].is_none() && equipment[slot].display_id != 0).then_some(equipment[slot])
};
let (mut main, mut off) = (base(15), base(16));
for &slot in &room.held_order {
let Some(item) = room.worn[slot] else {
continue;
};
// Which lane the item installs into. A bow is the one ranged type that goes LEFT.
let to_off = slot == 16 || (slot == 17 && item.inventory_type == 15);
let (mine, other) = if to_off {
(&mut off, &mut main)
} else {
(&mut main, &mut off)
};
if let Some(evicted) = *other {
let (m, o) = if to_off {
(evicted.inventory_type, item.inventory_type)
} else {
(item.inventory_type, evicted.inventory_type)
};
if !lanes_coexist(m, o) {
*other = None;
}
}
*mine = Some(item);
}
// Back into the enum array, each item at the slot its own inventory type names — so a bow that
// won the off lane still renders through the ranged arm (`placement`'s HandLeft), which is what
// the widget installs it as.
for slot in HELD_SLOTS {
equipment[slot] = CharEnumItem::default();
}
for item in [main, off].into_iter().flatten() {
if let Some(slot) = equip_slot(item.inventory_type) {
equipment[slot] = item;
}
}
}
/// `0x504bc0(mainInvType, offInvType, dualWield)` — may these two held lanes coexist?
///
/// TRUE only for a one-hand main (`WEAPON` 13 / `WEAPONMAINHAND` 21) beside a `SHIELD` (14) or
/// `HOLDABLE` (23), plus the dual-wield arm for a `WEAPON` (13) / `WEAPONOFFHAND` (22) off-hand.
/// Everything else — a two-hander beside anything, and **any** ranged type on either side —
/// evicts.
///
/// **The dual-wield arm is taken as permitted, and that is a stated assumption.** The binary gates
/// it on a global (`0xc4d770`) whose source wow-re could not identify, and we have no dual-wield
/// capability streamed to read. Permitted is the conservative direction here: it evicts strictly
/// less than the alternative, so it can only ever leave the pre-1076 behaviour in place for a
/// melee pair — while the ranged evictions this record is actually about are unconditional in the
/// binary and unaffected by the global either way.
fn lanes_coexist(main: u8, off: u8) -> bool {
matches!(main, 13 | 21) && matches!(off, 13 | 14 | 22 | 23)
}
/// What the room is currently showing: the substitutions applied on top of the player's own gear,
/// and the ones still waiting on a template answer.
#[derive(Resource, Default)]
@@ -46,6 +135,12 @@ pub(crate) struct DressUpRoom {
/// Resolved substitutions by equipment slot — `(display id, inventory type)`, the same pair the
/// enum-shaped array carries.
worn: [Option<CharEnumItem>; 19],
/// The **held** substitutions in the order they landed, most recent last (slots 15/16/17 only).
///
/// The widget installs each try-on on top of the model it is already showing, and a held item
/// can *evict* the opposite lane, so which one arrived last decides what survives — a fact the
/// per-slot array above cannot express on its own. See [`held_lanes`] (decision 1076).
held_order: Vec<usize>,
/// Item ids whose template has not answered yet, oldest first. Retried every frame; a
/// substitution only becomes visible once its display id is known.
pending: Vec<u32>,
@@ -58,6 +153,7 @@ impl DressUpRoom {
DressUpIntent::Dress => {
self.open = true;
self.worn = Default::default();
self.held_order.clear();
self.pending.clear();
}
DressUpIntent::TryOn(item) => {
@@ -67,6 +163,7 @@ impl DressUpRoom {
DressUpIntent::Close => {
self.open = false;
self.worn = Default::default();
self.held_order.clear();
self.pending.clear();
}
}
@@ -87,6 +184,11 @@ impl DressUpRoom {
display_id,
inventory_type,
});
// Held slots also record *when* they landed — the eviction law reads the order.
if HELD_SLOTS.contains(&slot) {
self.held_order.retain(|&s| s != slot);
self.held_order.push(slot);
}
}
// A non-worn item (a potion, a bag) resolves to no slot and simply previews nothing —
// the reference's `TryOn` is equally happy to be handed one.
@@ -164,6 +266,8 @@ fn player_look(
};
}
}
// …and then the widget's own two-lane law over the held triple (decision 1076).
held_lanes(&mut equipment, room);
Some(DressUpLook {
display_id: net.display_id?,
race: s.unit_race()?,
@@ -347,4 +451,114 @@ mod tests {
assert!(room.pending.is_empty(), "resolved, just not worn anywhere");
assert!(room.worn.iter().all(Option::is_none));
}
/// The director's report (2026-08-06): *"when I ctrl click a bow or cross bow I still see only
/// the swords no bow"*. Decision 1076 — the widget installs a tried-on ranged weapon at a hand,
/// and it coexists with neither melee lane, so the sword and shield go.
///
/// Both ranged directions in one test because they evict *opposite* lanes in the binary and a
/// single-sided implementation would still pass one half: a bow takes the off lane (clearing
/// the main), a crossbow takes the main (clearing the off).
#[test]
fn a_tried_on_ranged_weapon_takes_a_hand_and_clears_the_melee_pair() {
let (cmds, _rx) = commands();
let mut items = Items::default();
items.insert_template(1500, Some(worn("Worn Sword", 5500, 21))); // WEAPONMAINHAND
items.insert_template(1600, Some(worn("Worn Shield", 5600, 14))); // SHIELD
items.insert_template(2500, Some(worn("Short Bow", 8500, 15))); // RANGED
items.insert_template(2600, Some(worn("Crossbow", 8600, 26))); // RANGEDRIGHT
let store = player(&[(15, 1500), (16, 1600)]);
for (item, display, what) in [(2500u32, 8500u32, "bow"), (2600, 8600, "crossbow")] {
let mut room = DressUpRoom::default();
room.apply(DressUpIntent::Dress);
room.apply(DressUpIntent::TryOn(item));
room.resolve_pending(&mut items, &cmds);
let look = player_look(&store, &net(), &room, &mut items, &cmds).expect("a look");
assert_eq!(
look.equipment[17].display_id, display,
"the {what} is in the ranged slot, which is what puts it in a hand"
);
assert_eq!(
(look.equipment[15].display_id, look.equipment[16].display_id),
(0, 0),
"…and a ranged weapon coexists with neither melee lane (0x504bc0)"
);
}
}
/// The other half of the same law, and the reason the fix is not "always render slot 17": a
/// **worn** bow stays invisible. `Dress()`/`SetUnit` clone the live world model rather than
/// installing gear per slot, and there a ranged weapon shows only while ranged-drawn — our
/// booth is frozen melee-drawn. So the hunter standing in the room holds their sword.
#[test]
fn a_worn_ranged_weapon_is_not_shown_until_it_is_tried_on() {
let (cmds, _rx) = commands();
let mut items = Items::default();
items.insert_template(1500, Some(worn("Worn Sword", 5500, 21)));
items.insert_template(1700, Some(worn("Worn Bow", 5700, 15)));
let store = player(&[(15, 1500), (17, 1700)]);
let mut room = DressUpRoom::default();
room.apply(DressUpIntent::Dress);
let look = player_look(&store, &net(), &room, &mut items, &cmds).expect("a look");
assert_eq!(
look.equipment[17].display_id, 0,
"the worn bow stays stowed"
);
assert_eq!(look.equipment[15].display_id, 5500, "the sword is in hand");
}
/// Eviction runs **both ways, in try-on order** — the half a "hide the melee pair whenever the
/// ranged slot is filled" shortcut would get wrong. Try on a bow, then a two-hander: the
/// two-hander takes the main lane and the bow, which can coexist with nothing, goes.
#[test]
fn a_later_melee_try_on_evicts_the_ranged_one() {
let (cmds, _rx) = commands();
let mut items = Items::default();
items.insert_template(2500, Some(worn("Short Bow", 8500, 15)));
items.insert_template(2700, Some(worn("Great Axe", 8700, 17))); // TWOHAND
let store = player(&[]);
let mut room = DressUpRoom::default();
room.apply(DressUpIntent::Dress);
room.apply(DressUpIntent::TryOn(2500));
room.resolve_pending(&mut items, &cmds);
room.apply(DressUpIntent::TryOn(2700));
room.resolve_pending(&mut items, &cmds);
let look = player_look(&store, &net(), &room, &mut items, &cmds).expect("a look");
assert_eq!(look.equipment[15].display_id, 8700, "the axe is in hand");
assert_eq!(look.equipment[17].display_id, 0, "…and the bow is gone");
}
/// The lanes that DO coexist are left alone — a one-hander beside a shield is the ordinary case
/// and 1076 must not start evicting there. (A two-hander beside a shield is a different matter,
/// and the reference evicts that one; it is asserted in the same breath so the coexistence set
/// is pinned rather than merely "nothing changed".)
#[test]
fn a_shield_coexists_with_a_one_hander_but_not_with_a_two_hander() {
let (cmds, _rx) = commands();
let mut items = Items::default();
items.insert_template(1500, Some(worn("Worn Sword", 5500, 21)));
items.insert_template(1550, Some(worn("Worn Axe", 5550, 17))); // TWOHAND
items.insert_template(1600, Some(worn("Bright Shield", 5600, 14)));
let mut room = DressUpRoom::default();
room.apply(DressUpIntent::Dress);
room.apply(DressUpIntent::TryOn(1600));
room.resolve_pending(&mut items, &cmds);
let look = player_look(&player(&[(15, 1500)]), &net(), &room, &mut items, &cmds).unwrap();
assert_eq!(look.equipment[15].display_id, 5500, "the one-hander stays");
assert_eq!(look.equipment[16].display_id, 5600, "…beside the shield");
let look = player_look(&player(&[(15, 1550)]), &net(), &room, &mut items, &cmds).unwrap();
assert_eq!(
look.equipment[15].display_id, 0,
"the two-hander is evicted"
);
assert_eq!(look.equipment[16].display_id, 5600);
}
}
+12 -2
View File
@@ -149,8 +149,18 @@ impl Plugin for UiPetPlugin {
pet_stop_on_old_target_clear
.in_set(UnitFeed)
.before(feed_pet_bar),
feed_pet_bar.in_set(UnitFeed).before(UiInput),
feed_pet_unit.in_set(UnitFeed).before(UiInput),
// …and both event-firing feeds run AFTER the pet snapshot (decision 1073). Their
// events reach Lua synchronously, and the handlers read `HasPetUI()` — which is
// `crate::ui_pet_stats`'s push, not ours. Unordered, a cold summon fired both
// edges against last frame's answer and the Pet tab never came up.
feed_pet_bar
.in_set(UnitFeed)
.after(crate::ui_pet_stats::PetSnapshot)
.before(UiInput),
feed_pet_unit
.in_set(UnitFeed)
.after(crate::ui_pet_stats::PetSnapshot)
.before(UiInput),
feed_pet_menu.in_set(UnitFeed).before(UiInput),
drain_pet_actions.after(UiInput),
drain_pet_menu.after(UiInput),
+100 -1
View File
@@ -67,13 +67,39 @@ pub(crate) struct PetFamilyTables {
pub(crate) foods: benilla_formats::PetFoodNames,
}
/// **The pet snapshot must be pushed before anything fires an event whose handlers read it**
/// (decision 1073). `fire_event` dispatches the Lua handlers *synchronously*, so a system that
/// fires while this frame's `set_pet_stats` is still pending hands the VM last frame's answer —
/// the codebase's own "push before firing" rule (`crate::ui_unit`), which held *inside* every
/// feed but not *between* them.
///
/// It cost the Pet tab. `BenillaPetTab_Update` is edge-driven off `UNIT_PET` / `PET_BAR_UPDATE`
/// and its whole predicate is `HasPetUI()`, which is this snapshot. With the three pet feeds
/// merely unordered inside [`UnitFeed`], a cold first summon fired both edges while `HasPetUI()`
/// still answered "no pet"; the push landed after, and since neither edge repeats, the tab stayed
/// down for the rest of the session. Measured live, frame by frame: both events on frame 440 with
/// `HasPetUI()=(nil,nil)`, the flip to `(1,nil)` on 441, and the tab still hidden 48 s later —
/// while calling `BenillaPetTab_Update()` by hand raised it instantly.
///
/// A set rather than a pair of `.before()` calls because the constraint belongs to the *snapshot*,
/// not to today's two consumers: anything that later fires a pet event inherits it by ordering
/// after this, and does not have to rediscover why.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct PetSnapshot;
pub(crate) struct UiPetStatsPlugin;
impl Plugin for UiPetStatsPlugin {
fn build(&self, app: &mut App) {
// Rides the unit feed beside the pet bar's own, and before the VM ticks — the pet frame
// repaints out of the same pass that pushes its health.
app.add_systems(Update, feed_pet_stats.in_set(UnitFeed).before(UiInput));
app.add_systems(
Update,
feed_pet_stats
.in_set(UnitFeed)
.in_set(PetSnapshot)
.before(UiInput),
);
}
}
@@ -652,4 +678,77 @@ mod tests {
assert_eq!(s.family, None);
assert!(s.food_types.is_empty());
}
/// **The schedule invariant, not the function's** (decision 1073): by the time `UNIT_PET`
/// reaches a Lua handler, `HasPetUI()` must already answer for the pet that event announces.
///
/// This is the Pet-tab bug in its smallest reproducible form, and it is only visible from a
/// real `App`: every unit test above calls `stats_for` directly and passes whatever the
/// ordering does. Driven through **one** `app.update()` on a cold pet — the exact live shape,
/// where `feed_pet_unit` sees the guid appear and fires while `feed_pet_stats` has or has not
/// yet pushed. A handler that reads `HasPetUI()` at that instant is what the shipped
/// `BenillaPetTab_Update` is, minus the frames.
#[test]
fn unit_pet_reaches_lua_with_has_pet_ui_already_true() {
use crate::char_select::ClientState;
use crate::net::GuidIndex;
use crate::ui_pet::UiPetPlugin;
const PET_GUID: u64 = 0xf140_0000_0000_002a;
let (tx, _rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
.insert_state(ClientState::InWorld)
.init_resource::<NameCache>()
.init_resource::<GuidIndex>()
.init_resource::<crate::target::Selection>()
.init_resource::<crate::net::SelfGuid>()
.init_resource::<crate::ui_script::UiClock>()
.init_resource::<crate::ui_action::UiErrorKeys>()
.init_resource::<crate::net::Reputations>()
.init_resource::<crate::ui_cast::QueuedMeleeSpell>()
.init_resource::<crate::ui_action::AutoRepeatActive>()
.add_message::<crate::creature_anim::SheathRequest>()
.insert_resource(NetCommands(tx))
.add_plugins((UiPetStatsPlugin, UiPetPlugin));
// The pet is already in the world when the bar's guid lands — the cold-summon shape.
let pet = app.world_mut().spawn(boar()).id();
app.world_mut()
.resource_mut::<GuidIndex>()
.0
.insert(PET_GUID, pet);
app.world_mut().resource_mut::<PetBar>().spells.pet_guid = PET_GUID;
// A bare frame standing in for the pet page: it records what `HasPetUI()` answered at the
// moment the event was dispatched, which is the only thing under test.
let script = UiScript::new().unwrap();
script
.run(
r#"
BENILLA_SAW = "never fired"
local f = CreateFrame("Frame")
f:RegisterEvent("UNIT_PET")
f:SetScript("OnEvent", function()
BENILLA_SAW = HasPetUI() and "has pet UI" or "no pet UI"
end)
"#,
)
.unwrap();
app.insert_non_send_resource(script);
app.update();
let saw: String = app
.world_mut()
.non_send_resource::<UiScript>()
.eval("return BENILLA_SAW")
.unwrap();
assert_eq!(
saw, "has pet UI",
"UNIT_PET must not reach Lua ahead of the snapshot its handlers read — unordered, \
this answered \"no pet UI\" and the Pet tab stayed down for the session"
);
}
}
@@ -941,3 +941,106 @@ fn rotate_arrows_tap_twice_and_spin_while_held() {
s.mouse_button(rx, ry, "LeftButton", false);
assert!(s.errors().is_empty(), "no handler errors: {:?}", s.errors());
}
/// **The keybind and the tab row must agree** (the director's 2026-08-06 report: *"go on skills tab
/// and press c, it goes back to char tab but without switching the actual tab ui below properly,
/// then if I click on char tab, it closes the whole window"*).
///
/// The cause was a dropped line: the reference selects the tab inside `ToggleCharacter` itself
/// (`PanelTemplates_SetTab(CharacterFrame, subFrame:GetID())`, ref `CharacterFrame.lua:11`), on
/// EVERY path. Ours only did it in the tab button's own `OnClick`, so the two entry points into the
/// window diverged: a keybind switched the page and left the row selecting the old tab, and the
/// next click on the *now-showing* page's tab hit `ToggleCharacter`'s "this page is already
/// visible" branch and shut the window instead of switching back.
///
/// Driven through the same entry point the `C` / `SHIFT-P` bindings use — a bare
/// `ToggleCharacter(page)` call, never the button — because that is precisely the path the row used
/// to miss.
#[test]
fn a_keybind_page_switch_moves_the_tab_row_with_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "CharacterFrame.xml");
load_xml(&s, "PetPaperDollFrame.xml");
load_xml(&s, "SkillFrame.xml");
s.set_unit("player", Some(player_unit()));
// THE INVARIANT the fix rests on: a page's `id=` is its slot in this window's own tab row, and
// `BENILLA_CHARACTERFRAME_SUBFRAMES` is the same 1:1 mapping written the other way round. The
// reference's numbers are NOT ours — its Skills page is id 4 behind a Reputation tab we have
// not built — so this is the check that makes the divergence loud the day Reputation lands
// between them, instead of silently selecting the wrong tab.
for i in 1..=3 {
let id: i64 = s
.eval(&format!(
"return getglobal(BENILLA_CHARACTERFRAME_SUBFRAMES[{i}]):GetID()"
))
.unwrap();
assert_eq!(
id, i,
"page {i} of BENILLA_CHARACTERFRAME_SUBFRAMES must carry id={i}"
);
}
let selected = |s: &mut UiScript| {
s.eval::<i64>("return PanelTemplates_GetSelectedTab(BenillaCharacterFrame)")
.unwrap()
};
// A tab wears its "Active" (=Disabled) art exactly when it is the selected one — the visible
// half of the same fact, so a SetTab that updated the number without repainting fails here too.
let wearing_active_art = |s: &mut UiScript, tab: u32| {
s.eval::<bool>(&format!(
"return BenillaCharacterFrameTab{tab}MiddleDisabled:IsVisible()"
))
.unwrap()
};
let shown = |s: &mut UiScript, name: &str| {
s.eval::<bool>(&format!("return {name}:IsVisible()"))
.unwrap()
};
// Open on Character (the `C` binding), then move to Skills the same way.
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
assert_eq!(selected(&mut s), 1);
s.run(r#"ToggleCharacter("BenillaSkillFrame")"#).unwrap();
assert!(shown(&mut s, "BenillaSkillFrame"));
assert_eq!(selected(&mut s), 3, "the row follows a keybind to Skills");
assert!(wearing_active_art(&mut s, 3));
assert!(!wearing_active_art(&mut s, 1));
// THE REPORT: `C` from the Skills page. The page goes back to Character — and so must the row.
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
assert!(shown(&mut s, "BenillaPaperDollFrame"));
assert!(!shown(&mut s, "BenillaSkillFrame"));
assert_eq!(
selected(&mut s),
1,
"the tab row followed the keybind back to Character"
);
assert!(wearing_active_art(&mut s, 1));
assert!(!wearing_active_art(&mut s, 3));
// …and the second half of the report — *"then if I click on char tab, it closes the whole
// window"* — falls out of the same fix rather than needing its own. `PanelTemplates_SelectTab`
// DISABLES the tab it selects (a selected tab is not re-clickable), so with the row in step the
// Character tab is inert while the Character page is up. The window only closed because the row
// still said Skills: tab 1 was left enabled, the click reached `ToggleCharacter`, and the page
// it named was already visible — the HideUIPanel arm.
s.run("BenillaCharacterFrameTab1:Click()").unwrap();
assert!(
shown(&mut s, "BenillaCharacterFrame"),
"the selected tab is disabled — clicking it cannot close the window"
);
assert!(
shown(&mut s, "BenillaPaperDollFrame"),
"…or change the page"
);
assert!(s.errors().is_empty(), "no handler errors: {:?}", s.errors());
}
@@ -243,6 +243,62 @@ fn xp_loss_two_step_confirm_then_range_hide() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A cancelled confirm comes back on the next ask (decision 1068, B80): every CONFIRM_XP_LOSS
/// fire re-shows the dialog — the feed fires per SMSG, and this guards the Lua half of that
/// contract. And the XP_LOSS dialogs carry the ref's alert dress (StaticPopup.lua l.1579-1589):
/// width 420 with the AlertIcon shown, reset to 320/hidden for a non-alert entry on the same
/// instance.
#[test]
fn xp_loss_cancel_then_reconfirm_reshows_with_the_alert_dress() {
let mut s = setup();
s.set_death(DeathUiState {
sickness_duration: Some("8 minutes".into()),
spirit_healer_in_range: true,
..Default::default()
});
s.fire_event("CONFIRM_XP_LOSS", vec![]);
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
assert_eq!(
s.eval::<f64>("return StaticPopup1:GetWidth()").unwrap(),
420.0,
"showAlert widens the dialog to the ref's 420"
);
assert!(
s.eval::<bool>("return StaticPopup1AlertIcon:IsShown()")
.unwrap(),
"showAlert shows the DialogAlertIcon"
);
// Cancel: nothing queues, nothing on the wire — the dialog just hides.
s.run("this = StaticPopup1Button2 StaticPopup_OnClick(StaticPopup1, 2) this = nil")
.unwrap();
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
assert!(s.take_death_actions().is_empty());
// Asking the healer again = a fresh SMSG = the feed fires again: the confirm is back.
s.fire_event("CONFIRM_XP_LOSS", vec![]);
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"a fresh confirm re-shows after a Cancel (B80's deadlock)"
);
s.run("StaticPopup_Hide(\"XP_LOSS\")").unwrap();
// A non-alert entry on the same instance resets the dress — Show's unconditional reset.
s.run(concat!(
"StaticPopupDialogs[\"TEST_PLAIN\"] = { text = \"x\", button1 = \"OK\", timeout = 0, ",
"whileDead = 1 } StaticPopup_Show(\"TEST_PLAIN\")"
))
.unwrap();
assert_eq!(
s.eval::<f64>("return StaticPopup1:GetWidth()").unwrap(),
320.0,
"a non-alert Show resets the width"
);
assert!(
!s.eval::<bool>("return StaticPopup1AlertIcon:IsShown()")
.unwrap(),
"a non-alert Show hides the icon"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// UnitIsGhost/UnitIsDeadOrGhost — the trio's ghost legs (a ghost has health 1, so UnitIsDead is
/// false for it; decision 0308 §1).
#[test]
@@ -58,8 +58,10 @@ pub(super) fn drive_script(
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
mut font_atlas: Option<ResMut<UiFontAtlas>>,
// The token -> off-screen-baked-face bridge a `SetPortraitTexture`-bound region samples.
portraits: Res<crate::portrait::PortraitImages>,
// Both directions of the booth seam: the token -> off-screen-baked-face bridge a
// `SetPortraitTexture`-bound region samples, and the pane geometry this pass publishes back
// for the booths' projection aspect + render gate (decision 1069).
mut booths: crate::portrait::BoothBridge,
// Gates the held-cursor icon quad (decision 0216 §5): CAPTURE-ONLY, the same presence check
// every other capture-only system uses (`ui_script::capture_ui_active`'s sibling pattern).
capture: Option<Res<crate::capture::CaptureMode>>,
@@ -324,7 +326,7 @@ pub(super) fn drive_script(
let settled = capture.is_none()
&& prev.dims == Some(dims)
&& text_ui == prev.text_ui
&& portraits.0 == prev.portraits
&& booths.images.0 == prev.portraits
&& extracted == prev.extracted;
if settled {
drop(extract_span);
@@ -358,8 +360,13 @@ pub(super) fn drive_script(
}
prev.dims = Some(dims);
prev.text_ui = text_ui.clone();
prev.portraits = portraits.0.clone();
prev.portraits = booths.images.0.clone();
prev.extracted = extracted.clone();
// This frame's booth panes, refilled by the loop below (decision 1069). Cleared only HERE, on
// the un-skipped path: a settled frame draws exactly what the last one did, so the map it left
// is still this frame's truth — clearing it above the gate would make every quiet frame put the
// body panes' cameras to sleep and freeze their animation.
booths.panes.0.clear();
for eq in extracted {
let Some(r) = eq.rect else { continue };
// WoW UI space is y-up from the bottom-left in 768-virtual units; the quad pass is
@@ -419,7 +426,20 @@ pub(super) fn drive_script(
// booth yet) draws nothing rather than the run-splitter's white default.
if let Some(token) = &portrait_unit {
use crate::portrait::PortraitSource;
let handle = match portraits.0.get(token) {
// A **square** binding is a booth pane (`BenillaSetBoothTexture`, decision
// 0208 §5), not a round unit portrait: publish the rect's aspect so the booth
// can bake at the shape it will be stretched into, and know it is on screen at
// all (decision 1069). Recorded before the readiness `continue` below — a pane
// whose bake hasn't landed yet is still a pane being drawn. The region's rect
// is the whole answer because no pane crops its bake; a pane that grew
// `<TexCoords>` would have to fold that UV window in here too.
if !circular && rect.height() > 0.0 {
booths
.panes
.0
.insert(token.clone(), rect.width() / rect.height());
}
let handle = match booths.images.0.get(token) {
Some(PortraitSource::Live(h)) => Some(h.clone()),
Some(PortraitSource::File(p)) => assets
.as_mut()
@@ -820,6 +840,7 @@ mod clip_plumb_tests {
app.init_resource::<UiQuads>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::hover_log::UiFrameCost>();
app.init_resource::<Time>();
@@ -903,6 +924,7 @@ mod clip_plumb_tests {
app.init_resource::<UiQuads>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::hover_log::UiFrameCost>();
app.init_resource::<Time>();
@@ -959,6 +981,7 @@ mod extract_gate_tests {
app.init_resource::<UiQuads>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::hover_log::UiFrameCost>();
app.init_resource::<Time>();
@@ -183,14 +183,23 @@ pub(super) fn emit(
// actually draws, which is what a truncation report needs to show.
let probe = std::env::var("WOW_TEXT_PROBE").as_deref() == Ok("1");
if probe {
// For the focused edit box, also the two numbers that must agree: where the engine puts the
// caret (`caret=`, advance-table-derived) and how wide the text this pass actually draws is
// (`ink=`). They diverge by the width of the markup when the advance table is measured over
// the raw buffer — the caret-out-in-space report (decision 1075) as a pair of numbers.
let ebox_geom = ebox.map(|ui| {
let ink = crate::ui_text::measure_text(atlas, draw_text, None, spec).0;
format!(" caret={:.1} ink={ink:.1}", ui.caret_x * host.scale)
});
info!(
"text probe: [{:.0},{:.0} {:.0}x{:.0}] h={:?} {:?}",
"text probe: [{:.0},{:.0} {:.0}x{:.0}] h={:?}{} {:?}",
draw_rect.min.x,
draw_rect.min.y,
host.rect.width(),
host.rect.height(),
style.font_height,
&draw_text[..draw_text.len().min(30)]
ebox_geom.unwrap_or_default(),
&draw_text[..draw_text.len().min(60)]
);
}
// The drop shadow (font object `<Shadow>` — MasterFont's (1,-1) black covers the whole
@@ -413,6 +413,51 @@ fn hovering_the_diet_icon_lists_what_the_pet_eats() {
"the diet icon is shown for a hunter pet"
);
// …and it is actually PAINTED, not merely a mouse-enabled hole. The director's 2026-08-06
// report was exactly that split — the tooltip answered while nothing was on screen — so the
// hover assertion below is not sufficient on its own: pin the art too, with its size and its
// reference TexCoords (the icon is the pet's DIET affordance, so its coords are static).
s.resolve();
let quads = s.extract();
let icon = quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("UI-PetHappiness"))
})
.expect("the diet icon's UI-PetHappiness quad is in the render list");
let rect = icon.rect.expect("…with a resolved rect");
assert!(
(rect.right - rect.left - 24.0).abs() < 0.5 && (rect.top - rect.bottom - 23.0).abs() < 0.5,
"24x23 (ref PetPaperDollPetInfo), got {}x{}",
rect.right - rect.left,
rect.top - rect.bottom
);
// …and it draws ON TOP of the model pane it sits inside — the actual regression. The pane's
// booth quad is OPAQUE and the icon's rect is wholly within it, so being in the render list is
// not the same as being seen. The pane rides BACKGROUND for exactly this reason (decision
// 1070): at ARTWORK the draw layer, which is bucket-wide and outranks the frame (0884), buried
// the icon no matter which frame was declared later.
let pane = quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path, .. }
if path.as_deref().unwrap_or_default().is_empty())
&& q.rect.is_some_and(|r| {
r.left <= rect.left
&& r.right >= rect.right
&& r.bottom <= rect.bottom
&& r.top >= rect.top
})
})
.expect("the pet model pane's booth quad covers the icon's rect");
assert!(
icon.z > pane.z,
"the diet icon must paint over the pane it sits in (icon z={:#x}, pane z={:#x})",
icon.z,
pane.z
);
s.run(
"local f = BenillaPetPaperDollPetInfo \
f:GetScript(\"OnEnter\")(f)",
+31 -10
View File
@@ -149,11 +149,26 @@ fn measure_line_width(
/// index degrades to the char's start and char-boundary lookups are exact. Single-line is the
/// EditBox law; a `\n`-bearing (multiLine) string accumulates per line via the run's line index
/// (x resets never — the table stays monotonic, which is all the hit-test needs).
///
/// **Indexed by the box's RAW byte, measured over what is DRAWN** (decision 1075). The box stores
/// the escaped string and draws only the visible one, so every `|c…`/`|r`/`|H…|h`/`|T…|t` byte
/// costs zero width: [`crate::ui_text::markup::visible_map`] carries each drawn byte back to its
/// raw offset, and the forward-fill below hands every escape byte the previous boundary's width.
/// Measuring the raw string instead is what parked the caret half a chat bar right of a
/// shift-clicked item link.
///
/// **Stated approximation:** the drawn string is shaped as ONE buffer, while the draw shapes each
/// color run separately — so kerning across a color boundary can differ by a fraction of a pixel.
/// (This predates 1075: the raw string was shaped as one buffer too.)
pub(crate) fn line_advances(atlas: &mut UiFontAtlas, text: &str, font: FontSpec) -> Vec<f32> {
let mut cum = vec![0.0f32; text.len() + 1];
if text.is_empty() {
return cum;
}
let (drawn, bounds) = crate::ui_text::markup::visible_map(text);
if drawn.is_empty() {
return cum; // pure markup draws nothing — every boundary sits at x = 0
}
let family = font
.path
.and_then(|p| atlas.path_to_family.get(&p.to_ascii_lowercase()))
@@ -163,9 +178,10 @@ pub(crate) fn line_advances(atlas: &mut UiFontAtlas, text: &str, font: FontSpec)
let font_size = atlas.snap_for(&family, font.height.unwrap_or(DEFAULT_FONT_SIZE));
let scale = atlas.scale;
let step_extra = step_extra_of(font.outline);
// Byte offset of each buffer line ('\n'-split) — glyph cluster ranges are line-relative.
// Byte offset of each buffer line ('\n'-split) within the DRAWN string — glyph cluster ranges
// are line-relative, and the map below is indexed in drawn bytes.
let mut line_starts = vec![0usize];
for (i, b) in text.bytes().enumerate() {
for (i, b) in drawn.bytes().enumerate() {
if b == b'\n' {
line_starts.push(i + 1);
}
@@ -177,7 +193,7 @@ pub(crate) fn line_advances(atlas: &mut UiFontAtlas, text: &str, font: FontSpec)
let font_system = &mut atlas.font_system;
let mut buffer = Buffer::new(font_system, Metrics::new(phys, phys));
buffer.set_wrap(font_system, Wrap::None);
buffer.set_text(font_system, text, &attrs, Shaping::Advanced, None);
buffer.set_text(font_system, &drawn, &attrs, Shaping::Advanced, None);
buffer.shape_until_scroll(font_system, false);
let mut x = 0.0f32;
for run in buffer.layout_runs() {
@@ -186,7 +202,8 @@ pub(crate) fn line_advances(atlas: &mut UiFontAtlas, text: &str, font: FontSpec)
let key: GlyphKey = (g.font_id, g.glyph_id, font_size.to_bits(), 0);
let adv = atlas.glyphs.get(&key).map(|i| i.advance).unwrap_or(g.w);
x += client_step(adv, step_extra, scale);
let end = (base + g.end).min(text.len());
// The glyph's end boundary in DRAWN bytes → the RAW boundary it sits on.
let end = bounds[(base + g.end).min(drawn.len())];
cum[end] = x;
written[end] = true;
}
@@ -233,9 +250,9 @@ fn drawn_k(font_height: Option<f32>, font_size: f32) -> f32 {
/// pitch (the snapped font em, the same `N·S` block law [`measure_text`] heights with). Row
/// starts are reconstructed by walking the source string past each wrapped row's verbatim text
/// and the separator the break swallowed (wrap keeps inter-word whitespace verbatim and drops
/// only the trailing separator, so the walk is exact). Assumes markup-free text — the EditBox
/// display string, whose draw treats `|c` codes as literal metrics anyway. Never empty (`[0]`
/// for empty text).
/// only the trailing separator, so the walk is exact) — in DRAWN bytes, mapped back to RAW ones,
/// since the rows index the same raw buffer [`line_advances`] does (decision 1075). Never empty
/// (`[0]` for empty text).
pub(crate) fn line_rows(
atlas: &mut UiFontAtlas,
text: &str,
@@ -297,10 +314,14 @@ fn segment_row_starts(seg: &str, sub: &[Vec<ColorRun>], base: usize, rows: &mut
rows.push(base);
return;
}
let mut p = 0usize; // byte cursor within `seg`
// A wrapped row is made of DRAWN text (the markup is gone by then) while a row start must be a
// RAW byte — the offset space the box's cursor and [`line_advances`] both live in. So the walk
// runs in drawn bytes and maps each start back (decision 1075).
let (drawn, bounds) = crate::ui_text::markup::visible_map(seg);
let mut p = 0usize; // byte cursor within `drawn`
for (j, line) in sub.iter().enumerate() {
if j > 0 {
while let Some(c) = seg[p..].chars().next() {
while let Some(c) = drawn[p.min(drawn.len())..].chars().next() {
if c.is_whitespace() {
p += c.len_utf8();
} else {
@@ -308,7 +329,7 @@ fn segment_row_starts(seg: &str, sub: &[Vec<ColorRun>], base: usize, rows: &mut
}
}
}
rows.push(base + p);
rows.push(base + bounds[p.min(drawn.len())]);
p += line.iter().map(|r| r.text.len()).sum::<usize>();
}
}
+201 -112
View File
@@ -18,17 +18,86 @@ use crate::ui_text::markup::ColorRun;
/// (a glyph step is ≥3px) and far above the seam dust (≤1e-3).
const WIDTH_EPS: f32 = 0.25;
/// One word carried through wrapping with the color it inherits from its source [`ColorRun`] and the
/// exact whitespace (`lead`) that separated it from the previous word — empty for a line's first word.
/// Preserving the verbatim separator (rather than collapsing it to one space) is what keeps Blizzard's
/// A hyperlink handle, shared by every run/piece the link's visible text splits into.
type Link = Option<std::sync::Arc<crate::ui_text::markup::LinkInfo>>;
/// Two handles name the same link (pointer identity — [`crate::ui_text::markup`] shares one `Arc`
/// across a link's runs for exactly this test).
fn same_link(a: &Link, b: &Link) -> bool {
match (a, b) {
(None, None) => true,
(Some(x), Some(y)) => std::sync::Arc::ptr_eq(x, y),
_ => false,
}
}
/// One word carried through wrapping — the wrap's atomic **break unit** — plus the exact whitespace
/// (`lead`) that separated it from the previous word (empty for a line's first word). Preserving the
/// verbatim separator (rather than collapsing it to one space) is what keeps Blizzard's
/// double-space-after-period intact through the wrap.
///
/// A word carries **pieces**, not one color, because a color/link boundary can fall *inside* a word.
/// `|cff9d9d9d|Hitem:7092:0:0:0|h[Chipped Claw]|h|rx2.` has no space between `]` and `x`, so
/// `[Chipped Claw]x2.` is one break unit whose two halves must keep their own colors and their own
/// link membership. Latching a single color per word (the pre-1075 model, taken from the run the
/// word's FIRST char sat in) painted the whole unit in that run's color and inside its clickable
/// span — every character typed straight after a chat link came out in the item's quality colour
/// (director, 2026-08-06; decision 1075).
pub(super) struct WrapWord {
text: String,
color: [f32; 4],
/// The word's styled pieces, in source order — one for the common case, more across an interior
/// color/link boundary. Never empty, and never holds an empty piece.
pieces: Vec<ColorRun>,
lead: String,
/// The hyperlink the word's source run sat inside (shared Arc) — carried through the wrap so
/// a link split across lines still spans every one of its words.
link: Option<std::sync::Arc<crate::ui_text::markup::LinkInfo>>,
}
impl WrapWord {
/// The word's visible text (its pieces joined) — what the packer measures and force-breaks.
fn text(&self) -> String {
self.pieces.iter().map(|p| p.text.as_str()).collect()
}
}
/// Append `ch` to a piece list under `run`'s style, extending the last piece when the style is
/// unchanged — the one place a new piece is opened, so a piece is never empty.
fn push_char(pieces: &mut Vec<ColorRun>, ch: char, run: &ColorRun) {
match pieces.last_mut() {
Some(last) if last.color == run.color && same_link(&last.link, &run.link) => {
last.text.push(ch);
}
_ => pieces.push(ColorRun {
text: ch.to_string(),
color: run.color,
link: run.link.clone(),
}),
}
}
/// Split a word's pieces at byte offset `at` of its joined text (the force-break point). `at` is a
/// char boundary of the joined text, and a piece is a whole substring of it, so the per-piece cut
/// lands on a char boundary too.
fn split_pieces(pieces: &[ColorRun], at: usize) -> (Vec<ColorRun>, Vec<ColorRun>) {
let (mut head, mut tail) = (Vec::new(), Vec::new());
let mut off = 0usize;
for p in pieces {
let end = off + p.text.len();
if end <= at {
head.push(p.clone());
} else if off >= at {
tail.push(p.clone());
} else {
let cut = at - off;
head.push(ColorRun {
text: p.text[..cut].to_string(),
..p.clone()
});
tail.push(ColorRun {
text: p.text[cut..].to_string(),
..p.clone()
});
}
off = end;
}
(head, tail)
}
/// Split a markup line's color runs into [`WrapWord`]s, each carrying the verbatim whitespace that
@@ -37,40 +106,31 @@ pub(super) struct WrapWord {
/// straddles a color boundary just rides along (whitespace draws no ink, so its color is immaterial).
/// A leading run of whitespace on the line attaches to the first word's `lead` and is dropped at emit
/// (like the pre-existing `split_whitespace`), so lines never gain a phantom indent.
///
/// Only whitespace ends a word: a color/link boundary splits the word into [pieces](WrapWord::pieces)
/// but is **not** a break opportunity (the client's opportunity classes are whitespace — see
/// [`super::wrap_line`]), so `[Chipped Claw]x2.` can never wrap between the `]` and the `x`.
pub(super) fn tokenize_words(line: &[ColorRun]) -> Vec<WrapWord> {
let mut words: Vec<WrapWord> = Vec::new();
let mut cur = String::new();
let mut cur_color = [0.0f32; 4];
let mut cur_link: Option<std::sync::Arc<crate::ui_text::markup::LinkInfo>> = None;
let mut cur: Vec<ColorRun> = Vec::new();
let mut lead = String::new();
for run in line {
for ch in run.text.chars() {
if ch.is_whitespace() {
if !cur.is_empty() {
words.push(WrapWord {
text: std::mem::take(&mut cur),
color: cur_color,
pieces: std::mem::take(&mut cur),
lead: std::mem::take(&mut lead),
link: cur_link.take(),
});
}
lead.push(ch);
} else {
if cur.is_empty() {
cur_color = run.color;
cur_link = run.link.clone();
}
cur.push(ch);
push_char(&mut cur, ch, run);
}
}
}
if !cur.is_empty() {
words.push(WrapWord {
text: cur,
color: cur_color,
lead,
link: cur_link,
});
words.push(WrapWord { pieces: cur, lead });
}
words
}
@@ -99,7 +159,7 @@ pub(super) fn greedy_pack<F: FnMut(&str) -> f32>(
let mut cur: Vec<WrapWord> = Vec::new();
let mut cur_w = 0.0f32;
for word in words {
let ww = measure(&word.text);
let ww = measure(&word.text());
if !cur.is_empty() {
let candidate = cur_w + measure(&word.lead) + ww;
if candidate <= max_width {
@@ -111,25 +171,24 @@ pub(super) fn greedy_pack<F: FnMut(&str) -> f32>(
out.push(std::mem::take(&mut cur));
}
// The word opens a fresh line. If it exceeds the width alone, force-break it: each full
// line becomes a chunk, the remainder keeps packing.
// line becomes a chunk, the remainder keeps packing. A multi-piece word splits its pieces
// with it, so each chunk keeps exactly the colors that fell inside it.
let mut word = word;
let mut ww = ww;
while ww > max_width {
let Some((head, rest)) =
split_at_last_fitting_glyph(&word.text, max_width, &mut measure)
else {
let text = word.text();
let Some(at) = split_at_last_fitting_glyph(&text, max_width, &mut measure) else {
// First glyph exceeds: no progress — bail this source line (the client's builder
// bail; the remainder is dropped).
return finish_pack(out, cur);
};
let (head, rest) = split_pieces(&word.pieces, at);
out.push(vec![WrapWord {
text: head,
color: word.color,
pieces: head,
lead: std::mem::take(&mut word.lead),
link: word.link.clone(),
}]);
word.text = rest;
ww = measure(&word.text);
word.pieces = rest;
ww = measure(&word.text());
}
cur_w = ww;
cur.push(word);
@@ -145,8 +204,8 @@ fn finish_pack(mut out: Vec<Vec<WrapWord>>, cur: Vec<WrapWord>) -> Vec<Vec<Color
out.iter().map(|ws| words_to_runs(ws)).collect()
}
/// The force-break point: split `text` after the longest glyph prefix that measures within
/// `max_width`. `None` when not even the first glyph fits (the no-progress case). The walk
/// The force-break point: the byte offset just past the longest glyph prefix of `text` that measures
/// within `max_width`. `None` when not even the first glyph fits (the no-progress case). The walk
/// re-measures the whole prefix per glyph rather than summing per-glyph steps — the step law is
/// additive, so the results agree, and force-broken words are rare and short enough that the
/// simpler exact-agreement-with-`measure` walk wins.
@@ -154,7 +213,7 @@ fn split_at_last_fitting_glyph<F: FnMut(&str) -> f32>(
text: &str,
max_width: f32,
measure: &mut F,
) -> Option<(String, String)> {
) -> Option<usize> {
let mut fit_end = 0usize;
for (i, c) in text.char_indices() {
let end = i + c.len_utf8();
@@ -163,23 +222,14 @@ fn split_at_last_fitting_glyph<F: FnMut(&str) -> f32>(
}
fit_end = end;
}
(fit_end > 0).then(|| (text[..fit_end].to_string(), text[fit_end..].to_string()))
(fit_end > 0).then_some(fit_end)
}
/// Rejoin wrapped words into color runs: consecutive words with the same color AND the same link
/// (pointer identity) merge into one run; the verbatim whitespace between words (`lead`) attaches
/// to the preceding run (invisible, so its color is immaterial). The first word on a line drops
/// its `lead` — the trailing separator at a break.
/// Rejoin a wrapped line's words into color runs: consecutive **pieces** with the same color AND the
/// same link (pointer identity) merge into one run — across a word boundary like within one — and the
/// verbatim whitespace between words (`lead`) attaches to the preceding run (invisible, so its color
/// is immaterial). The first word on a line drops its `lead` — the trailing separator at a break.
fn words_to_runs(words: &[WrapWord]) -> Vec<ColorRun> {
let same_link =
|a: &Option<std::sync::Arc<crate::ui_text::markup::LinkInfo>>,
b: &Option<std::sync::Arc<crate::ui_text::markup::LinkInfo>>| {
match (a, b) {
(None, None) => true,
(Some(x), Some(y)) => std::sync::Arc::ptr_eq(x, y),
_ => false,
}
};
let mut runs: Vec<ColorRun> = Vec::new();
for (i, word) in words.iter().enumerate() {
if i > 0 {
@@ -187,15 +237,13 @@ fn words_to_runs(words: &[WrapWord]) -> Vec<ColorRun> {
last.text.push_str(&word.lead);
}
}
match runs.last_mut() {
Some(last) if last.color == word.color && same_link(&last.link, &word.link) => {
last.text.push_str(&word.text);
for piece in &word.pieces {
match runs.last_mut() {
Some(last) if last.color == piece.color && same_link(&last.link, &piece.link) => {
last.text.push_str(&piece.text);
}
_ => runs.push(piece.clone()),
}
_ => runs.push(ColorRun {
text: word.text.clone(),
color: word.color,
link: word.link.clone(),
}),
}
}
runs
@@ -214,17 +262,21 @@ mod wrap_tests {
s.chars().count() as f32
}
/// One single-piece word (the common case) with the given separator.
fn word(text: &str, color: [f32; 4], lead: &str) -> WrapWord {
WrapWord {
pieces: vec![ColorRun {
text: text.to_string(),
color,
link: None,
}],
lead: lead.to_string(),
}
}
fn words(pairs: &[(&str, [f32; 4])]) -> Vec<WrapWord> {
// The single-space separator these tests assume; the first word's `lead` is ignored on emit.
pairs
.iter()
.map(|(t, c)| WrapWord {
text: (*t).to_string(),
color: *c,
lead: " ".to_string(),
link: None,
})
.collect()
pairs.iter().map(|(t, c)| word(t, *c, " ")).collect()
}
/// Flatten a wrapped line's runs back to plain text (dropping color) for assertions.
@@ -294,24 +346,14 @@ mod wrap_tests {
fn content_exact_width_with_float_dust_does_not_break() {
// One "word" of two 5.5-unit glyphs (11.0 total) in a box 15 ulps shy of 11.0.
let measure = |s: &str| s.chars().count() as f32 * 5.5;
let w = vec![WrapWord {
text: "34".into(),
color: WHITE,
lead: "".into(),
link: None,
}];
let w = vec![word("34", WHITE, "")];
let lines = greedy_pack(w, 11.0 - 0.000015, measure);
assert_eq!(lines.len(), 1, "float dust must not split the digits");
assert_eq!(line_text(&lines[0]), "34");
// A genuinely narrow box (one glyph short) still force-breaks — the epsilon is far below
// any real break decision.
let w2 = vec![WrapWord {
text: "34".into(),
color: WHITE,
lead: "".into(),
link: None,
}];
let w2 = vec![word("34", WHITE, "")];
let lines2 = greedy_pack(w2, 5.5, measure);
assert_eq!(lines2.len(), 2, "a real overflow still breaks");
}
@@ -335,11 +377,11 @@ mod wrap_tests {
}];
let ws = tokenize_words(&line);
assert_eq!(ws.len(), 3);
assert_eq!(ws[0].text, "Hello.");
assert_eq!(ws[0].text(), "Hello.");
assert_eq!(ws[0].lead, "", "first word has no separator");
assert_eq!(ws[1].text, "World");
assert_eq!(ws[1].text(), "World");
assert_eq!(ws[1].lead, " ", "the double space is kept verbatim");
assert_eq!(ws[2].text, "again");
assert_eq!(ws[2].text(), "again");
assert_eq!(ws[2].lead, " ");
}
@@ -347,40 +389,14 @@ mod wrap_tests {
fn wrap_preserves_double_space_between_words() {
// On one line, the joined run text keeps the double space (the greedy join uses each word's
// verbatim `lead`, not a collapsed single space).
let w = vec![
WrapWord {
text: "Hello.".into(),
color: WHITE,
lead: "".into(),
link: None,
},
WrapWord {
text: "World".into(),
color: WHITE,
lead: " ".into(),
link: None,
},
];
let w = vec![word("Hello.", WHITE, ""), word("World", WHITE, " ")];
let lines = greedy_pack(w, 100.0, char_measure);
assert_eq!(lines.len(), 1);
assert_eq!(line_text(&lines[0]), "Hello. World");
// At a break the trailing separator drops (standard): a width that fits "Hello." (6) but not
// "Hello." + " World" splits, and neither produced line carries stray spaces.
let w2 = vec![
WrapWord {
text: "Hello.".into(),
color: WHITE,
lead: "".into(),
link: None,
},
WrapWord {
text: "World".into(),
color: WHITE,
lead: " ".into(),
link: None,
},
];
let w2 = vec![word("Hello.", WHITE, ""), word("World", WHITE, " ")];
let lines2 = greedy_pack(w2, 6.0, char_measure);
assert_eq!(lines2.len(), 2);
assert_eq!(line_text(&lines2[0]), "Hello.");
@@ -402,6 +418,79 @@ mod wrap_tests {
assert_eq!(runs[1].color, RED);
}
/// The director's report (2026-08-06, decision 1075): a chat item link with text typed straight
/// after it, no space between. `Bearer]` and the typed text are ONE word — one break unit — and
/// the wrap must keep them two differently-styled pieces. Before 1075 the word latched the color
/// AND the link of the run its first char sat in, so everything typed after a link came out in
/// the item's quality colour and inside its clickable span.
#[test]
fn a_word_straddling_a_color_boundary_keeps_both_colors() {
let link = std::sync::Arc::new(crate::ui_text::markup::LinkInfo {
link: "item:13984:0:0:0".into(),
markup: "|Hitem:13984:0:0:0|h[The Plague Bearer]|h".into(),
});
let line = vec![
ColorRun {
text: "[The Plague Bearer]".into(),
color: RED,
link: Some(link.clone()),
},
ColorRun {
text: "dsfsdfsd".into(),
color: WHITE,
link: None,
},
];
// The color boundary is NOT a break opportunity: the last word runs from "Bearer]" straight
// through the typed text…
let ws = tokenize_words(&line);
assert_eq!(ws.len(), 3, "three whitespace-delimited break units");
assert_eq!(ws[2].text(), "Bearer]dsfsdfsd");
// …carrying two pieces, each with its own color and link membership.
assert_eq!(ws[2].pieces.len(), 2);
let lines = greedy_pack(ws, 100.0, char_measure);
assert_eq!(lines.len(), 1);
let runs = &lines[0];
assert_eq!(runs.len(), 2, "the color boundary survives the round trip");
assert_eq!(runs[0].text, "[The Plague Bearer]");
assert_eq!(runs[0].color, RED);
assert!(runs[0].link.is_some(), "the name stays clickable");
assert_eq!(runs[1].text, "dsfsdfsd");
assert_eq!(runs[1].color, WHITE, "typed text keeps the base color");
assert!(runs[1].link.is_none(), "typed text is not part of the link");
}
/// The same unit, force-broken: a color boundary inside an over-wide word splits with it, so
/// neither chunk inherits the other's color.
#[test]
fn a_force_break_splits_a_words_pieces_with_it() {
let line = vec![
ColorRun {
text: "[Claw]".into(),
color: RED,
link: None,
},
ColorRun {
text: "x2.".into(),
color: WHITE,
link: None,
},
];
// Width 4: "[Cla" | "w]x2" | "." — the color boundary falls inside the middle chunk.
let lines = greedy_pack(tokenize_words(&line), 4.0, char_measure);
assert_eq!(line_text(&lines[0]), "[Cla");
assert_eq!(lines[0][0].color, RED);
assert_eq!(line_text(&lines[1]), "w]x2");
assert_eq!(lines[1].len(), 2, "the chunk carries both colors");
assert_eq!(lines[1][0].text, "w]");
assert_eq!(lines[1][0].color, RED);
assert_eq!(lines[1][1].text, "x2");
assert_eq!(lines[1][1].color, WHITE);
assert_eq!(line_text(&lines[2]), ".");
assert_eq!(lines[2][0].color, WHITE);
}
#[test]
fn each_wrapped_line_is_an_independent_run_sequence() {
// Justify is applied per line by the emit pass (each render line measured + shifted on its
+217 -113
View File
@@ -2,15 +2,6 @@
// Markup — ported verbatim from probes/text-glyph/src/markup.rs
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// WoW's inline FrameXML text markup: `|cAARRGGBB ... |r` color escapes, `\n` newlines, and `|T...|t`
/// inline textures (stripped — see the module doc's v1 simplifications).
///
/// `|c` is followed by exactly 8 hex digits in **AARRGGBB** order (alpha first — verified against
/// FrameXML usage across the client's own strings, e.g. quality-color prefixes like `|cff1eff00` for
/// uncommon-green). `|r` resets to the string's base color. Unterminated/malformed escapes are left
/// literal rather than dropped, so a typo'd markup string degrades to visible garbage instead of
/// silently eating text — the same posture real FrameXML rendering takes.
///
/// A `|H<link>|h<text>|h` hyperlink a run sits inside: the link payload (`item:2000:0:0:0`,
/// `player:Bob`) and the reconstructed link markup (`|H…|h[Name]|h` — the `OnHyperlinkClick`
/// `arg2`, what shift-click inserts into the edit box). Shared by every run the link's visible
@@ -31,61 +22,64 @@ pub(super) struct ColorRun {
pub(super) link: Option<std::sync::Arc<LinkInfo>>,
}
/// Split `input` into lines (on `\n`) and, within each line, into [`ColorRun`]s by resolving
/// `|cAARRGGBB` / `|r` and `|H…|h…|h` hyperlinks. `|T...|t` inline-texture escapes are stripped
/// entirely (their content is never a color/text run).
/// Resolve `input`'s inline markup into lines of [`ColorRun`]s — the render side of the grammar
/// [`benilla_ui::markup`] owns (decision 1077).
///
/// The grammar itself is **not** this module's: `benilla-ui` holds it because the EditBox's cursor
/// model needs the identical token boundaries, and two copies of an escape grammar is a drift bug
/// waiting to happen. This function only decides what each token *draws*:
///
/// | token | drawn |
/// |---|---|
/// | `\|cAARRGGBB` | nothing — switches the color, at **this string's** alpha (the escape's own `AA` is parsed and thrown away, `0x5c2ab2`; the emitter substitutes `[edi+0x2f]` at `0x5cceb0`) |
/// | `\|r` | nothing — back to `base_color` |
/// | `\n` · `\r` · `\r\n` · `\|n` | a line break |
/// | `\|\|` | one literal `\|` |
/// | `\|H…\|h` / `\|h` | nothing — opens/closes the link every run inside it shares |
/// | anything else | itself |
///
/// **Stated divergence:** the client gates `|n` off for a single-line box (`K & 0x200`, set by
/// `SetMultiLine`'s single-line leg at `0x77a5e2`) while its *cursor* model parses it regardless —
/// a disagreement wow-re records as an anomaly, not a design. We have no flags word here and draw
/// `|n` as a break everywhere; nothing in our FrameXML emits one, and a user cannot type one
/// (`0x77c200` turns a typed `|` into `||`).
pub(super) fn parse_markup(input: &str, base_color: [f32; 4]) -> Vec<Vec<ColorRun>> {
input
.split('\n')
.map(|line| parse_line(line, base_color))
.collect()
}
use benilla_ui::markup::TokenKind as T;
fn parse_line(line: &str, base_color: [f32; 4]) -> Vec<ColorRun> {
let chars: Vec<char> = line.chars().collect();
let mut runs = Vec::new();
let mut lines: Vec<Vec<ColorRun>> = Vec::new();
let mut runs: Vec<ColorRun> = Vec::new();
let mut color = base_color;
let mut cur = String::new();
// The open hyperlink, if any: (payload, visible-text accumulator, indices of runs already
// flushed under it). The Arc is built at the closing `|h` and back-patched onto those runs.
let mut link: Option<(String, String, Vec<usize>)> = None;
let mut i = 0;
while i < chars.len() {
if chars[i] == '|' && i + 1 < chars.len() {
match chars[i + 1] {
'c' | 'C' => {
if let Some(argb) = parse_color_escape(&chars, i) {
flush(&mut runs, &mut cur, color, &mut link);
color = argb;
i += 10; // "|c" + 8 hex digits
continue;
}
}
'r' | 'R' => {
flush(&mut runs, &mut cur, color, &mut link);
color = base_color;
i += 2;
continue;
}
'T' | 't' if link.is_none() => {
if let Some(end) = find_texture_close(&chars, i) {
i = end; // drop everything from |T through the matching |t
continue;
}
}
// `|H<link>|h` opens a hyperlink (no nesting — Blizzard's own strings never nest).
'H' if link.is_none() => {
if let Some((payload, after)) = parse_link_open(&chars, i) {
flush(&mut runs, &mut cur, color, &mut link);
link = Some((payload, String::new(), Vec::new()));
i = after;
continue;
}
}
// The closing `|h` of an open hyperlink.
'h' | 'H' if link.is_some() => {
flush(&mut runs, &mut cur, color, &mut link);
let (payload, visible, idxs) = link.take().expect("open link");
for (_, token) in benilla_ui::markup::tokens(input) {
match token.kind {
T::Color(rgba) => {
flush(&mut runs, &mut cur, color, &mut link);
// At the STRING's alpha, not opaque: the decoder discards the escape's `AA` and the
// emitter patches the FontString's own alpha over it (`0x5cceb0`). A fading chat
// line's item link fades with it.
color = rgba.to_f32_at(base_color[3]);
}
T::ColorReset => {
flush(&mut runs, &mut cur, color, &mut link);
color = base_color;
}
T::LineBreak => {
flush(&mut runs, &mut cur, color, &mut link);
lines.push(std::mem::take(&mut runs));
}
T::EscapedPipe => push_visible('|', &mut cur, &mut link),
T::LinkOpen { payload } => {
flush(&mut runs, &mut cur, color, &mut link);
link = Some((payload.to_string(), String::new(), Vec::new()));
}
T::LinkClose => {
flush(&mut runs, &mut cur, color, &mut link);
// A close with no open is a stray token: nothing to back-patch, nothing drawn.
if let Some((payload, visible, idxs)) = link.take() {
let info = std::sync::Arc::new(LinkInfo {
markup: format!("|H{payload}|h{visible}|h"),
link: payload,
@@ -93,22 +87,24 @@ fn parse_line(line: &str, base_color: [f32; 4]) -> Vec<ColorRun> {
for idx in idxs {
runs[idx].link = Some(info.clone());
}
i += 2;
continue;
}
_ => {}
}
T::Char(c) => push_visible(c, &mut cur, &mut link),
}
if let Some((_, visible, _)) = &mut link {
visible.push(chars[i]);
}
cur.push(chars[i]);
i += 1;
}
// An unterminated link degrades gracefully: its runs stay plain text (no span), matching the
// "degrade to visible garbage" posture — the |H opener was consumed, the text still shows.
// An unterminated link degrades gracefully: its runs stay plain text (no span) — the `|H` was
// consumed, the text still shows.
flush(&mut runs, &mut cur, color, &mut link);
runs
lines.push(runs);
lines
}
/// Accumulate one drawn char into the current run, and into the open link's visible text.
fn push_visible(c: char, cur: &mut String, link: &mut Option<(String, String, Vec<usize>)>) {
if let Some((_, visible, _)) = link {
visible.push(c);
}
cur.push(c);
}
fn flush(
@@ -129,51 +125,47 @@ fn flush(
}
}
/// `chars[i]` is `'|'`, `chars[i+1]` is `'H'` (opening a hyperlink). Scans for the payload
/// delimiter `|h` and returns `(payload, index-just-past-the-delimiter)`. `None` if unterminated
/// (left as literal text by the caller).
fn parse_link_open(chars: &[char], i: usize) -> Option<(String, usize)> {
let mut j = i + 2;
while j + 1 < chars.len() {
if chars[j] == '|' && (chars[j + 1] == 'h' || chars[j + 1] == 'H') {
let payload: String = chars[i + 2..j].iter().collect();
return Some((payload, j + 2));
}
j += 1;
}
None
}
/// `input`'s **drawn** text — what [`parse_markup`] would put on screen, markup resolved — paired
/// with the RAW byte offset of every boundary in it: `bounds.len() == drawn.len() + 1`, and
/// `bounds[k]` is the raw offset of the boundary *before* drawn byte `k`.
///
/// This is the raw↔drawn map the EditBox metrics ride on (decision 1075). The box **stores and
/// edits** the raw string (`|cffa335ee|Hitem:11684:0:0:0|h[Ironfoe]|h|r`) and **draws** only
/// `[Ironfoe]`, so an advance table indexed by raw byte has to charge every escape byte zero width.
/// Measuring the raw string instead put the caret 180 px — twice the drawn text's own width — to the
/// right of the text it was supposed to follow (director, 2026-08-06).
///
/// A **boundary** map rather than a per-byte one, because that is the question the metrics actually
/// ask: a glyph ending at drawn byte `e` files its width at raw `bounds[e]`, which is the raw byte
/// just past that glyph and *before* whatever escape follows it. The per-byte reading (raw offset of
/// drawn byte `e`, i.e. where the NEXT visible char starts) lands past the escape and leaves the
/// caret position immediately after that glyph reading the previous glyph's width — one glyph short,
/// at every escape in the string. It is also the only reading that survives `||`, whose one drawn
/// byte spans two raw ones.
pub(super) fn visible_map(input: &str) -> (String, Vec<usize>) {
use benilla_ui::markup::TokenKind as T;
/// `chars[i]` is `'|'`, `chars[i+1]` is `'c'`/`'C'`. Parses the 8 following hex digits as
/// **AARRGGBB** and returns the normalized `[r, g, b, a]`. `None` if fewer than 8 hex digits follow
/// (malformed escape — left as literal text by the caller).
fn parse_color_escape(chars: &[char], i: usize) -> Option<[f32; 4]> {
let start = i + 2;
let end = start + 8;
if end > chars.len() {
return None;
let mut drawn = String::new();
let mut bounds: Vec<usize> = Vec::new();
let mut last_end = 0usize;
for (at, token) in benilla_ui::markup::tokens(input) {
let c = match token.kind {
T::Char(c) => c,
T::EscapedPipe => '|',
T::LineBreak => '\n',
// Zero-width: no drawn byte, so no boundary of its own.
T::Color(_) | T::ColorReset | T::LinkOpen { .. } | T::LinkClose => continue,
};
// The boundary before this char is wherever the previous drawn token ENDED — not this
// token's raw start, which would sit past any escape in between. A multi-byte char's
// interior boundaries are its own raw bytes (never caret positions, but kept coherent).
bounds.push(last_end);
bounds.extend((1..c.len_utf8()).map(|k| at + k));
last_end = at + token.byte_len;
drawn.push(c);
}
let hex: String = chars[start..end].iter().collect();
let argb = u32::from_str_radix(&hex, 16).ok()?;
let a = ((argb >> 24) & 0xFF) as f32 / 255.0;
let r = ((argb >> 16) & 0xFF) as f32 / 255.0;
let g = ((argb >> 8) & 0xFF) as f32 / 255.0;
let b = (argb & 0xFF) as f32 / 255.0;
Some([r, g, b, a])
}
/// `chars[i]` is `'|'`, `chars[i+1]` is `'T'`/`'t'` (opening an inline-texture escape). Scans forward
/// for the matching `|t`/`|T` close and returns the index just past it. `None` if unterminated (left
/// as literal text by the caller, matching the "degrade to visible garbage" posture above).
fn find_texture_close(chars: &[char], i: usize) -> Option<usize> {
let mut j = i + 2;
while j + 1 < chars.len() {
if chars[j] == '|' && (chars[j + 1] == 't' || chars[j + 1] == 'T') {
return Some(j + 2);
}
j += 1;
}
None
bounds.push(last_end);
(drawn, bounds)
}
#[cfg(test)]
@@ -239,11 +231,14 @@ mod markup_tests {
assert_eq!(lines[1][0].text, "two");
}
/// There is no inline-texture escape in build 5875 — the remap table at `0x5c2b10` sends every
/// `|`-lead but C/H/N/R to the ordinary-character arm (wow-re RF-0087 §1.1). Our renderer used
/// to strip `|T…|t`, a later-expansion feature we had invented; it now draws, like the client's.
#[test]
fn inline_texture_is_stripped() {
fn there_is_no_inline_texture_escape() {
let lines = parse_markup("a|TInterface\\Icons\\Foo:16:16|tb", WHITE);
assert_eq!(lines[0].len(), 1);
assert_eq!(lines[0][0].text, "ab");
assert_eq!(lines[0][0].text, "a|TInterface\\Icons\\Foo:16:16|tb");
}
#[test]
@@ -275,6 +270,115 @@ mod markup_tests {
assert_eq!(first.markup, "|Hplayer:Bob|h[Bob]|h");
}
// ── visible_map: the raw↔drawn boundary map the EditBox metrics ride on (1075/1077) ───────
/// `visible_map`'s invariants, checked together: the drawn text is exactly what `parse_markup`
/// would draw, and the boundary map is monotonic, `drawn.len() + 1` long, and lands only on raw
/// char boundaries.
fn check_map(raw: &str, expect_drawn: &str) -> Vec<usize> {
let (drawn, bounds) = visible_map(raw);
assert_eq!(drawn, expect_drawn, "drawn text of {raw:?}");
let from_runs: String = parse_markup(raw, WHITE)
.iter()
.map(|l| l.iter().map(|r| r.text.as_str()).collect::<String>())
.collect::<Vec<_>>()
.join("\n");
assert_eq!(drawn, from_runs, "visible_map agrees with parse_markup");
assert_eq!(
bounds.len(),
drawn.len() + 1,
"one boundary per drawn byte, plus the end"
);
for w in bounds.windows(2) {
assert!(w[0] <= w[1], "monotonic: {bounds:?}");
}
for &b in &bounds {
assert!(
raw.is_char_boundary(b),
"boundary {b} of {raw:?} is mid-char"
);
}
bounds
}
/// The director's exact case (2026-08-06): a shift-clicked item link in the chat edit box. The
/// end-of-buffer caret must land on the drawn text's full width, so the last boundary is the
/// byte just past the `]` — with `|h|r` still to come — and not the raw length.
#[test]
fn an_item_link_maps_its_drawn_boundaries_onto_the_raw_buffer() {
let raw = "|cffa335ee|Hitem:13984:0:0:0|h[The Plague Bearer]|h|r";
let bounds = check_map(raw, "[The Plague Bearer]");
// The boundary before the first drawn byte is 0 — before the leading escapes, which is
// where the reachable cursor set puts it (`|c`/`|H` absorb forward).
assert_eq!(bounds[0], 0);
// The `]` glyph ends at drawn byte 19; its raw boundary is just past the `]`.
let end = bounds[19];
assert_eq!(&raw[end - 1..end], "]");
assert_eq!(&raw[end..], "|h|r");
}
/// The case the boundary reading exists for: an escape sitting *between* two visible chars. The
/// boundary after `b` must be 2, not 12 — the per-byte reading (where the next visible char
/// begins) lands past the escape and leaves the caret right after `b` reading `a`'s width.
#[test]
fn a_boundary_stops_before_a_following_escape() {
assert_eq!(check_map("ab|cffff0000cd", "abcd"), vec![0, 1, 2, 13, 14]);
}
/// Text typed straight after the link — the buffer the director was looking at. The typed bytes
/// sit past the trailing `|r`, so their advances land after the drawn name, not after 53
/// invisible characters.
#[test]
fn text_after_a_links_reset_maps_past_the_escape() {
let raw = "|cffa335ee|Hitem:13984:0:0:0|h[The Plague Bearer]|h|rds";
let bounds = check_map(raw, "[The Plague Bearer]ds");
assert_eq!(&raw[bounds[19]..], "|h|rds");
assert_eq!(bounds[bounds.len() - 1], raw.len());
}
#[test]
fn visible_map_handles_plain_text_and_newlines() {
assert_eq!(check_map("hello", "hello"), vec![0, 1, 2, 3, 4, 5]);
// A newline draws as its own byte, and the map keeps crossing it.
assert_eq!(
check_map("ab\n|cffff0000cd|r", "ab\ncd"),
vec![0, 1, 2, 3, 14, 15]
);
// A string that draws nothing has its one boundary at the start — there is no glyph for it
// to sit after, and every raw offset forward-fills to x = 0 either way.
assert_eq!(visible_map("|cffff0000|r"), (String::new(), vec![0]));
assert_eq!(visible_map(""), (String::new(), vec![0]));
}
/// The three tokens 1075 could not see, now that the grammar is the engine's (1077): `||` draws
/// ONE `|` out of two raw bytes — the case a per-byte map cannot represent at all — `|n` and
/// `\r\n` are line breaks, and there is no `|T…|t` texture escape in 1.12.1, so it draws
/// literally instead of being swallowed.
#[test]
fn the_tokens_the_engine_grammar_added() {
// `a||b`: the drawn `|` spans raw 1..3, so the boundary after it is 3.
assert_eq!(check_map("a||b", "a|b"), vec![0, 1, 3, 4]);
assert_eq!(parse_markup("a||b", WHITE)[0][0].text, "a|b");
// `|n` and `\r\n` both break the line.
assert_eq!(parse_markup("a|nb", WHITE).len(), 2);
assert_eq!(parse_markup("a\r\nb", WHITE).len(), 2);
assert_eq!(check_map("a\r\nb", "a\nb"), vec![0, 1, 3, 4]);
// No inline-texture escape exists in this build: it draws as text.
let (drawn, _) = visible_map("a|Tfoo|tb");
assert_eq!(drawn, "a|Tfoo|tb");
}
/// A malformed escape is literal text to the draw, so it must be literal to the map too —
/// otherwise the metrics would charge zero width for bytes the user can see.
#[test]
fn a_malformed_escape_stays_visible_in_the_map() {
check_map("a|cffzzb", "a|cffzzb");
// Here the `|H…|h` OPEN is well formed — it is the closing `|h` that never comes, so the
// link degrades (no clickable span) while its visible text still draws. Contrast the three
// cases in `the_tokens_the_engine_grammar_added`, where the OPEN itself degrades.
check_map("|Hitem:1|h[Broken", "[Broken");
}
#[test]
fn unterminated_link_degrades_to_plain_text() {
// No closing |h: the opener is consumed, the text still shows, no span attaches.
@@ -54,15 +54,17 @@ struct ViewCtx<'a> {
}
/// Build one spell's tooltip view (decision 0274 P2) — the verified spell line law's inputs,
/// every string resolved here where the catalogs live: the cost cell (power-typed; rage prints
/// wire-cost ÷ 10; "Next melee" for on-next-swing attributes), the range cell ("N yd range",
/// "N-M yd range" when the row's min is nonzero — the law's `"%d-%d"` fork; "Melee Range" for
/// the melee family — INTERIM text, the proper source is SpellRange.dbc's own display-name
/// column), the cast cell ("Instant"/"Instant cast"/"N sec cast"; None = the law's passive gate,
/// which omits the whole line), the cooldown cell (`max(RecoveryTime, CategoryRecoveryTime)` —
/// law §3.4), the required-item and required-form lines (law §3.6), the reagents line (law
/// §3.8), and the $-substituted description/aura-description (byte-exact formulas,
/// `benilla_formats::substitute`).
/// every string resolved here where the catalogs live: the cost cell (the RESOLVED
/// `power_cost` through the power-type key array, health fallback, `_PER_TIME` composite — law
/// §3.3, 1074; rage prints wire-cost ÷ 10), the range cell ("N yd range", "N-M yd range" when
/// the row's min is nonzero — the law's `"%d-%d"` fork; "Melee Range" for the melee family —
/// INTERIM text, the proper source is SpellRange.dbc's own display-name column), the cast cell
/// (the full `52eb45` ladder: sec/min, the negative-base sentinel, "Next melee"/"Attack
/// speed"/"Channeled", and the mana-keyed Instant fork — law §3.4, 1074; None = the law's
/// passive gate, which omits the whole line), the cooldown cell
/// (`max(RecoveryTime, CategoryRecoveryTime)` — law §3.4), the required-item and required-form
/// lines (law §3.6), the reagents line (law §3.8), and the $-substituted
/// description/aura-description (byte-exact formulas, `benilla_formats::substitute`).
///
/// Line TEXT is composed in English here, as every cell in this builder is; the reference reads
/// its own GlobalStrings templates ("Reagents: ", "Requires %s"). That is this builder's standing
@@ -81,19 +83,42 @@ fn spell_tooltip_view(
lookup: &|id| spells.catalog.get(id),
home_area,
};
// The on-next-swing class reads "Next melee" in the cost cell.
let cost = if d.on_next_swing() {
Some("Next melee".to_string())
} else if d.mana_cost > 0 {
Some(match d.power_type {
1 => format!("{} Rage", d.mana_cost / 10),
3 => format!("{} Energy", d.mana_cost),
_ => format!("{} Mana", d.mana_cost),
})
} else if d.mana_cost_pct > 0 {
Some(format!("{}% of base mana", d.mana_cost_pct))
} else {
None
// The cost cell (law §3.3, the `0x52e8ad` caller): the RESOLVED cost — `GetPowerCost`'s
// number, the same `power_cost` the usable walk compares (0948) — through the power-type key
// array (`0x85416c`: Mana/Rage/Focus/Energy/Happiness) with HEALTH as the out-of-range
// fallback (the `jl`/`cmp 5` fork at `0x52e8fc` — Life Tap's and Bloodrage's −2 → "N Health";
// B192, 1074). Rage displays wire÷10 (the `0x6e7130` per-type divisor), applied to the flat
// and per-second values alike; a `manaPerSecond` column composes the `_PER_TIME` string
// ("11 Health, plus 5 per sec" — Health Funnel), including the cost-0 case. Zero cost with
// zero per-second leaves the cell EMPTY. The ref never prints a percentage — a pct-only cost
// resolves to its flat number like any other, which is why the "% of base mana" line this
// replaces was unfaithful (B152). No store (a DBC-only view) degrades to the flat cost.
// HAPPINESS_COST has no GlobalStrings entry and no 5875 player spell reaches powerType 4;
// the unit word is a dead arm kept for the array's shape.
let resolved_cost = vctx
.store
.map_or(d.mana_cost, |s| crate::ui_action::usable::power_cost(d, s));
let cost = {
let div = if d.power_type == 1 { 10 } else { 1 };
let unit = match d.power_type {
0 => "Mana",
1 => "Rage",
2 => "Focus",
3 => "Energy",
4 => "Happiness",
_ => "Health",
};
if resolved_cost == 0 && d.mana_per_second == 0 {
None
} else if d.mana_per_second > 0 {
Some(format!(
"{} {unit}, plus {} per sec",
resolved_cost / div,
d.mana_per_second / div
))
} else {
Some(format!("{} {unit}", resolved_cost / div))
}
};
let range = spells.ranges.get(d.range_index).and_then(|r| {
if r.is_melee() {
@@ -110,23 +135,39 @@ fn spell_tooltip_view(
}
});
// Law §3.4's own gate — wider than the spellbook's `passive`: a TRADE_SKILL or ATTACK
// Effect[0] omits the line too ([`SpellDisplay::tooltip_omits_cast_line`]).
// Effect[0] omits the line too ([`SpellDisplay::tooltip_omits_cast_line`]). The arm order is
// the byte ladder `0x52eb45-0x52ec90` (1074): a positive time prints sec/min at the 60 s
// threshold; a NEGATIVE SpellCastTimes base is the "Instant cast" sentinel (`52ebce`); at
// zero — next-swing (attr 0x404) → "Next melee", auto-shot (attr 0x2) → "Attack speed",
// channeled (attrEx 0x44) → "Channeled", else the mana fork: "Instant cast" iff
// powerType==0 AND the resolved cost is nonzero (`52ec4b` re-reads `GetPowerCost`'s edi) —
// every other type reads bare "Instant" whatever it costs (Sinister Strike, Life Tap).
let cast_time = if d.tooltip_omits_cast_line() {
None
} else {
let base = spells
.cast_times
.get(d.casting_time_index)
.map(|c| c.base_ms)
.map(|c| c.base_ms as i32)
.unwrap_or(0);
Some(if base == 0 {
if cost.is_none() {
"Instant".to_string()
Some(if base > 0 {
if base >= 60_000 {
format!("{} min cast", trim_secs(f64::from(base) / 60_000.0))
} else {
"Instant cast".to_string()
format!("{} sec cast", trim_secs(f64::from(base) / 1000.0))
}
} else if base < 0 {
"Instant cast".to_string()
} else if d.on_next_swing() {
"Next melee".to_string()
} else if d.tooltip_on_next_ranged() {
"Attack speed".to_string()
} else if d.tooltip_channeled() {
"Channeled".to_string()
} else if d.power_type == 0 && resolved_cost > 0 {
"Instant cast".to_string()
} else {
format!("{} sec cast", trim_secs(f64::from(base) / 1000.0))
"Instant".to_string()
})
};
// The cooldown cell reads BOTH recovery columns (law §3.4: `max([+0x4c],[+0x50])>0` —
@@ -724,259 +765,4 @@ fn drive_mouseover_tooltip(
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui_action::Spells;
/// A view context with no player state — the DBC-only half of the builder (the shape the
/// pre-0616 test used). `sub_classes` is threaded in by the caller when the case needs it.
struct TestCtx {
items: Items,
commands: NetCommands,
_rx: crossbeam_channel::Receiver<crate::net::ClientCommand>,
}
impl TestCtx {
fn new() -> Self {
let (tx, rx) = crossbeam_channel::unbounded();
Self {
items: Items::default(),
commands: NetCommands(tx),
_rx: rx,
}
}
fn ctx<'a>(
&'a mut self,
form: u8,
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
) -> ViewCtx<'a> {
self.ctx_for(form, sub_classes, None)
}
fn ctx_for<'a>(
&'a mut self,
form: u8,
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
store: Option<&'a ObjectStore>,
) -> ViewCtx<'a> {
ViewCtx {
home_area: None,
form,
store,
items: &mut self.items,
commands: &self.commands,
sub_classes,
}
}
}
/// A player descriptor with nothing worn and nothing in the bags — the "owns none of it"
/// pole of both possession tests.
fn empty_player() -> ObjectStore {
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(
22u16, 100u32,
)]))
}
/// The full spell-tooltip view off the REAL 5875 data — Fireball rank 1 (133) end to end:
/// the pinned columns (description 138, cast index 18→1500 ms, duration 30), the token
/// engine's byte formulas, and the view's verified cell shapes. Skips without client data.
#[test]
fn fireball_view_on_real_data() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
forms: benilla_formats::load_shapeshift_forms(&mut chain)
.expect("SpellShapeshiftForm.dbc"),
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("SpellRange.dbc"),
cast_times: benilla_formats::load_spell_cast_times(&mut chain)
.expect("SpellCastTimes.dbc"),
durations: benilla_formats::load_spell_durations(&mut chain)
.expect("SpellDuration.dbc"),
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
};
let mut t = TestCtx::new();
let v = spell_tooltip_view(133, &spells, &mut t.ctx(0, None)).expect("Fireball view");
assert_eq!(v.name, "Fireball");
assert_eq!(v.rank.as_deref(), Some("Rank 1"));
assert_eq!(v.cost.as_deref(), Some("30 Mana"));
assert_eq!(v.range.as_deref(), Some("35 yd range"));
assert_eq!(v.cast_time.as_deref(), Some("1.5 sec cast"));
assert_eq!(
v.cooldown, None,
"Fireball has no recovery in either column"
);
assert_eq!(v.requires_form, None);
assert!(
v.description.starts_with("Hurls a fiery ball that causes"),
"got: {}",
v.description
);
assert!(
v.description.contains(" to ") && v.description.contains("Fire damage"),
"the $s range substituted: {}",
v.description
);
assert!(
!v.description.contains('$'),
"no unsubstituted tokens: {}",
v.description
);
// Charge rank 1 (100) — the director's reference shot, end to end: the dual-bound range
// row (SpellRange 95 = {8, 25}), the CATEGORY-column cooldown (recoveryTime 0 /
// categoryRecoveryTime 15000), and the Stances-mask form line (0x10000 → form 17).
let v = spell_tooltip_view(100, &spells, &mut t.ctx(0, None)).expect("Charge view");
assert_eq!(v.name, "Charge");
assert_eq!(v.rank.as_deref(), Some("Rank 1"));
assert_eq!(v.cost, None, "Charge costs nothing (it generates rage)");
assert_eq!(v.range.as_deref(), Some("8-25 yd range"));
assert_eq!(v.cast_time.as_deref(), Some("Instant"));
assert_eq!(v.cooldown.as_deref(), Some("15 sec cooldown"));
assert_eq!(v.requires_form.as_deref(), Some("Requires Battle Stance"));
assert!(!v.form_met, "form 0 (unshifted) does not satisfy the mask");
assert_eq!(
v.description,
"Charge an enemy, generate 9 rage, and stun it for 1 sec. Cannot be used in combat."
);
let v = spell_tooltip_view(100, &spells, &mut t.ctx(17, None)).expect("Charge view");
assert!(v.form_met, "form 17 = Battle Stance satisfies the mask");
}
/// The three lines the 2026-07-25 reference captures pinned (decision 0620), each against the
/// REAL 5875 data. Skips without client data.
#[test]
fn the_pinned_c6_lines_on_real_data() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
forms: benilla_formats::load_shapeshift_forms(&mut chain)
.expect("SpellShapeshiftForm.dbc"),
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("SpellRange.dbc"),
cast_times: benilla_formats::load_spell_cast_times(&mut chain)
.expect("SpellCastTimes.dbc"),
durations: benilla_formats::load_spell_durations(&mut chain)
.expect("SpellDuration.dbc"),
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
};
let subs = benilla_formats::load_item_sub_classes(&mut chain).expect("ItemSubClass.dbc");
let mut t = TestCtx::new();
let store = empty_player();
// 1 · The wand Shoot (5019, class 2 / submask bit 19) — "Requires Wands", red with no
// wand worn. The same row feeds the cast-fail line's SINGULAR "Wand" (see `cast_fail`).
assert_eq!(subs.name(2, 19), Some("Wands"), "the verbose plural");
assert_eq!(subs.display_name(2, 19), Some("Wand"), "the singular");
let v = spell_tooltip_view(5019, &spells, &mut t.ctx_for(0, Some(&subs), Some(&store)))
.expect("Shoot view");
assert_eq!(v.requires_item.as_deref(), Some("Requires Wands"));
assert!(!v.item_met, "nothing worn satisfies class 2 / bit 19 → red");
// A multi-bit mask is named by ItemSubClassMask.dbc, not skipped (law §3-EQUIPITEM — we
// printed nothing here until `0x6e2380` was carved): Parry's 0x2a5f3 is exactly the eleven
// melee subclasses, which that table names in one word.
let parry = spells.catalog.get(3127).expect("Parry 3127");
assert!(parry.equipped_item_subclass_mask.count_ones() > 1);
let v = spell_tooltip_view(3127, &spells, &mut t.ctx_for(0, Some(&subs), Some(&store)))
.expect("Parry view");
assert_eq!(v.requires_item.as_deref(), Some("Requires Melee Weapon"));
// 2 · Attack (6603) — `Effect[0] == 78` omits the cast|cooldown line WHOLE, even though
// `Attributes & 0x40` is clear. Before the §3.4 gate widened, this read "Instant".
let d = spells.catalog.get(6603).expect("Attack 6603");
assert_eq!(d.effect_1, 78, "SPELL_EFFECT_ATTACK");
assert!(!d.passive, "6603 carries Attributes 0x10, not 0x40");
let v = spell_tooltip_view(6603, &spells, &mut t.ctx(0, None)).expect("Attack view");
assert_eq!(v.cast_time, None, "the law's Effect[0] gate");
// …and the chance line the same Effect[0] selects (law line 10 / §3-CHANCE). ATTACK
// BYPASSES the passive gate, which is the whole reason a non-passive Attack shows a crit
// line at all. No descriptor = no line; the percentages are already percents on the wire.
assert_eq!(v.chance, None, "no player streamed yet");
let rated = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(22u16, 100u32),
(1109u16, 2.62f32.to_bits()), // PLAYER_CRIT_PERCENTAGE
(1107u16, 5.5f32.to_bits()), // PLAYER_DODGE_PERCENTAGE
]));
let v = spell_tooltip_view(6603, &spells, &mut t.ctx_for(0, None, Some(&rated)))
.expect("Attack view");
assert_eq!(v.chance.as_deref(), Some("2.62% chance to crit"));
// Dodge (81) is passive and reads its own field.
let dodge = spells.catalog.get(81).expect("Dodge 81");
assert_eq!(dodge.effect_1, 20, "SPELL_EFFECT_DODGE");
assert!(dodge.passive, "81 carries Attributes 0x40");
let v = spell_tooltip_view(81, &spells, &mut t.ctx_for(0, None, Some(&rated)))
.expect("Dodge view");
assert_eq!(v.chance.as_deref(), Some("5.50% chance to dodge"));
// A spell naming none of the four effects has no line at all.
let v = spell_tooltip_view(133, &spells, &mut t.ctx_for(0, None, Some(&rated)))
.expect("Fireball view");
assert_eq!(v.chance, None);
// 3 · Slow Fall (130) — "Reagents: Light Feather", inline-red while unowned (no store =
// owns nothing). The name rides the ask-once item cache, seeded here as the server would.
let d = spells.catalog.get(130).expect("Slow Fall 130");
assert_eq!(d.reagents[0], (17056, 1), "Light Feather ×1");
let v = spell_tooltip_view(130, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Slow Fall view");
assert_eq!(
v.reagents, None,
"the template hasn't landed: the line waits rather than printing an id"
);
t.items
.insert_template(17056, Some(crate::items::test_template("Light Feather")));
let v = spell_tooltip_view(130, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Slow Fall view");
assert_eq!(
v.reagents.as_deref(),
Some("Reagents: |cffff2020Light Feather|r"),
"count 1 prints no (N); unowned wraps in the builder's inline red"
);
}
/// The "Locked" line's colour law (decision 0770) — the director's report: a door they held
/// the key for read RED, where the reference reads green.
///
/// The builder's own shape is "red unless the resolver found an opener", and *every* kind of
/// opener lands on the same green — so the mapping is a one-way test on `Unmet`, not a
/// per-arm table. A flag-locked object with no `Lock.dbc` row is the reference's
/// no-requirement arm and is green too: the flag alone never means "you can't".
#[test]
fn the_locked_line_greens_when_the_lock_can_be_opened() {
use crate::target::lock::LockOutcome;
// The report: the Scarlet Key in hand, the Armory Door in front of you.
assert_eq!(
locked_line_tint(Some(LockOutcome::OpenByKey(7146))),
TooltipTint::LockOpen,
"holding the key must read green"
);
// The same door without the key — unchanged, and the control for the fix.
assert_eq!(
locked_line_tint(Some(LockOutcome::Unmet)),
TooltipTint::Red,
"no key still reads red"
);
// A skill opener you know: green. (The reference would ramp this by margin; green is that
// ramp's comfortable rung — see `locked_line_tint`'s note.)
assert_eq!(
locked_line_tint(Some(LockOutcome::OpenBySpell(2575))),
TooltipTint::LockOpen
);
// A lock row that imposes nothing, and a flag-locked object with no row at all.
assert_eq!(
locked_line_tint(Some(LockOutcome::Unlocked)),
TooltipTint::LockOpen
);
assert_eq!(locked_line_tint(None), TooltipTint::LockOpen);
}
}
mod tests;
+346
View File
@@ -0,0 +1,346 @@
//! The spell-view cell tests, against the REAL 5875 data (the module doc's line laws; moved
//! out of `mod.rs` whole at the 1000-line seam — same tests, file module).
use super::*;
use crate::ui_action::Spells;
/// A view context with no player state — the DBC-only half of the builder (the shape the
/// pre-0616 test used). `sub_classes` is threaded in by the caller when the case needs it.
struct TestCtx {
items: Items,
commands: NetCommands,
_rx: crossbeam_channel::Receiver<crate::net::ClientCommand>,
}
impl TestCtx {
fn new() -> Self {
let (tx, rx) = crossbeam_channel::unbounded();
Self {
items: Items::default(),
commands: NetCommands(tx),
_rx: rx,
}
}
fn ctx<'a>(
&'a mut self,
form: u8,
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
) -> ViewCtx<'a> {
self.ctx_for(form, sub_classes, None)
}
fn ctx_for<'a>(
&'a mut self,
form: u8,
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
store: Option<&'a ObjectStore>,
) -> ViewCtx<'a> {
ViewCtx {
home_area: None,
form,
store,
items: &mut self.items,
commands: &self.commands,
sub_classes,
}
}
}
/// A player descriptor with nothing worn and nothing in the bags — the "owns none of it"
/// pole of both possession tests.
fn empty_player() -> ObjectStore {
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(
22u16, 100u32,
)]))
}
/// The full spell-tooltip view off the REAL 5875 data — Fireball rank 1 (133) end to end:
/// the pinned columns (description 138, cast index 18→1500 ms, duration 30), the token
/// engine's byte formulas, and the view's verified cell shapes. Skips without client data.
#[test]
fn fireball_view_on_real_data() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
forms: benilla_formats::load_shapeshift_forms(&mut chain).expect("SpellShapeshiftForm.dbc"),
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("SpellRange.dbc"),
cast_times: benilla_formats::load_spell_cast_times(&mut chain).expect("SpellCastTimes.dbc"),
durations: benilla_formats::load_spell_durations(&mut chain).expect("SpellDuration.dbc"),
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
};
let mut t = TestCtx::new();
let v = spell_tooltip_view(133, &spells, &mut t.ctx(0, None)).expect("Fireball view");
assert_eq!(v.name, "Fireball");
assert_eq!(v.rank.as_deref(), Some("Rank 1"));
assert_eq!(v.cost.as_deref(), Some("30 Mana"));
assert_eq!(v.range.as_deref(), Some("35 yd range"));
assert_eq!(v.cast_time.as_deref(), Some("1.5 sec cast"));
assert_eq!(
v.cooldown, None,
"Fireball has no recovery in either column"
);
assert_eq!(v.requires_form, None);
assert!(
v.description.starts_with("Hurls a fiery ball that causes"),
"got: {}",
v.description
);
assert!(
v.description.contains(" to ") && v.description.contains("Fire damage"),
"the $s range substituted: {}",
v.description
);
assert!(
!v.description.contains('$'),
"no unsubstituted tokens: {}",
v.description
);
// Charge rank 1 (100) — the director's reference shot, end to end: the dual-bound range
// row (SpellRange 95 = {8, 25}), the CATEGORY-column cooldown (recoveryTime 0 /
// categoryRecoveryTime 15000), and the Stances-mask form line (0x10000 → form 17).
let v = spell_tooltip_view(100, &spells, &mut t.ctx(0, None)).expect("Charge view");
assert_eq!(v.name, "Charge");
assert_eq!(v.rank.as_deref(), Some("Rank 1"));
assert_eq!(v.cost, None, "Charge costs nothing (it generates rage)");
assert_eq!(v.range.as_deref(), Some("8-25 yd range"));
assert_eq!(v.cast_time.as_deref(), Some("Instant"));
assert_eq!(v.cooldown.as_deref(), Some("15 sec cooldown"));
assert_eq!(v.requires_form.as_deref(), Some("Requires Battle Stance"));
assert!(!v.form_met, "form 0 (unshifted) does not satisfy the mask");
assert_eq!(
v.description,
"Charge an enemy, generate 9 rage, and stun it for 1 sec. Cannot be used in combat."
);
let v = spell_tooltip_view(100, &spells, &mut t.ctx(17, None)).expect("Charge view");
assert!(v.form_met, "form 17 = Battle Stance satisfies the mask");
}
/// The cost and cast cells' full law on the REAL 5875 data (decision 1074, B192): the health
/// fallback and pct resolution (Bloodrage), the empty Life Tap cell (1.12 carries no cost
/// columns for it — the trade lives in the description), the `_PER_TIME` composite (Health
/// Funnel), the resolved pct-of-base-mana with no percentage line (Judgement — the B152
/// reframe), and the cast ladder's attr arms (Next melee / Attack speed / Channeled) with
/// the mana-keyed Instant fork. Skips without client data.
#[test]
fn cost_and_cast_cells_on_real_data() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
forms: benilla_formats::load_shapeshift_forms(&mut chain).expect("SpellShapeshiftForm.dbc"),
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("SpellRange.dbc"),
cast_times: benilla_formats::load_spell_cast_times(&mut chain).expect("SpellCastTimes.dbc"),
durations: benilla_formats::load_spell_durations(&mut chain).expect("SpellDuration.dbc"),
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
};
let mut t = TestCtx::new();
// A level-60 warrior-shaped store: max health 4000, base mana 1000 (field indices are
// the protocol crate's: health 22, maxhealth 28, level 34, base mana 162).
let store = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(22u16, 3500u32),
(28, 4000),
(34, 60),
(162, 1000),
]));
// Bloodrage (2687): pct-ONLY health cost — 20% of MAX health resolves to a flat number
// through the health fallback; never a percentage line. Instant on a non-mana type reads
// bare "Instant" whatever it costs.
let v = spell_tooltip_view(2687, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Bloodrage view");
assert_eq!(v.cost.as_deref(), Some("800 Health"), "20% of 4000");
assert_eq!(v.cast_time.as_deref(), Some("Instant"));
// Life Tap (1454): the 5875 file carries NO cost columns for any rank — the cell is
// empty, exactly the reference render (the printed health cost is 2.x's change).
let v = spell_tooltip_view(1454, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Life Tap view");
assert_eq!(v.cost, None, "1.12 Life Tap has no cost cell");
assert_eq!(
v.cast_time.as_deref(),
Some("Instant"),
"health type, not mana"
);
// Health Funnel (755): the `_PER_TIME` composite in the health lane, and the channeled
// cast cell.
let v = spell_tooltip_view(755, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Health Funnel view");
assert_eq!(v.cost.as_deref(), Some("11 Health, plus 5 per sec"));
assert_eq!(v.cast_time.as_deref(), Some("Channeled"));
// Judgement (20271): pct-of-base-mana resolves to its flat number — the line B152
// reported as "% of base mana" never exists on the reference. DBC-only (no store)
// degrades to the flat cost: none here.
let v = spell_tooltip_view(20271, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Judgement view");
assert_eq!(v.cost.as_deref(), Some("60 Mana"), "6% of base mana 1000");
let v = spell_tooltip_view(20271, &spells, &mut t.ctx(0, None)).expect("Judgement view");
assert_eq!(v.cost, None, "a DBC-only view cannot resolve a pct cost");
// Heroic Strike (78): the cost cell keeps its rage (wire 150 ÷ 10) and "Next melee"
// moves to the CAST cell where the ref's ladder puts it.
let v = spell_tooltip_view(78, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Heroic Strike view");
assert_eq!(v.cost.as_deref(), Some("15 Rage"));
assert_eq!(v.cast_time.as_deref(), Some("Next melee"));
// Throw (2764) and Auto Shot (75): the ranged bit reads "Attack speed" — and for Throw
// the bit is ALONE (`Attributes & 0x2`, not the auto-repeat or-pair). Melee Attack
// (6603) is the §3.4 skip: Effect[0] == ATTACK omits the whole line.
let v = spell_tooltip_view(2764, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Throw view");
assert_eq!(v.cast_time.as_deref(), Some("Attack speed"));
let v = spell_tooltip_view(75, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Auto Shot view");
assert_eq!(v.cast_time.as_deref(), Some("Attack speed"));
let v = spell_tooltip_view(6603, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Attack view");
assert_eq!(v.cast_time, None, "ATTACK Effect[0] omits the line");
// Mind Flay (15407): a channeled MANA spell — the cost cell and the channeled cell
// together.
let v = spell_tooltip_view(15407, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Mind Flay view");
assert_eq!(v.cost.as_deref(), Some("45 Mana"));
assert_eq!(v.cast_time.as_deref(), Some("Channeled"));
}
/// The three lines the 2026-07-25 reference captures pinned (decision 0620), each against the
/// REAL 5875 data. Skips without client data.
#[test]
fn the_pinned_c6_lines_on_real_data() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
forms: benilla_formats::load_shapeshift_forms(&mut chain).expect("SpellShapeshiftForm.dbc"),
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("SpellRange.dbc"),
cast_times: benilla_formats::load_spell_cast_times(&mut chain).expect("SpellCastTimes.dbc"),
durations: benilla_formats::load_spell_durations(&mut chain).expect("SpellDuration.dbc"),
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
};
let subs = benilla_formats::load_item_sub_classes(&mut chain).expect("ItemSubClass.dbc");
let mut t = TestCtx::new();
let store = empty_player();
// 1 · The wand Shoot (5019, class 2 / submask bit 19) — "Requires Wands", red with no
// wand worn. The same row feeds the cast-fail line's SINGULAR "Wand" (see `cast_fail`).
assert_eq!(subs.name(2, 19), Some("Wands"), "the verbose plural");
assert_eq!(subs.display_name(2, 19), Some("Wand"), "the singular");
let v = spell_tooltip_view(5019, &spells, &mut t.ctx_for(0, Some(&subs), Some(&store)))
.expect("Shoot view");
assert_eq!(v.requires_item.as_deref(), Some("Requires Wands"));
assert!(!v.item_met, "nothing worn satisfies class 2 / bit 19 → red");
// A multi-bit mask is named by ItemSubClassMask.dbc, not skipped (law §3-EQUIPITEM — we
// printed nothing here until `0x6e2380` was carved): Parry's 0x2a5f3 is exactly the eleven
// melee subclasses, which that table names in one word.
let parry = spells.catalog.get(3127).expect("Parry 3127");
assert!(parry.equipped_item_subclass_mask.count_ones() > 1);
let v = spell_tooltip_view(3127, &spells, &mut t.ctx_for(0, Some(&subs), Some(&store)))
.expect("Parry view");
assert_eq!(v.requires_item.as_deref(), Some("Requires Melee Weapon"));
// 2 · Attack (6603) — `Effect[0] == 78` omits the cast|cooldown line WHOLE, even though
// `Attributes & 0x40` is clear. Before the §3.4 gate widened, this read "Instant".
let d = spells.catalog.get(6603).expect("Attack 6603");
assert_eq!(d.effect_1, 78, "SPELL_EFFECT_ATTACK");
assert!(!d.passive, "6603 carries Attributes 0x10, not 0x40");
let v = spell_tooltip_view(6603, &spells, &mut t.ctx(0, None)).expect("Attack view");
assert_eq!(v.cast_time, None, "the law's Effect[0] gate");
// …and the chance line the same Effect[0] selects (law line 10 / §3-CHANCE). ATTACK
// BYPASSES the passive gate, which is the whole reason a non-passive Attack shows a crit
// line at all. No descriptor = no line; the percentages are already percents on the wire.
assert_eq!(v.chance, None, "no player streamed yet");
let rated = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(22u16, 100u32),
(1109u16, 2.62f32.to_bits()), // PLAYER_CRIT_PERCENTAGE
(1107u16, 5.5f32.to_bits()), // PLAYER_DODGE_PERCENTAGE
]));
let v = spell_tooltip_view(6603, &spells, &mut t.ctx_for(0, None, Some(&rated)))
.expect("Attack view");
assert_eq!(v.chance.as_deref(), Some("2.62% chance to crit"));
// Dodge (81) is passive and reads its own field.
let dodge = spells.catalog.get(81).expect("Dodge 81");
assert_eq!(dodge.effect_1, 20, "SPELL_EFFECT_DODGE");
assert!(dodge.passive, "81 carries Attributes 0x40");
let v =
spell_tooltip_view(81, &spells, &mut t.ctx_for(0, None, Some(&rated))).expect("Dodge view");
assert_eq!(v.chance.as_deref(), Some("5.50% chance to dodge"));
// A spell naming none of the four effects has no line at all.
let v = spell_tooltip_view(133, &spells, &mut t.ctx_for(0, None, Some(&rated)))
.expect("Fireball view");
assert_eq!(v.chance, None);
// 3 · Slow Fall (130) — "Reagents: Light Feather", inline-red while unowned (no store =
// owns nothing). The name rides the ask-once item cache, seeded here as the server would.
let d = spells.catalog.get(130).expect("Slow Fall 130");
assert_eq!(d.reagents[0], (17056, 1), "Light Feather ×1");
let v = spell_tooltip_view(130, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Slow Fall view");
assert_eq!(
v.reagents, None,
"the template hasn't landed: the line waits rather than printing an id"
);
t.items
.insert_template(17056, Some(crate::items::test_template("Light Feather")));
let v = spell_tooltip_view(130, &spells, &mut t.ctx_for(0, None, Some(&store)))
.expect("Slow Fall view");
assert_eq!(
v.reagents.as_deref(),
Some("Reagents: |cffff2020Light Feather|r"),
"count 1 prints no (N); unowned wraps in the builder's inline red"
);
}
/// The "Locked" line's colour law (decision 0770) — the director's report: a door they held
/// the key for read RED, where the reference reads green.
///
/// The builder's own shape is "red unless the resolver found an opener", and *every* kind of
/// opener lands on the same green — so the mapping is a one-way test on `Unmet`, not a
/// per-arm table. A flag-locked object with no `Lock.dbc` row is the reference's
/// no-requirement arm and is green too: the flag alone never means "you can't".
#[test]
fn the_locked_line_greens_when_the_lock_can_be_opened() {
use crate::target::lock::LockOutcome;
// The report: the Scarlet Key in hand, the Armory Door in front of you.
assert_eq!(
locked_line_tint(Some(LockOutcome::OpenByKey(7146))),
TooltipTint::LockOpen,
"holding the key must read green"
);
// The same door without the key — unchanged, and the control for the fix.
assert_eq!(
locked_line_tint(Some(LockOutcome::Unmet)),
TooltipTint::Red,
"no key still reads red"
);
// A skill opener you know: green. (The reference would ramp this by margin; green is that
// ramp's comfortable rung — see `locked_line_tint`'s note.)
assert_eq!(
locked_line_tint(Some(LockOutcome::OpenBySpell(2575))),
TooltipTint::LockOpen
);
// A lock row that imposes nothing, and a flag-locked object with no row at all.
assert_eq!(
locked_line_tint(Some(LockOutcome::Unlocked)),
TooltipTint::LockOpen
);
assert_eq!(locked_line_tint(None), TooltipTint::LockOpen);
}
@@ -1013,3 +1013,106 @@ fn gcd_wildcard_and_shape_corners_hold_on_the_real_data() {
(133, 0)
);
}
/// The cost columns on the REAL 5875 data (decision 1074, B192): the health power type
/// (−2 as `0xFFFFFFFE`), Bloodrage's pct-only shape, Health Funnel's `manaPerSecond`, the cast
/// cell's attr rows, and the verified NEGATIVE that keeps columns 33/35 unparsed. Skips without
/// client data.
#[test]
fn real_spell_catalog_cost_columns() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = crate::load_spell_catalog(&mut chain).expect("Spell.dbc");
// Life Tap, EVERY rank: health type and NO cost columns at all — the client's own file,
// against the folk memory of a printed health cost (that is 2.x's change; 1.12 carries the
// trade in the description text). The cost cell stays EMPTY, which is the reference render.
for id in [1454u32, 1455, 1456, 11687, 11688, 11689] {
let d = cat.get(id).unwrap();
assert_eq!(
(
d.power_type,
d.mana_cost,
d.mana_cost_pct,
d.mana_per_second
),
(0xFFFF_FFFE, 0, 0, 0),
"Life Tap {id}"
);
}
// Bloodrage: pct-ONLY health cost — the resolved-cost law's health-pool lane (B192).
let bloodrage = cat.get(2687).unwrap();
assert_eq!(
(
bloodrage.power_type,
bloodrage.mana_cost,
bloodrage.mana_cost_pct
),
(0xFFFF_FFFE, 0, 20)
);
// Health Funnel: flat health + per-second — the `_PER_TIME` composite's live customer — and
// channeled, so its cast cell reads "Channeled".
let funnel = cat.get(755).unwrap();
assert_eq!(
(funnel.power_type, funnel.mana_cost, funnel.mana_per_second),
(0xFFFF_FFFE, 11, 5)
);
assert!(funnel.tooltip_channeled());
// The cast cell's attr arms at their pinned rows: Heroic Strike (next melee, rage 150 wire =
// "15 Rage" displayed), Auto Shot and Throw (the ranged bit ALONE — Throw is not
// auto-repeat and still reads "Attack speed"), Mind Flay (channeled mana), Judgement
// (pct-only mana — the resolved flat number, never a percentage line).
let hs = cat.get(78).unwrap();
assert_eq!((hs.power_type, hs.mana_cost), (1, 150));
assert!(hs.on_next_swing());
assert!(cat.get(75).unwrap().tooltip_on_next_ranged(), "Auto Shot");
assert!(cat.get(2764).unwrap().tooltip_on_next_ranged(), "Throw");
let mf = cat.get(15407).unwrap();
assert!(mf.tooltip_channeled());
assert_eq!((mf.power_type, mf.mana_cost), (0, 45));
let judgement = cat.get(20271).unwrap();
assert_eq!(
(
judgement.power_type,
judgement.mana_cost,
judgement.mana_cost_pct
),
(0, 0, 6)
);
// The column scan, both verdicts pinned: `manaPerSecondPerLevel` (35) is all-zero across
// the whole file — the verified negative that keeps it unparsed — while `manaCostPerlevel`
// (33) is real: exactly 72 nonzero rows, all creature-cast spells (Dark Offering's is even
// in the health lane), which is why the per-level term is modeled in `power_cost` but
// dormant for player tooltips. (1074)
let bytes = chain.read_file(super::SPELL).expect("reading Spell.dbc");
let rs = super::parse(&bytes, super::spell_schema(), "Spell.dbc").expect("Spell.dbc");
let mut per_level_rows = 0u32;
for r in rs.records() {
let id = super::u32_at(r, 0).unwrap_or(0);
if super::u32_at(r, 33).unwrap_or(0) > 0 {
per_level_rows += 1;
}
assert_eq!(
super::u32_at(r, 35).unwrap_or(0),
0,
"manaPerSecondPerLevel on {id}"
);
}
assert_eq!(per_level_rows, 72, "the manaCostPerlevel population");
let dark_offering = cat.get(7154).unwrap();
assert_eq!(
(
dark_offering.power_type,
dark_offering.mana_cost,
dark_offering.mana_cost_per_level,
dark_offering.spell_level
),
(0xFFFF_FFFE, 180, 9, 24),
"Dark Offering: the per-level term's health-lane row"
);
}
+30 -1
View File
@@ -102,13 +102,25 @@ pub struct SpellDisplay {
pub start_recovery_category: u32,
/// `StartRecoveryTime` ms (column 158) — the GCD duration (1500 for ordinary spells; 0 = no GCD).
pub start_recovery_ms: u32,
/// `powerType` (column 31) — 0 mana, 1 rage, 3 energy (vmangos `Powers`).
/// `powerType` (column 31) — 0 mana, 1 rage, 3 energy (vmangos `Powers`); **health is the
/// signed −2 stored as `0xFFFFFFFE`** (Life Tap, Bloodrage, Health Funnel), which is why every
/// consumer treats "negative or ≥ 5" as the health lane (the ref's own fallback fork, 1074).
pub power_type: u32,
/// `manaCost` (column 32) — the flat cast cost in `power_type`'s unit.
pub mana_cost: u32,
/// `ManaCostPercentage` (column 156) — percent of base mana added to the flat cost (Judgement
/// and kin); 0 for most spells.
pub mana_cost_pct: u32,
/// `manaCostPerlevel` (column 33) — the per-level cost term (`0x6e31b0`'s
/// `(level − spellLevel) · perLevel`; 1074). 72 nonzero rows in the 5875 file, every one a
/// creature-cast spell (Dark Offering's health lane among them) — dormant for player
/// tooltips, modeled because the law and the data both carry it.
pub mana_cost_per_level: u32,
/// `manaPerSecond` (column 34) — the per-second maintenance component; the tooltip cost
/// cell's `_PER_TIME` composite ("11 Health, plus 5 per sec" — Health Funnel; 1074). The
/// sibling `manaPerSecondPerLevel` (35) is all-zero across the whole 5875 file
/// ([`catalog_tests`]'s column scan — a verified negative) and stays unparsed.
pub mana_per_second: u32,
/// `rangeIndex` (column 36) — the `SpellRange.dbc` row ([`SpellRangeCatalog`]).
pub range_index: u32,
/// `Targets` (column 13, [`COL_TARGETS`]) — the `TARGET_FLAG_*` seed mask of the cast-arm's
@@ -263,6 +275,8 @@ impl Default for SpellDisplay {
power_type: 0,
mana_cost: 0,
mana_cost_pct: 0,
mana_cost_per_level: 0,
mana_per_second: 0,
range_index: 0,
targets: 0,
implicit_target_a1: 0,
@@ -509,6 +523,21 @@ impl SpellDisplay {
self.attributes & ATTR_ON_NEXT_SWING != 0
}
/// The tooltip cast cell's "Attack speed" arm — `Attributes & 0x2` ALONE (the byte test at
/// `0x52ec1c`: `test al,0x2`; 1074), NOT [`Self::ranged_attack`]'s or-pair: Throw carries
/// only `0x2` and still reads "Attack speed" in its cell.
pub fn tooltip_on_next_ranged(&self) -> bool {
self.attributes & ATTR_RANGED != 0
}
/// The tooltip cast cell's "Channeled" arm — `AttributesEx & 0x44` (`SPELL_ATTR_EX_CHANNELED`
/// both variants), the byte test at `0x52ec27` (`test [rec+0x1c],0x44`; wow-re
/// `tooltip-content-law.md` §3.4, folded as 1074). Distinct from
/// [`Self::channel_interrupt_flags`], which is the *running* channel's break mask.
pub fn tooltip_channeled(&self) -> bool {
self.attributes_ex & ATTR_EX_CHANNELED != 0
}
/// Whether *casting* this spell turns on the melee auto-attack **at the send** —
/// `Spell_C::TryCast`'s post-send tail (`6e51b5`), byte-verified whole by the 2026-07-14
/// wow-re §5 (`combat-feel-law.md` @ c445713b): fires the attack entry `0x6131a0` iff the
+13
View File
@@ -173,6 +173,14 @@ const COL_CHANNEL_INTERRUPT_FLAGS: usize = 23;
const COL_POWER_TYPE: usize = 31;
const COL_MANA_COST: usize = 32;
const COL_MANA_COST_PCT: usize = 156;
/// `manaCostPerlevel` (33) / `manaPerSecond` (34) — the vmangos field order between `manaCost`
/// 32 and `manaPerSecondPerLevel` 35. The per-level column is `0x6e31b0`'s
/// `(level − spellLevel) · perLevel` term (72 nonzero rows, all creature spells); the
/// per-second column is the tooltip's `_PER_TIME` composite (Health Funnel 755 reads 5).
/// Column 35 is all-zero across the whole 5875 file (the catalog test's scan) and stays
/// unparsed. (1074)
const COL_MANA_COST_PER_LEVEL: usize = 33;
const COL_MANA_PER_SECOND: usize = 34;
/// `rangeIndex` (`SpellRec+0x90`, `0x90/4 == 36`) — the `SpellRange.dbc` row the byte-verified
/// `GetMinMaxRange 0x6e3480` resolves (wow-re `wave-cooldown.md`). Same empirical pin (Auto
/// Shot/Aimed Shot 114, Throw 74, Charge 95, Fireball 35).
@@ -393,6 +401,9 @@ const ATTR_ON_NEXT_SWING: u32 = 0x404;
/// `combat-feel-law.md` @ c445713b). Rend/Sunder Armor/Slam/Sinister Strike carry it; Heroic
/// Strike and Charge do not.
const ATTR_EX_INITIATES_COMBAT: u32 = 0x200;
/// `AttributesEx` bits 0x4|0x40 — the two CHANNELED variants, tested as one mask by the tooltip's
/// cast cell (`0x52ec27`: `test [rec+0x1c],0x44` → "Channeled"; 1074).
const ATTR_EX_CHANNELED: u32 = 0x44;
/// `AttributesEx2` bit `0x100000` — vmangos `SPELL_ATTR_EX2_INITIATE_COMBAT_POST_CAST` ("Client
/// will send CMSG_ATTACK_SWING after SMSG_SPELL_GO"). The §5-verified send-tail predicate
/// EXCLUDES it (`[ebp-2] = 0x6e5200 && Ex2-bit20 CLEAR`): a bit20 spell defers its attack-start
@@ -652,6 +663,8 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
power_type: u32_at(r, COL_POWER_TYPE).unwrap_or(0),
mana_cost: u32_at(r, COL_MANA_COST).unwrap_or(0),
mana_cost_pct: u32_at(r, COL_MANA_COST_PCT).unwrap_or(0),
mana_cost_per_level: u32_at(r, COL_MANA_COST_PER_LEVEL).unwrap_or(0),
mana_per_second: u32_at(r, COL_MANA_PER_SECOND).unwrap_or(0),
range_index: u32_at(r, COL_RANGE_INDEX).unwrap_or(0),
targets: u32_at(r, COL_TARGETS).unwrap_or(0),
implicit_target_a1: u32_at(r, COL_IMPLICIT_TARGET_A1).unwrap_or(0),
+4 -1
View File
@@ -13,11 +13,14 @@
//! [`script`] — the engine-free Lua host (mlua 5.1) binding the arena/layout/order model to the
//! FrameScript object model + WoW stdlib so FrameXML/addon Lua runs against it (decision 0068);
//! [`loader`] — the FrameXML loader that joins the two, walking a parsed document to materialize live
//! frames in a running [`script::UiScript`] by driving the object model exactly as an addon does.
//! frames in a running [`script::UiScript`] by driving the object model exactly as an addon does;
//! [`markup`] — the single owner of WoW's inline-markup grammar (`|cAARRGGBB`, `|H…|h…|h`, `|n`,
//! `||`): the client's token decoder and the per-byte class map its cursor law is built on.
pub mod framexml;
pub mod layout;
pub mod loader;
pub mod markup;
pub mod order;
pub mod script;
pub mod toc;
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -65,7 +65,12 @@ pub(super) fn char_input(lua: &Lua, text: &str) -> bool {
highlight_text(lua, h, 0, -1);
} else if text.chars().any(|c| (c as u32) >= 0x20) {
// A printable char (or string); pure control input is consumed but not inserted.
insert(lua, h, text, true);
//
// A typed `|` goes in as `||` — OnChar `0x77c200` pushes the literal at `0x879cac`, which
// is `"||"` (decision 1077). That is why real markup can only enter a box through
// `SetText`, `Insert`, a paste or the C++ link-insert path: you cannot type an escape, and
// the doubled form draws as one `|` and counts as one letter.
insert(lua, h, &text.replace('|', "||"), true);
}
true
}
@@ -196,11 +196,15 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
Ok((ins[0], ins[1], ins[2], ins[3]))
})?,
)?;
// GetNumLetters: the CHARACTER count (not bytes) — `0x7992c0` walks the class array.
// GetNumLetters: the LETTER count — `0x7992c0` walks the class array (`0x77bc80`), which
// counts classes 2, 3 and 6 only, so escapes are free and a 43-byte item link reports 9
// (decision 1077). Not bytes, and not chars either.
m.set(
"GetNumLetters",
lua.create_function(|lua, this: Table| {
with_editbox(lua, &this, |eb| eb.text.chars().count() as i64)
with_editbox(lua, &this, |eb| {
crate::markup::ClassMap::new(&eb.text).num_letters() as i64
})
})?,
)?;
@@ -968,3 +968,130 @@ fn caret_blinks_on_tick_and_resets_on_edit() {
answer_advances(&mut s);
assert!(s.focused_editbox_text_ui().unwrap().caret_on);
}
// ── A hyperlink is ONE keypress (RF-0087 §6, decision 1077) ──────────────────────────────────
//
// The engine-level law lives in `markup`; these drive it the way a player does — through the
// focused box's public keyboard API — because that is the level the reported defect lived at:
// backspacing a shift-clicked item link chewed it one invisible byte at a time, and the byte that
// went first was the `r` of its trailing `|r`, which turned everything typed afterwards the item's
// colour.
/// An epic link exactly as the client's own builder formats one (`0x52adb0`).
const LINK: &str = "|cffa335ee|Hitem:11684:0:0:0|h[Ironfoe]|h|r";
fn box_with_link(suffix: &str) -> UiScript {
let s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetFocus()"#)
.unwrap();
s.run(&format!("E:Insert(\"{LINK}{suffix}\")")).unwrap();
s
}
fn text_of(s: &UiScript) -> String {
s.eval::<String>("return E:GetText()").unwrap()
}
#[test]
fn one_backspace_deletes_a_whole_item_link() {
let mut s = box_with_link("");
assert!(s.editbox_action(EditAction::Delete {
unit: EditUnit::Char,
back: true
}));
assert_eq!(text_of(&s), "", "colour prefix and trailing |r go with it");
// And from the far side of following text: the link survives until the text is gone, then goes
// whole — never half-eaten, which is what left a dangling `|c` colouring the rest of the line.
let mut s = box_with_link("ab");
for expected in ["|cffa335ee|Hitem:11684:0:0:0|h[Ironfoe]|h|ra", LINK, ""] {
s.editbox_action(EditAction::Delete {
unit: EditUnit::Char,
back: true,
});
assert_eq!(text_of(&s), expected);
}
}
#[test]
fn one_arrow_crosses_a_whole_item_link() {
let mut s = box_with_link("");
let cursor = |s: &mut UiScript| {
s.editbox_action(EditAction::Move {
unit: EditUnit::Edge,
back: true,
extend: false,
});
s.editbox_action(EditAction::Move {
unit: EditUnit::Char,
back: false,
extend: false,
});
};
cursor(&mut s);
// One RIGHT from the start lands past the entire link — so typing there appends, and does not
// land between the `]` and its `|h`.
s.char_input("x");
assert_eq!(text_of(&s), format!("{LINK}x"));
}
#[test]
fn shift_arrow_selects_the_whole_link_and_typing_replaces_it() {
let mut s = box_with_link("");
s.editbox_action(EditAction::Move {
unit: EditUnit::Char,
back: true,
extend: true,
});
assert_eq!(
s.editbox_copy().as_deref(),
Some(LINK),
"one Shift+LEFT takes the whole unit"
);
s.char_input("x");
assert_eq!(text_of(&s), "x");
}
#[test]
fn typing_strictly_inside_a_link_is_refused() {
// Only the mouse can put the caret there, so place it the way a click would.
let mut s = box_with_link("");
s.run("E:HighlightText(35, 35)").unwrap(); // mid-"Ironfoe"
s.char_input("x");
assert_eq!(
text_of(&s),
LINK,
"the client swallows it rather than splitting the link"
);
// The same keystroke at the link's leading edge is allowed — the guard tests the PREVIOUS
// token too, which is what keeps the boundary usable.
s.run("E:HighlightText(0, 0)").unwrap();
s.char_input("x");
assert_eq!(text_of(&s), format!("x{LINK}"));
}
#[test]
fn a_typed_pipe_is_doubled_and_costs_one_letter() {
let mut s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetFocus()"#)
.unwrap();
s.char_input("|");
assert_eq!(
text_of(&s),
"||",
"OnChar 0x77c200 inserts the literal at 0x879cac"
);
assert_eq!(s.eval::<i64>("return E:GetNumLetters()").unwrap(), 1);
}
#[test]
fn max_letters_counts_visible_letters_not_escape_bytes() {
let s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetFocus(); E:SetMaxLetters(20)"#)
.unwrap();
s.run(&format!("E:Insert(\"{LINK}\")")).unwrap();
// 43 bytes, but "[Ironfoe]" is 9 letters — comfortably under a 20-letter cap that a raw char
// count would have blown through, trimming the link's own tail off.
assert_eq!(text_of(&s), LINK);
assert_eq!(s.eval::<i64>("return E:GetNumLetters()").unwrap(), 9);
}
@@ -45,12 +45,14 @@ pub struct SpellTooltipView {
/// flag, so an undispellable aura (Stealth) carries `None`. Rendered GOLD, like the aura name
/// it shares its line with — not the spell variant's gray.
pub dispel_type: Option<String>,
/// "35 Mana" / "20 Rage" / "Next melee" — the cost cell.
/// "35 Mana" / "20 Rage" / "20 Health" / "11 Health, plus 5 per sec" — the cost cell: the
/// RESOLVED cost through the power-type key array with the health fallback (1074).
pub cost: Option<String>,
/// "30 yd range" — the range cell.
pub range: Option<String>,
/// "1.5 sec cast" / "Instant cast" / "Instant" — `None` = a passive spell: the whole
/// casttime|cooldown line is omitted (the verified law; never a "Passive" text line).
/// "1.5 sec cast" / "Instant cast" / "Instant" / "Next melee" / "Attack speed" /
/// "Channeled" — `None` = a passive spell: the whole casttime|cooldown line is omitted
/// (the verified law; never a "Passive" text line).
pub cast_time: Option<String>,
/// "15 sec cooldown" — the cooldown cell: `max(RecoveryTime, CategoryRecoveryTime)` (the
/// 0276 line law §3.4 — Charge's 15 s lives in the CATEGORY column).
+99 -55
View File
@@ -279,18 +279,18 @@ impl EditBoxState {
/// [`Self::index_at_x`] restricted to the boundary range `[start, end]` of `display` —
/// the per-row walk of the 2-D law ([`Self::index_at_pos`]). `x` is measured from
/// `advances[start]`.
///
/// The candidates are the **reachable cursor stops**, not every char boundary: the client's
/// hit-test converts its glyph index through the same token walk everything else uses, with
/// `atomicLinks = 0` (`0x77d0d0`, `6a 00` @`0x77d2f6`), so a click stops on each visible
/// character of a link's text but can never land inside an escape (decision 1077).
fn index_at_x_in(&self, x: f32, start: usize, end: usize, display: &str) -> usize {
if self.advances.len() != display.len() + 1 {
return end;
}
let origin = self.advances[start];
let mut prev = start;
for b in display[start..end]
.char_indices()
.map(|(b, _)| start + b)
.skip(1)
.chain([end])
{
for b in self.stops_in(start, end, display) {
if self.advances[b] - origin >= x {
// x lies between boundaries `prev` and `b` — pick the nearer.
return if x - (self.advances[prev] - origin) <= (self.advances[b] - origin) - x {
@@ -304,6 +304,26 @@ impl EditBoxState {
end
}
/// The cursor stops strictly inside `(start, end]` of `display`, in order — the mouse-path walk
/// of [`crate::markup::ClassMap::advance`] (`atomic_links = false`). Always ends at `end`.
fn stops_in(&self, start: usize, end: usize, display: &str) -> Vec<usize> {
let map = crate::markup::ClassMap::new(display);
let mut stops = Vec::new();
let mut at = start;
loop {
let next = map.advance(at, 1, false).min(end);
if next <= at {
break;
}
stops.push(next);
at = next;
}
if stops.last() != Some(&end) {
stops.push(end);
}
stops
}
/// The wrapped row index containing DISPLAY byte `b` — the last row whose start is ≤ `b`
/// (a byte exactly on a wrap boundary belongs to the row it *starts*, so the caret lands at
/// the head of the new row, where typing continues).
@@ -511,6 +531,13 @@ impl EditBoxState {
if self.numeric && !ins.chars().all(|c| c.is_ascii_digit()) {
return EditOutcome::default();
}
// Refused outright with the caret strictly inside a hyperlink — the opening guard of
// `0x77bee0` (decision 1077). Only the mouse can put the caret there (the keyboard treats a
// link as one unit), and the client then silently swallows the typing rather than letting
// it split `|Hitem:…|h[Name]|h` into something unclickable.
if !crate::markup::ClassMap::new(&self.text).insert_allowed(self.cursor) {
return EditOutcome::default();
}
self.end_history_browse(); // a typed edit turns a recalled line into an ordinary draft
self.delete_selection();
self.text.insert_str(self.cursor, ins);
@@ -603,17 +630,16 @@ impl EditBoxState {
self.delete_selection();
break 'del true;
}
if forward {
if self.cursor < self.text.len() {
let end = next_boundary(&self.text, self.cursor);
self.text.replace_range(self.cursor..end, "");
break 'del true;
}
} else if self.cursor > 0 {
let start = prev_boundary(&self.text, self.cursor);
self.text.replace_range(start..self.cursor, "");
self.cursor = start;
self.collapse();
// One ATOMIC token step, then the endpoint snap — `0x77c280(±1)` passes
// `atomicLinks = 1` (`6a 01` @`0x77c2a3`) and hands the span to `0x77c510`, so one
// BACKSPACE takes a whole item link, escapes and trailing `|r` included (1077).
let target = crate::markup::ClassMap::new(&self.text).advance(
self.cursor,
if forward { 1 } else { -1 },
true,
);
if target != self.cursor {
self.delete_span(target.min(self.cursor)..target.max(self.cursor));
break 'del true;
}
false
@@ -636,9 +662,7 @@ impl EditBoxState {
if a == b {
false
} else {
self.text.replace_range(a..b, "");
self.cursor = a;
self.collapse();
self.delete_span(a..b);
true
}
};
@@ -651,12 +675,12 @@ impl EditBoxState {
/// LEFT/RIGHT one char: `extend` drags the selection from its fixed anchor; otherwise the caret
/// collapses onto the selection edge (when there is one) or steps one char.
pub fn move_by_char(&mut self, right: bool, extend: bool) {
// One TOKEN step, links atomic — `0x77bb30(±1, atomicLinks = 1)`, which every arrow path
// reaches (`6a 01` @`0x77c6d2`). So one press crosses a whole `|cff…|Hitem:…|h[Name]|h|r`
// rather than stepping into the middle of an escape, and Shift+arrow selects all of it
// (decision 1077). Not a char step: an escape byte is not a cursor position.
let step = |s: &str, i: usize| {
if right {
next_boundary(s, i)
} else {
prev_boundary(s, i)
}
crate::markup::ClassMap::new(s).advance(i, if right { 1 } else { -1 }, true)
};
if extend {
let anchor = self.selection_anchor();
@@ -672,9 +696,27 @@ impl EditBoxState {
self.reset_blink();
}
/// Ctrl/Option+arrow: the caret to the next [`word_boundary`](Self::word_boundary).
/// Ctrl/Option+arrow: the caret to the next [`word_boundary`](Self::word_boundary) — reached as
/// a **loop of single atomic steps** (`0x77c8c0`/`0x77c7a0` loop `0x77c6b0`), so the landing
/// place is always a reachable stop even when the word target falls inside an escape or
/// part-way through a link (decision 1077).
pub fn move_by_word(&mut self, right: bool, extend: bool) {
let target = self.word_boundary(right);
let word = self.word_boundary(right);
let mut target = self.cursor;
loop {
let next = crate::markup::ClassMap::new(&self.text).advance(
target,
if right { 1 } else { -1 },
true,
);
if next == target || (right && next > word) || (!right && next < word) {
break;
}
target = next;
if target == word {
break;
}
}
self.move_caret_to(target, extend);
}
@@ -708,8 +750,17 @@ impl EditBoxState {
self.sel_start.min(self.sel_end),
self.sel_start.max(self.sel_end),
);
self.text.replace_range(a..b, "");
self.cursor = a;
self.delete_span(a..b);
}
/// Remove a byte span, first widening it out of any hyperlink it cuts — `0x77c510` (decision
/// 1077), the second guarantee under the atomic walk: a mouse-drag selection that clips half an
/// item link still deletes the whole `|c…|H…|h[text]|h|r` unit rather than leaving orphaned
/// escape bytes. The caret collapses to the widened span's left edge.
fn delete_span(&mut self, span: std::ops::Range<usize>) {
let span = crate::markup::ClassMap::new(&self.text).snap_delete_range(span);
self.cursor = span.start;
self.text.replace_range(span, "");
self.collapse();
}
@@ -744,8 +795,25 @@ impl EditBoxState {
}
}
if self.max_letters > 0 {
while self.text.chars().count() > self.max_letters {
self.text.pop();
// LETTERS, not chars: `0x77bc80` counts classes 2, 3 and 6 only, so a 48-byte item link
// costs 14 against `maxLetters` — its visible `[Chipped Claw]` and nothing more
// (decision 1077). Counting raw chars made a 255-letter chat line fill up three times
// too fast once it held links.
//
// The trim pops through `0x77c280(-1)` — the BACKSPACE primitive, `atomicLinks = 1` —
// re-counting the whole buffer after every pop (`0x77c0e4`). So an over-long buffer
// sheds a whole hyperlink in one bite, exactly as a keypress would, and can never shear
// an escape in half.
loop {
let map = crate::markup::ClassMap::new(&self.text);
if map.num_letters() <= self.max_letters {
break;
}
let cut = map.advance(self.text.len(), -1, true);
if cut >= self.text.len() {
break;
}
self.text.truncate(cut);
}
}
let len = self.text.len();
@@ -764,30 +832,6 @@ fn snap_down(s: &str, i: usize) -> usize {
i
}
/// The next char boundary strictly after `i` (clamped to `len`).
fn next_boundary(s: &str, i: usize) -> usize {
if i >= s.len() {
return s.len();
}
let mut j = i + 1;
while j < s.len() && !s.is_char_boundary(j) {
j += 1;
}
j
}
/// The char boundary strictly before `i` (clamped to `0`).
fn prev_boundary(s: &str, i: usize) -> usize {
if i == 0 {
return 0;
}
let mut j = i - 1;
while j > 0 && !s.is_char_boundary(j) {
j -= 1;
}
j
}
#[cfg(test)]
mod row_law_tests {
use super::*;