edit boxes take focus the way 1.12 does, and the stable shows its pets
- the caret was a wedge in the line: it sat in flow, four units wide, so focusing a login box shoved its text right by one caret and a caret parked mid-word opened a gap; it is a zero-width seam now with the bar drawn as an overlay (a 0×26 seam and a 5×26 bar at the same x, off a node probe), the way the reference anchors its caret quad over the FontString; a blurred box collapses its selection so only the box holding the keyboard shows one; and a dialog answers only keys pressed while it was already on screen — ENTER on an empty form used to open the error dialog and close it in the same frame - `autoFocus` defaults ON in the reference (the ctor leaves flags = 1, and every one of the ten `autoFocus` attributes in the shipped chain is an opt-out), showing an autoFocus box focuses it after its `OnShow`, and hiding the focused box releases the keyboard — the "never focuses on show" note we had transcribed came from a `call`-only census that missed a tail `jmp`; the loader's flag loop could only ever turn a flag on, so `autoFocus="false"` was a no-op, fixed before the default flipped or it would have grabbed exactly the ten boxes the reference exempts; and the EditBox does fire generic `OnChar` with the spliced string, on every typed or pasted character and never on `SetText` - the stable master's portrait was a black disc (its session was missing from the `"npc"` token chain — the third window to ship that way, so the test is now exhaustive over every `impl NpcSession` and names the one you forgot) and its model pane draws the selected pet: a stabled pet is a database row with no world entity, so a `PortraitStandIn(display_id)` — an entity that exists only to be mirrored — feeds the existing booth code unchanged; plus the registrar's two service rows were hidden by an inherited attribute, a charter's tooltip carries the guild name and master on line 3, and the Help window drops three strings pointing at a company that stopped answering Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -119,8 +119,17 @@
|
||||
`BenillaQuestTitleButtonTemplate` because that one hardwires `QuestTitleButton_OnClick()`
|
||||
and would drag this window's load order behind the questgiver's for a fifteen-line shape.
|
||||
Same LEFT-anchored title as that template, and for the same reason: the round bullet is
|
||||
cell-centred, so a TOPLEFT-anchored label rides above it (QuestFrame.xml's own note). -->
|
||||
<Button name="BenillaGuildRegistrarServiceTemplate" virtual="true" hidden="true">
|
||||
cell-centred, so a TOPLEFT-anchored label rides above it (QuestFrame.xml's own note).
|
||||
|
||||
**NOT `hidden="true"`, and that is the whole difference from the template it is modelled
|
||||
on.** `BenillaQuestTitleButtonTemplate` carries `hidden` because the quest list is a POOL:
|
||||
its rows are hidden by default and `:Show()`n one at a time as the greeting fills. These
|
||||
two rows are STATIC — the registrar's services never change, nothing ever calls `:Show()`
|
||||
on them — so inheriting `hidden` left both permanently invisible and the window came up as
|
||||
a bare "Available Services" heading over blank parchment. The reference's own
|
||||
`QuestTitleButtonTemplate` (QuestFrameTemplates.xml l.174) has no `hidden` either; the
|
||||
attribute was copied across with the shape. -->
|
||||
<Button name="BenillaGuildRegistrarServiceTemplate" virtual="true">
|
||||
<Size><AbsDimension x="285" y="16"/></Size>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
|
||||
@@ -155,6 +155,34 @@
|
||||
return fallback or ""
|
||||
end
|
||||
|
||||
-- ── Retail-only text, retired (director's call, 2026-08-29) ────────────────────────────
|
||||
-- The Home page is entirely GlobalStrings, executed off the player's own chain — so these
|
||||
-- are Blizzard's 2006 strings, and three of them point at a company that stopped answering
|
||||
-- in 2006. Overridden here, BEFORE the markup below resolves its global-string attributes
|
||||
-- (the loader walks a document's top-level items in order, and `resolve_text` reads the
|
||||
-- live global), which is why this sits above the frames rather than in an OnLoad.
|
||||
--
|
||||
-- Do not spell a key-lookup attribute out in prose anywhere in this file:
|
||||
-- `every_shipped_text_attribute_answers_against_the_real_global_strings` sweeps the raw
|
||||
-- file for that attribute and would try to resolve the example as a real key.
|
||||
--
|
||||
-- Deliberately a SHORT list. Everything else on the page still reads true on any server
|
||||
-- with GMs — what a GM will and will not help with is a policy, not a URL, and rewriting
|
||||
-- policy prose we did not author would be a bigger change than the one asked for.
|
||||
--
|
||||
-- PVP_POLICY_URL a dead http:// link to worldofwarcraft.com/policy/pvp.shtml.
|
||||
-- HELPFRAME_HOME_ISSUE3 its last sentence exists only to introduce that link, so it goes
|
||||
-- with it; the PvP guidance before it is kept verbatim.
|
||||
-- HELPFRAME_HOME_TEXT2 "utilise the forums and the website" + two more dead links.
|
||||
--
|
||||
-- Emptying the two FontStrings is enough: the page is one anchor chain (each element hangs
|
||||
-- off the one above), so the button simply rides up into the space they leave.
|
||||
PVP_POLICY_URL = ""
|
||||
HELPFRAME_HOME_TEXT2 = ""
|
||||
HELPFRAME_HOME_ISSUE3 = "Most PVP issues can be resolved through the PVP game mechanics. "
|
||||
.. "GM's will not be able to assist in many cases. The exceptions to this rule are "
|
||||
.. "any behaviors that fall under the harassment policy of the realm you are playing on."
|
||||
|
||||
-- ── Constants (ref-lua l.1-2, l.160-166) ────────────────────────────────────────────────
|
||||
HELPFRAME_BULLET_SPACING = -3 -- ref-lua l.1
|
||||
HELPFRAME_SECTION_SPACING = -20 -- ref-lua l.2
|
||||
|
||||
@@ -32,13 +32,11 @@
|
||||
here, exactly as `PetPaperDollFrame.xml` records for its own pane. `Model_RotateLeft`/
|
||||
`Model_RotateRight` (xml l.222/241) become `BenillaPaperDollModel_RotateLeft`/`_Right`,
|
||||
the shared helpers `CharacterFrame.xml` defines.
|
||||
- **The booth itself does not exist yet** (the one open dependency): there is no
|
||||
`"stable"` slot in `crates/benilla-app/src/portrait` and no `BenillaStableModel_SetFacing` yaw
|
||||
binding beside the paperdoll/petdoll/inspect three. `BenillaSetBoothTexture` accepts any
|
||||
token and simply resolves to no image, so the pane loads and lays out correctly and
|
||||
draws nothing until the app side lands; the yaw goes through the forwarding shim below
|
||||
so a rotate click cannot raise on a nil global in the meantime. Both are app-side work,
|
||||
deliberately not faked here.
|
||||
- The pane's booth is the `"stable"` slot in `crates/benilla-app/src/portrait`, and its yaw
|
||||
binding is `BenillaStableModel_SetFacing`, the fourth beside the paperdoll/petdoll/
|
||||
inspect three. The app resolves `GetSelectedStablePet()`'s slot into either the live
|
||||
pet's entity or — for a stabled or dismissed pet, which has no world object at all —
|
||||
the creature query's `CreatureDisplayInfo` id, and the booth mirrors whichever it got.
|
||||
- **`GetPetIcon()` does not exist in this client** (reference/1.12-globals.tsv has it as
|
||||
an engine global; benilla has never needed it). `PetStable_Update` calls it at lua l.51
|
||||
and l.161-162 and it is transcribed verbatim rather than guarded: the window's current
|
||||
@@ -85,17 +83,14 @@
|
||||
-- iterate 1..NUM_PET_STABLE_SLOTS and handle 0 on its own.
|
||||
NUM_PET_STABLE_SLOTS = 2
|
||||
|
||||
-- ── The model pane's yaw (header, deferral 2) ────────────────────────────────────────────
|
||||
-- ── The model pane's yaw ─────────────────────────────────────────────────────────────────
|
||||
-- Every booth pane names its own engine yaw binding in `frame.setFacing`
|
||||
-- (`BenillaPaperDollModel_SetFacing`, `BenillaPetPaperDollModel_SetFacing`,
|
||||
-- `BenillaInspectModel_SetFacing`); the stable booth's twin is not built yet. This forwards
|
||||
-- to it the moment it exists and swallows the yaw until then, so `BenillaPaperDollModel_OnLoad`'s
|
||||
-- opening `frame.setFacing(0.61)` and every rotate click stay callable. It is the ONE line
|
||||
-- in this file that changes when the binding lands.
|
||||
-- `BenillaInspectModel_SetFacing`); this pane's is `BenillaStableModel_SetFacing`. The
|
||||
-- named forwarder stays because `frame.setFacing` wants a plain function value and this
|
||||
-- file's OnLoad is where the pane's own name for it belongs.
|
||||
function BenillaPetStableModel_SetFacing(radians)
|
||||
if BenillaStableModel_SetFacing then
|
||||
BenillaStableModel_SetFacing(radians)
|
||||
end
|
||||
BenillaStableModel_SetFacing(radians)
|
||||
end
|
||||
|
||||
-- ── The window ──────────────────────────────────────────────────────────────────────────
|
||||
@@ -628,9 +623,8 @@
|
||||
<!-- The pane's booth + its yaw. `BenillaPaperDollModel_OnLoad` seeds the
|
||||
reference's own 0.61 default facing and pushes it through `setFacing`; the
|
||||
OnUpdate is the shared held-button continuous spin. The booth token is
|
||||
"stable" and the yaw goes through this file's forwarding shim — neither the
|
||||
slot nor the binding exists app-side yet (header, deferral 2), so the pane
|
||||
lays out and stays inert rather than drawing another window's subject. -->
|
||||
"stable" and the yaw goes through this file's named forwarder onto
|
||||
`BenillaStableModel_SetFacing`. -->
|
||||
<OnLoad>
|
||||
this.setFacing = BenillaPetStableModel_SetFacing
|
||||
BenillaPaperDollModel_OnLoad(this)
|
||||
|
||||
@@ -1415,6 +1415,7 @@ fn seed_bag_window(
|
||||
};
|
||||
let slot = |disp: u32, count: u32, id: u32, name: &str, quality: u32| {
|
||||
benilla_ui::script::ContainerSlot {
|
||||
petition: None,
|
||||
durability: None,
|
||||
bar_placeable: true,
|
||||
texture: icon(disp),
|
||||
@@ -1505,6 +1506,7 @@ fn seed_equipped_bags(
|
||||
};
|
||||
let slot =
|
||||
|disp: u32, count: u32, name: &str, quality: u32| benilla_ui::script::ContainerSlot {
|
||||
petition: None,
|
||||
durability: None,
|
||||
bar_placeable: true,
|
||||
texture: icon(disp),
|
||||
|
||||
@@ -336,6 +336,39 @@ fn vm_icon_type(script: &UiScript, pos: usize) -> String {
|
||||
/// The texture path `BENILLA_GOSSIP_ICONS.petition` resolves to in the live VM — `""` if the table
|
||||
/// or the key is missing, which would mean the row draws the fallback bubble whatever the app
|
||||
/// mapped ([`super::probe_binder`]'s second half of the same assert).
|
||||
/// The charter's own bag tooltip, line by line — `SetBagItem` on the slot the buy landed in, read
|
||||
/// back out of the live VM.
|
||||
///
|
||||
/// Reported in step 8's detail rather than asserted, for §7's reason: the wording and the ORDER are
|
||||
/// a unit test's job, and what a live run adds is only that the petition record travels from the
|
||||
/// query response into the bag slot's pushed view at all.
|
||||
fn charter_tooltip_lines(script: &UiScript, pos: Option<(i64, u32)>) -> Vec<String> {
|
||||
let Some((bag, slot)) = pos else {
|
||||
return Vec::new();
|
||||
};
|
||||
let chunk = format!(
|
||||
r#"
|
||||
local a = getglobal("BenillaProbeTipAnchor")
|
||||
if not a then
|
||||
a = CreateFrame("Button", "BenillaProbeTipAnchor")
|
||||
a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
|
||||
end
|
||||
GameTooltip:SetOwner(a, "ANCHOR_RIGHT")
|
||||
GameTooltip:SetBagItem({bag}, {slot})
|
||||
local out = {{}}
|
||||
for i = 1, GameTooltip:NumLines() do
|
||||
local L = getglobal("GameTooltipTextLeft" .. i)
|
||||
if L then table.insert(out, L:GetText() or "") end
|
||||
end
|
||||
return table.concat(out, " | ")
|
||||
"#
|
||||
);
|
||||
script
|
||||
.eval::<String>(&chunk)
|
||||
.map(|s| s.split(" | ").map(str::to_string).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn vm_petition_texture(script: &UiScript) -> String {
|
||||
script
|
||||
.eval::<String>("return (BENILLA_GOSSIP_ICONS and BENILLA_GOSSIP_ICONS.petition) or \"\"")
|
||||
@@ -1061,14 +1094,32 @@ fn charter_probe(
|
||||
return;
|
||||
}
|
||||
let title = vm_petition_str(&script, 2);
|
||||
if title == probe.renamed {
|
||||
// The bag tooltip has to reach the SAME name, not merely be non-empty. It is fed by a
|
||||
// different path — the container snapshot, whose rebuild is gated (decision 1439) —
|
||||
// so the two can disagree, and requiring convergence is what proves the rename's
|
||||
// record patch reaches the gate rather than only the window. Held inside the step's
|
||||
// own timeout: a tooltip that never catches up FAILs here rather than being reported
|
||||
// as a curiosity.
|
||||
let tip = charter_tooltip_lines(
|
||||
&script,
|
||||
probe.charter.and_then(|(b, sl, _)| lua_bag_pos(b, sl)),
|
||||
);
|
||||
let tip_caught_up = tip.iter().any(|l| l.contains(&probe.renamed));
|
||||
if title == probe.renamed && tip_caught_up {
|
||||
let bought = probe.bought.clone();
|
||||
// The item TOOLTIP's line 3 while we are here — the charter's guild name and
|
||||
// master, which the director reported missing. Reported, not asserted: the plate's
|
||||
// wording and placement are pinned by the unit test
|
||||
// (`charter_lines_sit_between_the_name_and_the_signable_line`); what only a live
|
||||
// run can show is that the petition record actually reaches the bag slot's view,
|
||||
// which is a different question from whether the renderer would print it.
|
||||
probe.pass(
|
||||
8,
|
||||
"rename",
|
||||
format!(
|
||||
"the title changed from {bought:?} to {title:?} — MSG_PETITION_RENAME's \
|
||||
echo arrived and patched the cached record in place"
|
||||
echo arrived and patched the cached record in place; the bag tooltip \
|
||||
reads {tip:?}"
|
||||
),
|
||||
);
|
||||
probe.phase = Phase::Destroying {
|
||||
|
||||
@@ -45,23 +45,24 @@ fn fire_node_probe(world: &mut World) {
|
||||
.iter(world)
|
||||
.next()
|
||||
.map_or(1.0, bevy::window::Window::scale_factor);
|
||||
// **`UiGlobalTransform`, not `GlobalTransform`.** Bevy moved UI onto its own 2-D transform;
|
||||
// a UI entity no longer carries `GlobalTransform` at all, so the old query matched *nothing*
|
||||
// and this probe answered "0 nodes" — a plausible-looking reading rather than a failure —
|
||||
// for every run since that upgrade (found 2026-08-29, reaching for it to check a caret).
|
||||
let mut q = world.query::<(
|
||||
Entity,
|
||||
&bevy::ui::ComputedNode,
|
||||
&GlobalTransform,
|
||||
&bevy::ui::UiGlobalTransform,
|
||||
Option<&InheritedVisibility>,
|
||||
)>();
|
||||
let rows: Vec<(Entity, Vec2, Vec3, bool)> = q
|
||||
let rows: Vec<(Entity, Vec2, Vec2, bool)> = q
|
||||
.iter(world)
|
||||
.map(|(e, node, gt, vis)| {
|
||||
(
|
||||
e,
|
||||
node.size(),
|
||||
gt.translation(),
|
||||
vis.is_none_or(|v| v.get()),
|
||||
)
|
||||
})
|
||||
.map(|(e, node, gt, vis)| (e, node.size(), gt.translation, vis.is_none_or(|v| v.get())))
|
||||
.collect();
|
||||
// A zero here is now itself the anomaly — the screen has no UI, or the query has rotted again.
|
||||
if rows.is_empty() {
|
||||
warn!("node probe: NO ui nodes matched — the screen is bare, or this probe has rotted");
|
||||
}
|
||||
info!("node probe: {} nodes, scale {scale}", rows.len());
|
||||
for (e, size, center, vis) in rows {
|
||||
let comps: Vec<String> = world.inspect_entity(e).map_or_else(
|
||||
|
||||
@@ -354,6 +354,51 @@ impl Creatures {
|
||||
})
|
||||
}
|
||||
|
||||
/// A built display's **mirror lists** — the same `(PortraitPart, PortraitBillboard)` pair a
|
||||
/// dressed unit's attach-spawned descendants carry, assembled straight from the display cache
|
||||
/// for a creature that has **no world entity at all**: the stable window's stabled pet
|
||||
/// ([`crate::portrait::PortraitStandIn`]).
|
||||
///
|
||||
/// The split is [`crate::entities::attach::preview`]'s `assemble_pet` law verbatim — every
|
||||
/// batch of the model, camera-facing ones separated out — because it is the same question:
|
||||
/// only the character compositor selects among geosets, and a creature is not a character
|
||||
/// model, so the world's creature path draws every batch of a beast the same way. The
|
||||
/// billboards seat at [`crate::portrait::PortraitSeat::Body`] (the model's own rigged batch:
|
||||
/// its bone's booth joint already bakes the pivot, the 0130 rig identity) with `attach: None`,
|
||||
/// which is exactly what a host model's own batch is.
|
||||
///
|
||||
/// `None` while the display's model is still loading — the same gate [`Self::display_rig`] and
|
||||
/// [`Self::display_anchors`] apply, so all three become available together.
|
||||
pub(crate) fn display_mirror(
|
||||
&self,
|
||||
display_id: u32,
|
||||
) -> Option<(
|
||||
Vec<crate::portrait::PortraitPart>,
|
||||
Vec<crate::portrait::PortraitBillboard>,
|
||||
)> {
|
||||
let parts = self.models.get(&display_id)?.parts.as_deref()?;
|
||||
let mut meshes = Vec::new();
|
||||
let mut cards = Vec::new();
|
||||
for part in parts {
|
||||
match &part.billboard {
|
||||
Some(info) => cards.push(crate::portrait::PortraitBillboard {
|
||||
mesh: part.mesh.clone(),
|
||||
material: part.material.clone(),
|
||||
bone: info.bone,
|
||||
seat: crate::portrait::PortraitSeat::Body,
|
||||
kind: info.kind,
|
||||
attach: None,
|
||||
}),
|
||||
None => meshes.push(crate::portrait::PortraitPart {
|
||||
static_mesh: part.mesh.clone(),
|
||||
skinned_mesh: part.skinned_mesh.clone(),
|
||||
material: part.material.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
Some((meshes, cards))
|
||||
}
|
||||
|
||||
/// A built display's **booth rig** — what the portrait booth needs to pose a fresh instance at
|
||||
/// Stand like the ref bake (wow-re portrait-render §4 D2: a throwaway instance armed to
|
||||
/// Stand/seq-0, not the unit's live world pose): the rest skeleton, the shared inverse bind
|
||||
@@ -1024,6 +1069,10 @@ fn update_display_models(
|
||||
// its model builds with no wire entity (the screens run pre-world, where no NetEntity carries it).
|
||||
glue_preview: Option<Res<crate::portrait::GluePreview>>,
|
||||
char_create: Option<Res<CharCreate>>,
|
||||
// The stable pane's want (wow-re `stable-master-window.md` §7.1): a stabled pet is a row in the
|
||||
// server's character-pet cache and a `CreatureDisplayInfo` id — there is no wire entity to
|
||||
// carry it, so the booth's own selection is the want.
|
||||
stable_booth: Option<Res<crate::portrait::StableBooth>>,
|
||||
) {
|
||||
if !mats.ready() {
|
||||
return; // no lighting yet → no materials to build
|
||||
@@ -1054,6 +1103,15 @@ fn update_display_models(
|
||||
}
|
||||
}
|
||||
|
||||
// The stable window's selected pet — the same wantless want, and the same `Unit` kind: the
|
||||
// creature query answers with a `CreatureDisplayInfo` id, so it resolves down the plain
|
||||
// creature chain, skins and all. Asked for whenever the pane names one, live pet or not; a
|
||||
// summoned pet's display is already in the scan above, and a repeat is free (the cache is
|
||||
// keyed by display).
|
||||
if let Some(display) = stable_booth.as_deref().and_then(|b| b.display_id) {
|
||||
actives.push((EntityKind::Unit, display));
|
||||
}
|
||||
|
||||
for (kind, disp) in actives {
|
||||
match kind {
|
||||
// Players resolve their body model through the very same creature chain (decision 0041):
|
||||
@@ -1319,3 +1377,96 @@ mod world_unit_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod display_mirror_tests {
|
||||
use super::*;
|
||||
use crate::portrait::PortraitSeat;
|
||||
|
||||
/// One synthetic batch — plain, or a camera-facing one on `bone`.
|
||||
fn part(billboard: Option<u16>) -> crate::entities::display::EntityPart {
|
||||
crate::entities::display::EntityPart {
|
||||
mesh: Handle::default(),
|
||||
geometry: std::sync::Arc::new(benilla_formats::RenderSubmesh::default()),
|
||||
aabb: None,
|
||||
skinned_mesh: Some(Handle::default()),
|
||||
welded_billboard: false,
|
||||
material: Handle::default(),
|
||||
material_interior: None,
|
||||
material_interior_bake: None,
|
||||
material_interior_bake_blend: None,
|
||||
fade_blend: None,
|
||||
zfill: None,
|
||||
blend: benilla_formats::ModelBlend::Opaque,
|
||||
additive: false,
|
||||
two_sided: false,
|
||||
geoset_id: 0,
|
||||
char_slot: None,
|
||||
billboard: billboard.map(|bone| benilla_assets::BillboardInfo {
|
||||
pivot: Vec3::ZERO,
|
||||
bone,
|
||||
kind: benilla_formats::BillboardKind::Spherical,
|
||||
scale_anim: None,
|
||||
seq_translations: Vec::new(),
|
||||
}),
|
||||
alpha_anim: None,
|
||||
rgb_anim: None,
|
||||
ground_quad: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn creatures(display_id: u32, dm: crate::entities::display::DisplayModel) -> Creatures {
|
||||
let mut c = Creatures {
|
||||
catalog: Default::default(),
|
||||
models: HashMap::new(),
|
||||
};
|
||||
c.models.insert(display_id, dm);
|
||||
c
|
||||
}
|
||||
|
||||
/// **Every batch the display authored reaches the mirror, split by lane.** This is the same
|
||||
/// failure mode decision 1539's imp filed against the select pet: a display-only assembly that
|
||||
/// reads one field and drops another renders a creature that is *nearly* right, silently. The
|
||||
/// assertion is "everything the cache holds arrives, in the right lane", not a count pinned to
|
||||
/// one model.
|
||||
#[test]
|
||||
fn a_display_mirror_carries_every_batch_and_splits_the_camera_facing_ones() {
|
||||
let mut dm = crate::entities::display::empty_shell();
|
||||
dm.parts = Some(vec![part(None), part(Some(17)), part(None)]);
|
||||
let c = creatures(4449, dm);
|
||||
|
||||
let (parts, cards) = c.display_mirror(4449).expect("the model is built");
|
||||
assert_eq!(parts.len(), 2, "the two plain batches are ordinary parts");
|
||||
assert_eq!(
|
||||
cards.len(),
|
||||
1,
|
||||
"the camera-facing batch is a card, not a part"
|
||||
);
|
||||
assert_eq!(cards[0].bone, 17, "the card keeps its own billboard bone");
|
||||
assert_eq!(
|
||||
cards[0].seat,
|
||||
PortraitSeat::Body,
|
||||
"a creature's own batch is the host model's — its bone's booth joint already bakes the \
|
||||
pivot, so it takes no rider offset"
|
||||
);
|
||||
assert!(
|
||||
cards[0].attach.is_none(),
|
||||
"a batch of the host model itself sits in no M2 attachment node"
|
||||
);
|
||||
assert!(
|
||||
parts.iter().all(|p| p.skinned_mesh.is_some()),
|
||||
"the skinned twin travels — the booth poses it at Stand on its own skeleton"
|
||||
);
|
||||
}
|
||||
|
||||
/// The gate that keeps a half-loaded display out of the booth: `display_mirror` answers `None`
|
||||
/// until the parts are built, the same instant [`Creatures::display_rig`] and
|
||||
/// [`Creatures::display_anchors`] start answering — so the stand-in never spawns a subject the
|
||||
/// booth could frame from fabricated bounds.
|
||||
#[test]
|
||||
fn a_display_still_loading_mirrors_nothing() {
|
||||
let c = creatures(4449, crate::entities::display::empty_shell());
|
||||
assert!(c.display_mirror(4449).is_none(), "parts not built yet");
|
||||
assert!(c.display_mirror(1).is_none(), "unknown display");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,22 +558,54 @@ const CARET_W: f32 = 4.0;
|
||||
/// line so it lands at the cursor without anyone measuring text. The ONE caret; every edit box —
|
||||
/// [`glue_edit_box`]'s chrome and the delete dialog's ChatInputBorder box — spawns it here, so the
|
||||
/// mechanism can never fork back into per-screen `"|"`/`"_"` glyphs.
|
||||
///
|
||||
/// **The caret is an OVERLAY: it takes no width in the row.** The flex item is zero-wide — a bare
|
||||
/// *seam* the row's layout collapses to nothing — and the drawn bar is an absolutely-positioned
|
||||
/// child hanging off it, left edge on the seam, so the typed line is one continuous run whatever
|
||||
/// the cursor or the selection is doing. It used to be the bar itself, [`CARET_W`] units wide and
|
||||
/// in flow, which pushed the text apart at the cursor and — because a select-all moves the whole
|
||||
/// string from the `Before` slot to the `Selected` slot, across one caret seam — **shifted the
|
||||
/// text bodily to the right the moment a box was focused** (the director's report, 2026-08-29).
|
||||
///
|
||||
/// This is also what the client does, and the two facts are the same fact: the caret is a
|
||||
/// `CSimpleTexture` quad at `drawLayer 3`, anchored LEFT-to-LEFT on the FontString with
|
||||
/// `x = W(lineStart → cursor)` — the measured advance of the text before the cursor (wow-re
|
||||
/// `rf85-editbox-caret.md` §1, §8, both §5-VERIFIED). A quad anchored *over* the line cannot
|
||||
/// displace it, and its left edge sits exactly on the seam flex puts us on. `drawLayer 3` is above
|
||||
/// the FontString's `2`, which is the [`ZIndex`] here.
|
||||
pub(crate) fn caret_bar<C: Bundle>(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
caret: C,
|
||||
font_size: f32,
|
||||
s: f32,
|
||||
) {
|
||||
parent.spawn((
|
||||
caret,
|
||||
Visibility::Hidden,
|
||||
Node {
|
||||
width: Val::Px(CARET_W * s),
|
||||
height: Val::Px(font_size * EDIT_LINE_HEIGHT * s),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(EDIT_TEXT_COLOR),
|
||||
));
|
||||
parent
|
||||
.spawn((
|
||||
caret,
|
||||
Visibility::Hidden,
|
||||
// The seam: zero-wide, one line tall (the row centres it), and above its siblings.
|
||||
ZIndex(1),
|
||||
Node {
|
||||
width: Val::Px(0.0),
|
||||
height: Val::Px(font_size * EDIT_LINE_HEIGHT * s),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|c| {
|
||||
// The drawn bar, out of flow so it costs the row nothing. `Visibility::Inherited` by
|
||||
// default, so blinking the seam blinks the bar.
|
||||
c.spawn((
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(0.0),
|
||||
top: Val::Px(0.0),
|
||||
width: Val::Px(CARET_W * s),
|
||||
height: Val::Px(font_size * EDIT_LINE_HEIGHT * s),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(EDIT_TEXT_COLOR),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Which flex item of a glue edit box's text row an entity is.
|
||||
@@ -584,6 +616,9 @@ pub(crate) fn caret_bar<C: Bundle>(
|
||||
/// highlight background permanently; with nothing selected its text is empty, so it has zero width
|
||||
/// and paints nothing. Which of the two caret slots is visible follows the cursor, which the box
|
||||
/// law keeps at one end of the selection or the other.
|
||||
///
|
||||
/// **Both caret slots are zero-width seams** ([`caret_bar`]), present or not, so the three text
|
||||
/// slots concatenate to one unbroken line: which slot a run of text sits in never moves it.
|
||||
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum GlueFieldPart {
|
||||
/// `display[..sel_start]`
|
||||
@@ -612,6 +647,15 @@ pub(crate) fn paint_glue_field<'a>(
|
||||
),
|
||||
>,
|
||||
) {
|
||||
// **The highlight is deliberately NOT gated on `focused`** — `focused` drives the caret alone.
|
||||
// That asymmetry looks like an oversight and is the reference's own: the caret's flush
|
||||
// (`0x77da80`) explicitly hides when `E != [0xcf4dc8]`, while the selection flush (`0x77d950`)
|
||||
// and its geometry worker (`0x77de70`) contain no read of the focus global anywhere, and the
|
||||
// per-frame update calls the flush BEFORE its own focus test. The quads show iff
|
||||
// `start < end` — focus never enters it (wow-re `editbox-selection-focus-law.md` §1-§3,
|
||||
// §5-VERIFIED, asked because this asymmetry looked wrong). An unfocused box that still holds a
|
||||
// selection paints it, and that is correct; the login screen simply never leaves one behind
|
||||
// (`LoginForm::focus` collapses the box it leaves).
|
||||
let display = field.display();
|
||||
let lo = field.sel_start.min(field.sel_end);
|
||||
let hi = field.sel_start.max(field.sel_end);
|
||||
|
||||
@@ -689,20 +689,37 @@ impl Default for LoginForm {
|
||||
}
|
||||
|
||||
impl LoginForm {
|
||||
/// Give `field` the keyboard **and select everything already in it**.
|
||||
/// Give `field` the keyboard **and select everything already in it** — dropping the selection
|
||||
/// on the box being left.
|
||||
///
|
||||
/// **A knowing divergence** (director's call, 2026-08-28). The reference does neither on a
|
||||
/// click nor a TAB: `CEditBox::OnMouseDown` (`0x77b800`) "focuses unconditionally" and
|
||||
/// `SetFocus` (`0x77e3d0`) only moves the focus — neither highlights (wow-re `ui.md` §1210,
|
||||
/// §1218). But the thing a player does after clicking into a login field is *replace* what is
|
||||
/// there — a remembered account name, a mistyped password — and having to select-all or
|
||||
/// backspace it out first is friction the reference only avoids by being what everyone was
|
||||
/// used to in 2006.
|
||||
/// **A knowing divergence** (director's call, 2026-08-28), and the reference half of it is now
|
||||
/// byte-settled rather than inferred (wow-re `editbox-selection-focus-law.md` §4/§5, §5-VERIFIED,
|
||||
/// dispatched from this work). The reference does the *opposite* on a click: `OnMouseDown`
|
||||
/// (`0x77b800`) hit-tests the click to a byte index, **collapses** the selection onto it
|
||||
/// (`0x77b86f call 0x77ccf0`) and only then calls `SetFocus` — so a fresh click-focus leaves an
|
||||
/// EMPTY selection at the character you clicked. And `SetFocus` itself writes no selection field
|
||||
/// at all; `0x77e3f6` being the only instruction image-wide that grants focus makes *every* focus
|
||||
/// gain selection-neutral, TAB included. Losing focus likewise touches nothing (`0x77af50` raises
|
||||
/// only the cursor dirty bit).
|
||||
///
|
||||
/// So we diverge in both directions, deliberately: the reference collapses where we select, and
|
||||
/// leaves stale where we collapse. The reason is the same one for both — the thing a player does
|
||||
/// after clicking into a login field is *replace* what is there, a remembered account name or a
|
||||
/// mistyped password, and having to select-all or backspace it out first is friction the
|
||||
/// reference only avoids by being what everyone was used to in 2006.
|
||||
///
|
||||
/// **A divergence owes both halves.** 1682 shipped only the select; nothing ever unselected,
|
||||
/// so the box you left kept a grey highlight behind you and the screen showed two selections at
|
||||
/// once (the director's report, 2026-08-29). Selecting on focus is only coherent if the
|
||||
/// selection *means* "this is the box the keyboard is in" — which makes collapsing the outgoing
|
||||
/// box not a second feature but the other half of this one. It runs the client's own
|
||||
/// collapse-to-cursor (`0x77ccf0`), the same primitive every delete path uses.
|
||||
///
|
||||
/// The selection uses the client's own `HighlightText(0, -1)` (`0x77cca0`), which resets the
|
||||
/// blink on its way, so the box still opens on a solid caret — the property the old
|
||||
/// `reset_blink()` calls at these sites existed to preserve.
|
||||
fn focus(&mut self, field: Field) {
|
||||
self.focused().collapse();
|
||||
self.focus = field;
|
||||
self.focused().highlight_text(0, -1);
|
||||
}
|
||||
@@ -1039,6 +1056,39 @@ impl DialogKind {
|
||||
const REALMLIST_BAD: &str =
|
||||
"That is not a server address.\nTry logon.example.org or 127.0.0.1:3724";
|
||||
|
||||
/// Which of the dialog's two buttons a key press answers, as `(button1, button2)` — the keyboard
|
||||
/// half of [`drive_dialog`]'s buttons; the mouse half is OR-ed in beside it. Button 1 is the
|
||||
/// affirmative one on every kind (Cancel on the status dialog, Okay on the others); button 2
|
||||
/// exists only where the kind declares it. ENTER confirms, ESCAPE dismisses; on a one-button
|
||||
/// dialog they are the same button.
|
||||
///
|
||||
/// **`on_screen` is the whole of the second bug fixed on 2026-08-29.** Pressing ENTER on an empty
|
||||
/// login form played the sound and showed nothing, while *clicking* Login showed the dialog
|
||||
/// (director's report). The two systems poll the same [`ButtonInput`]: [`login_input`] opened the
|
||||
/// error dialog on the ENTER edge, and [`drive_dialog`] — later in the same chained frame, with
|
||||
/// `keys` untouched — read *the same* `just_pressed(Enter)` as that dialog's own Okay and closed
|
||||
/// it before it had ever been drawn. Both sounds played, which is exactly what was heard.
|
||||
///
|
||||
/// The reference cannot have this bug because it is event-driven: the ENTER that fires
|
||||
/// `AccountLogin_Login` is *dispatched* to the focused edit box (`OnEnterPressed`), and a dialog
|
||||
/// that does not exist yet is not in the dispatch. Polling is our seam, so the gate has to be
|
||||
/// ours too, and it says the same thing the dispatch does — **a dialog answers only keys pressed
|
||||
/// while it was already on screen**. Not "not this frame": on screen. It is passed the same
|
||||
/// `fresh` flag that drives the (re)spawn, so the two cannot drift apart.
|
||||
fn dialog_keys(kind: DialogKind, on_screen: bool, enter: bool, escape: bool) -> (bool, bool) {
|
||||
if !on_screen {
|
||||
return (false, false);
|
||||
}
|
||||
let button1 = match kind {
|
||||
// The status dialog's one button IS Cancel, so ESCAPE is it. The queue's button is the
|
||||
// same act under another name, so it answers to ESCAPE the same way.
|
||||
DialogKind::Status | DialogKind::Queued => escape,
|
||||
DialogKind::Error => escape || enter,
|
||||
DialogKind::Realmlist => enter,
|
||||
};
|
||||
(button1, kind == DialogKind::Realmlist && escape)
|
||||
}
|
||||
|
||||
/// The login screen's one dialog (the ref's shared `GlueDialog`): kind + text; the driver spawns/
|
||||
/// despawns the tree (respawning on a kind change — the button caption differs) and updates the
|
||||
/// text in place.
|
||||
@@ -1141,8 +1191,13 @@ fn drive_dialog(
|
||||
|
||||
// (Re)spawn on the open edge, a kind change (the button caption differs), or a window resize
|
||||
// (the tree bakes the glue scale); a text-only change updates the message line in place.
|
||||
//
|
||||
// `fresh` is *this dialog appearing*, as opposed to a resize rebuilding a dialog already up —
|
||||
// and it is the same flag [`dialog_keys`] takes as `!on_screen`, so the frame the dialog is
|
||||
// drawn on and the frame it starts answering keys on are one decision, not two.
|
||||
let s = crate::glue::screen_scale(window.single().ok());
|
||||
if dialog.root.is_none() || dialog.spawned != Some(kind) || dialog.spawned_s != s {
|
||||
let fresh = dialog.root.is_none() || dialog.spawned != Some(kind);
|
||||
if fresh || dialog.spawned_s != s {
|
||||
if let Some(root) = dialog.root.take() {
|
||||
commands.entity(root).despawn();
|
||||
}
|
||||
@@ -1168,21 +1223,17 @@ fn drive_dialog(
|
||||
dialog.dirty = false;
|
||||
}
|
||||
|
||||
// The buttons (or their keys). Button 1 is the affirmative one on every kind — Cancel on
|
||||
// the status dialog, Okay on the other two — and button 2 exists only where the kind declares
|
||||
// it. ENTER confirms, ESCAPE dismisses; on a one-button dialog they are the same button.
|
||||
// The buttons, from the mouse or the keys. A click needs no `fresh` guard of its own: the
|
||||
// button it would hit did not exist to be clicked on the frame the tree spawned.
|
||||
let hit = |want: LoginAction| buttons.iter().any(|(e, a)| *a == want && clicks.hit(e));
|
||||
let enter = keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::NumpadEnter);
|
||||
let escape = keys.just_pressed(KeyCode::Escape);
|
||||
let button1 = hit(LoginAction::Dialog)
|
||||
|| match kind {
|
||||
// The status dialog's one button IS Cancel, so ESCAPE is it. The queue's button is
|
||||
// the same act under another name, so it answers to ESCAPE the same way.
|
||||
DialogKind::Status | DialogKind::Queued => escape,
|
||||
DialogKind::Error => escape || enter,
|
||||
DialogKind::Realmlist => enter,
|
||||
};
|
||||
let button2 = hit(LoginAction::Dialog2) || (kind == DialogKind::Realmlist && escape);
|
||||
let (key1, key2) = dialog_keys(
|
||||
kind,
|
||||
!fresh,
|
||||
keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::NumpadEnter),
|
||||
keys.just_pressed(KeyCode::Escape),
|
||||
);
|
||||
let button1 = hit(LoginAction::Dialog) || key1;
|
||||
let button2 = hit(LoginAction::Dialog2) || key2;
|
||||
|
||||
// **Every glue dialog button clicks** — `GlueDialog_OnClick` ends with
|
||||
// `PlaySound("gsTitleOptionOK")` for button 1 and button 2 alike, whatever the dialog's type.
|
||||
@@ -1513,6 +1564,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **A dialog never answers the key press that opened it.** ENTER on an empty login form
|
||||
/// opened the error dialog in `login_input` and then, later in the *same* frame with the same
|
||||
/// `ButtonInput`, `drive_dialog` read that identical `just_pressed(Enter)` as the dialog's own
|
||||
/// Okay — so the popup appeared and vanished inside one frame and only the two sounds were
|
||||
/// heard (director's report, 2026-08-29). The gate is being on screen, not a frame count.
|
||||
#[test]
|
||||
fn a_dialog_does_not_answer_the_key_that_opened_it() {
|
||||
// The frame it appears on: the key that opened it is not its answer.
|
||||
assert_eq!(
|
||||
dialog_keys(DialogKind::Error, false, true, false),
|
||||
(false, false),
|
||||
);
|
||||
assert_eq!(
|
||||
dialog_keys(DialogKind::Error, false, false, true),
|
||||
(false, false),
|
||||
);
|
||||
// Once it is up, the same press is.
|
||||
assert_eq!(
|
||||
dialog_keys(DialogKind::Error, true, true, false),
|
||||
(true, false),
|
||||
);
|
||||
}
|
||||
|
||||
/// Which key answers which button, per kind: ENTER and ESCAPE are the same (single) button on
|
||||
/// an error dialog; ESCAPE alone works the Cancel-shaped ones; the two-button editor splits
|
||||
/// them, ENTER to Okay and ESCAPE to Cancel.
|
||||
#[test]
|
||||
fn each_dialog_kind_maps_its_own_keys() {
|
||||
for (kind, enter, escape, want) in [
|
||||
(DialogKind::Error, true, false, (true, false)),
|
||||
(DialogKind::Error, false, true, (true, false)),
|
||||
(DialogKind::Status, true, false, (false, false)),
|
||||
(DialogKind::Status, false, true, (true, false)),
|
||||
(DialogKind::Queued, false, true, (true, false)),
|
||||
(DialogKind::Queued, true, false, (false, false)),
|
||||
(DialogKind::Realmlist, true, false, (true, false)),
|
||||
(DialogKind::Realmlist, false, true, (false, true)),
|
||||
] {
|
||||
assert_eq!(
|
||||
dialog_keys(kind, true, enter, escape),
|
||||
want,
|
||||
"{kind:?} enter={enter} escape={escape}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opening the editor seats the current address in the box, selected whole — so typing a new
|
||||
/// server replaces the old one instead of appending to it.
|
||||
#[test]
|
||||
@@ -1671,9 +1768,24 @@ mod tests {
|
||||
"the whole password is selected even though it cannot be read back",
|
||||
);
|
||||
|
||||
// **And the box we left is no longer selected.** The other half of the divergence: with
|
||||
// the selection standing for "the keyboard is here", exactly one box can carry one.
|
||||
assert_eq!(
|
||||
form.account.selected_text(),
|
||||
None,
|
||||
"the box that lost the keyboard keeps no highlight",
|
||||
);
|
||||
|
||||
// Typing then replaces rather than appends — the point of the divergence.
|
||||
form.focused().insert("x");
|
||||
assert_eq!(form.password.text, "x");
|
||||
|
||||
form.focus(Field::Account);
|
||||
assert_eq!(
|
||||
form.password.selected_text(),
|
||||
None,
|
||||
"and back the other way"
|
||||
);
|
||||
}
|
||||
|
||||
/// The code→string map quotes the client's own strings for the vmangos-verified rows.
|
||||
|
||||
@@ -82,18 +82,10 @@ pub(crate) struct CreatureRecord {
|
||||
/// For anything actually on screen the unit's own descriptor field still wins — vmangos picks
|
||||
/// among a template's four display ids at spawn, and this is the first.
|
||||
///
|
||||
/// **Stored but not yet read** — the stable window's model pane is the consumer, and it needs a
|
||||
/// booth that renders a creature from a display id with **no world entity** to mirror. Every
|
||||
/// in-world booth today takes an `Option<Entity>` and copies that unit's dressed look
|
||||
/// ([`crate::portrait`]'s `sync_body_booth`); the only display-id path that exists is the glue
|
||||
/// stage's select pet (`portrait::glue_booth`, decisions 1536/1538/1539), which is a different
|
||||
/// system. Wiring the two together is its own arc, named as a deferral in decision 1676 rather
|
||||
/// than half-built here. The field is filled now because the wire already carries it and the
|
||||
/// query is ask-once: dropping it would mean re-querying every template later.
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "the stable model pane's booth arc — decision 1676"
|
||||
)]
|
||||
/// **Read by the stable window's model pane** ([`crate::ui_stable`]'s `feed_stable_booth`),
|
||||
/// which is what 1676 deferred and named this field for: the booth points at a
|
||||
/// [`crate::portrait::PortraitStandIn`] built from this id when the selected pet has no world
|
||||
/// object — every stabled pet, and a dismissed one in slot 0.
|
||||
pub(crate) display_id: u32,
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,15 @@ const INSPECT_SLOT: &str = "inspect";
|
||||
/// rather than sharing the `"pet"` portrait slot because that one is a 256² *bust* for the pet unit
|
||||
/// frame — different subject framing, different resolution, and a yaw the portrait must never have.
|
||||
const PETDOLL_SLOT: &str = "petdoll";
|
||||
/// The **stable window**'s model pane (wow-re `ui/scratch/stable-master-window.md` §7.1) — the
|
||||
/// fourth body pane, and the only one whose subject may have no world object at all.
|
||||
/// `SetPetStablePaperdoll` forks on the selection: `-1` (the summoned pet) resolves the live pet by
|
||||
/// GUID and takes its model, anything else maps the petNumber back to a slot and goes through the
|
||||
/// **creature cache**, i.e. a bare `CreatureDisplayInfo` id for a pet that is asleep in a stable and
|
||||
/// has no unit anywhere. Both forks land in the same booth here: the live one points
|
||||
/// [`StableBooth::unit`] at the pet entity ([`PETDOLL_SLOT`]'s case exactly), the cached one points
|
||||
/// [`StableBooth::display_id`] at the display and the mirror comes off a [`PortraitStandIn`].
|
||||
const STABLE_SLOT: &str = "stable";
|
||||
/// World is layer 0, the UI quad pass layer 1; portraits sit on their own high layers so nothing in the
|
||||
/// world leaks into a booth and vice-versa (one layer per slot: base, base+1, …).
|
||||
const PORTRAIT_LAYER_BASE: usize = 2;
|
||||
@@ -138,6 +147,8 @@ const PAPERDOLL_LAYER: usize = PORTRAIT_LAYER_BASE + SLOTS.len();
|
||||
const INSPECT_LAYER: usize = PAPERDOLL_LAYER + 1;
|
||||
/// The pet-doll booth's render layer — the next one past inspect's (the one ladder below).
|
||||
const PETDOLL_LAYER: usize = INSPECT_LAYER + 1;
|
||||
/// The stable pane's render layer — the next one past the pet doll's, by the ladder rule below.
|
||||
const STABLE_LAYER: usize = PETDOLL_LAYER + 1;
|
||||
/// The glue booth's render layer — the next one past inspect's. **Every booth layer is computed
|
||||
/// HERE**, in one ladder, because they were not: the glue booth (`930b327c`) and the inspect booth
|
||||
/// (`ead3b0c9`) each defined "the next layer past the paper doll's" in a different file and landed
|
||||
@@ -145,7 +156,7 @@ const PETDOLL_LAYER: usize = INSPECT_LAYER + 1;
|
||||
/// resolves an emitter's booth camera by *finding the first camera whose layers intersect*, so the
|
||||
/// glue scene's 28 emitters addressed the INSPECT camera, which is off at the glue screens, and the
|
||||
/// login screen's braziers simulated forever without ever being drawn (decision 0775).
|
||||
pub(super) const GLUE_LAYER: usize = PETDOLL_LAYER + 1;
|
||||
pub(super) const GLUE_LAYER: usize = STABLE_LAYER + 1;
|
||||
/// The **dressing room**'s render layer (decision 1060) — the next one past the glue booth's, by
|
||||
/// the same ladder rule.
|
||||
pub(super) const DRESSUP_LAYER: usize = GLUE_LAYER + 1;
|
||||
@@ -165,6 +176,10 @@ pub(crate) const MINIMAP_COMPOSITE_LAYER: usize = WARM_BOOTH_LAYER + 1;
|
||||
const _: () = assert!(
|
||||
PAPERDOLL_LAYER != INSPECT_LAYER
|
||||
&& INSPECT_LAYER != PETDOLL_LAYER
|
||||
&& PETDOLL_LAYER != STABLE_LAYER
|
||||
&& STABLE_LAYER != GLUE_LAYER
|
||||
&& PAPERDOLL_LAYER != STABLE_LAYER
|
||||
&& INSPECT_LAYER != STABLE_LAYER
|
||||
&& PETDOLL_LAYER != GLUE_LAYER
|
||||
&& PAPERDOLL_LAYER != PETDOLL_LAYER
|
||||
&& PAPERDOLL_LAYER != GLUE_LAYER
|
||||
@@ -310,6 +325,22 @@ pub(crate) struct PortraitEffects {
|
||||
pub(crate) emitters: Vec<benilla_assets::ModelEmitter>,
|
||||
}
|
||||
|
||||
/// Stamped on a **stand-in subject** — a bodiless entity that exists only to be mirrored, whose
|
||||
/// [`PortraitPart`] / [`PortraitBillboard`] children are assembled straight from the display cache
|
||||
/// ([`crate::entities::Creatures::display_mirror`]) instead of from a unit's attach path.
|
||||
///
|
||||
/// It is what lets a body booth be pointed at a creature that has **no world object at all** — the
|
||||
/// stable window's stabled pet, which is a row in the server's character-pet cache and a
|
||||
/// `CreatureDisplayInfo` id, nothing more. The alternative would have been a second bake path
|
||||
/// beside [`sync_body_booth`]; a subject entity means the *existing* one runs verbatim, so the
|
||||
/// summoned-pet case and the stabled case cannot drift apart in framing, lighting, yaw or settle.
|
||||
///
|
||||
/// It carries the display id because that is the one thing [`sync_body_booth`] otherwise reads off
|
||||
/// the unit's [`crate::entities::NetEntity`] — and a stand-in has none, by construction. Nothing
|
||||
/// else about it is a unit: no guid, no transform of consequence, no visibility, no descriptors.
|
||||
#[derive(Component)]
|
||||
pub(crate) struct PortraitStandIn(pub(crate) u32);
|
||||
|
||||
/// What a portrait slot currently shows — the booth's live bake, or the ref's 2D stand-in file
|
||||
/// while a unit's model is still streaming in (RE C5).
|
||||
#[derive(Clone, PartialEq)]
|
||||
@@ -439,6 +470,63 @@ impl Default for PetDollBooth {
|
||||
}
|
||||
}
|
||||
|
||||
/// The stable window's model pane input (wow-re `stable-master-window.md` §7.1) — the
|
||||
/// [`PetDollBooth`] shape plus the one thing no other body pane needs: a **subject with no world
|
||||
/// object**.
|
||||
///
|
||||
/// `GetSelectedStablePet()` answers `0` for the summoned pet, `1..=2` for a bought stable slot and
|
||||
/// `-1` for nothing, and the reference's `SetPetStablePaperdoll` forks on exactly that: a live pet
|
||||
/// is resolved by GUID and its model taken, while a stabled — or merely dismissed — pet is a row in
|
||||
/// the server's character-pet cache with nothing but a creature entry, resolved through the
|
||||
/// creature cache. So this booth carries both, and [`sync_stable_booth`] prefers the live one:
|
||||
///
|
||||
/// - `unit` — the summoned pet's entity, when the selection is slot 0 **and** the pet is actually
|
||||
/// out and streamed. Then the pane is [`PETDOLL_SLOT`]'s case exactly, mirrored from the world
|
||||
/// body, and [`sync_body_booth`] handles it unchanged.
|
||||
/// - `display_id` — the selected pet's `CreatureDisplayInfo`, from the creature query
|
||||
/// ([`crate::names::CreatureRecord::display_id`]). Used whenever `unit` is `None`: every stabled
|
||||
/// pet, and slot 0 while the pet is dismissed or out of range to summon (the server still sends
|
||||
/// its row, and the reference still draws it — `0xb72060`, the summoned record's creature entry,
|
||||
/// is the fall-through of the live branch's own GUID miss).
|
||||
///
|
||||
/// Both `None` = nothing selected, or the query has not answered yet, which empties the booth.
|
||||
///
|
||||
/// ## No pet-family size ramp here, and that is checked rather than assumed
|
||||
///
|
||||
/// Decision 1538 sizes the *select screen's* pet by `CreatureFamily`'s level ramp. **This pane
|
||||
/// applies no scale at all**, because the reference applies none: `SetPetStablePaperdoll`
|
||||
/// (`0x4cb870`) writes no scale on either arm; `0x505cb0` only stores the pointer into
|
||||
/// `[widget+0x3f0]` and clears `[+0x3e0]`/`[+0x3e4]`; and the record→model build it defers to
|
||||
/// (`0x505a70`) walks `CreatureDisplayInfo` → `CreatureModelData` → the model path and calls
|
||||
/// `0x4797b0`, which wow-re's `charactermodel` ledger records as the creature **texture-variation**
|
||||
/// applier — there is no `fmul` of a scale anywhere on the path. The pane is unnormalized by
|
||||
/// construction anyway ([`framing::body_frame`]: a `<PlayerModel>` renders through the model's own
|
||||
/// authored camera or a fixed rig, never a bounds fit), so "small model draws small" is already the
|
||||
/// law here.
|
||||
///
|
||||
/// The second reason is internal, and it is the stronger one: the live arm goes through
|
||||
/// [`sync_body_booth`] like every other body pane, which applies only [`framing::pane_root_scale`].
|
||||
/// Ramping the display arm alone would make **the same pet change size** the moment it was
|
||||
/// dismissed — one pane telling two stories.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct StableBooth {
|
||||
pub(crate) yaw: f32,
|
||||
pub(crate) unit: Option<Entity>,
|
||||
pub(crate) display_id: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for StableBooth {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// The reference's own `<PlayerModel>` default facing (UIParent.lua:1422), seeded again
|
||||
// by `BenillaPaperDollModel_OnLoad` the moment the window loads.
|
||||
yaw: 0.61,
|
||||
unit: None,
|
||||
display_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The key identifying what a booth currently has baked. Any change in the unit's dressed look
|
||||
/// (gear swap, appearance refresh, different unit) changes it → re-bake.
|
||||
#[derive(PartialEq)]
|
||||
@@ -1035,6 +1123,8 @@ impl Plugin for PortraitPlugin {
|
||||
.init_resource::<PaperDollBooth>()
|
||||
.init_resource::<InspectBooth>()
|
||||
.init_resource::<PetDollBooth>()
|
||||
.init_resource::<StableBooth>()
|
||||
.init_resource::<StableStandIn>()
|
||||
.init_resource::<glue_booth::GluePreview>()
|
||||
.init_resource::<glue_booth::GluePreviewBake>()
|
||||
.init_resource::<glue_booth::GluePetBake>()
|
||||
@@ -1065,6 +1155,10 @@ impl Plugin for PortraitPlugin {
|
||||
sync_paperdoll,
|
||||
sync_inspect_booth,
|
||||
sync_petdoll_booth,
|
||||
// Before the pane that reads it: the stand-in must exist (and have had a frame
|
||||
// to flush its children) before the booth can mirror it.
|
||||
sync_stable_standin,
|
||||
sync_stable_booth,
|
||||
glue_booth::sync_glue_booth,
|
||||
glue_booth::sync_glue_scene,
|
||||
// After the scene's framing: the viewport/clear its law decided (1619).
|
||||
@@ -1294,17 +1388,18 @@ fn setup_booths(
|
||||
);
|
||||
}
|
||||
|
||||
// The three **body** booths — the character window's paper doll (decision 0208 §5), the
|
||||
// inspect window's pane (decision 0631 §4) and the pet paper doll's (decision 1057). Same
|
||||
// off-screen pipeline as the portrait slots (transparent target, HDR + the FFXGlow node,
|
||||
// negative order so the bake is ready before the world/UI cameras), but their own 512²
|
||||
// targets, their own layers, and a body-framing projection (aimed per-bake by
|
||||
// `sync_body_booth`). Kept a separate spawn from the portrait loop above so these cameras
|
||||
// stay byte-for-byte what the director approved.
|
||||
// The four **body** booths — the character window's paper doll (decision 0208 §5), the
|
||||
// inspect window's pane (decision 0631 §4), the pet paper doll's (decision 1057) and the
|
||||
// stable window's (wow-re `stable-master-window.md` §7.1). Same off-screen pipeline as the
|
||||
// portrait slots (transparent target, HDR + the FFXGlow node, negative order so the bake is
|
||||
// ready before the world/UI cameras), but their own 512² targets, their own layers, and a
|
||||
// body-framing projection (aimed per-bake by `sync_body_booth`). Kept a separate spawn from
|
||||
// the portrait loop above so these cameras stay byte-for-byte what the director approved.
|
||||
for (i, (slot, layer_index)) in [
|
||||
(PAPERDOLL_SLOT, PAPERDOLL_LAYER),
|
||||
(INSPECT_SLOT, INSPECT_LAYER),
|
||||
(PETDOLL_SLOT, PETDOLL_LAYER),
|
||||
(STABLE_SLOT, STABLE_LAYER),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
@@ -1612,6 +1707,11 @@ struct DressedLook<'w, 's> {
|
||||
/// because all three body-pane systems were at Bevy's 16-parameter ceiling.
|
||||
dress: Query<'w, 's, &'static crate::entities::DressKey>,
|
||||
revs: Query<'w, 's, &'static ModelRevision>,
|
||||
/// A [`PortraitStandIn`] subject's display id — the one thing a bodiless subject carries that
|
||||
/// a world unit reads off its [`crate::entities::NetEntity`] instead. It rides this param for
|
||||
/// the same reason `dress`/`revs` do: all four body-pane systems sit at Bevy's 16-parameter
|
||||
/// ceiling, and this is already the "what does this subject look like" bundle.
|
||||
stand_in: Query<'w, 's, &'static PortraitStandIn>,
|
||||
}
|
||||
|
||||
impl DressedLook<'_, '_> {
|
||||
@@ -1676,6 +1776,12 @@ impl DressedLook<'_, '_> {
|
||||
(parts, riders, billboards, effects)
|
||||
}
|
||||
|
||||
/// A stand-in subject's display id — `None` for a real world unit, which carries its own on
|
||||
/// its [`crate::entities::NetEntity`].
|
||||
fn stand_in_display(&self, unit: Entity) -> Option<u32> {
|
||||
self.stand_in.get(unit).ok().map(|s| s.0)
|
||||
}
|
||||
|
||||
/// `unit`'s dress and model revision — `None`/`0` before its equipment has first resolved,
|
||||
/// which is simply another key value and re-snapshots when it lands.
|
||||
fn snapshot_inputs(&self, unit: Entity) -> (Option<crate::entities::DressKey>, u32) {
|
||||
@@ -2159,9 +2265,144 @@ fn sync_petdoll_booth(
|
||||
);
|
||||
}
|
||||
|
||||
/// The stable pane's **stand-in subject** — the bodiless [`PortraitStandIn`] entity that stands
|
||||
/// for a pet with no world object (module note on [`StableBooth`]).
|
||||
///
|
||||
/// Keyed on the **display**, not on the selection: two stabled pets that share a display id are the
|
||||
/// same model standing at the same facing, so moving the selection between them is not a re-bake —
|
||||
/// and the reference is no different, since its cached branch resolves a creature record and the
|
||||
/// two rows resolve to the same one. A *changed* display respawns the entity rather than refilling
|
||||
/// it, so the bake's own `SnapKey` (keyed on the subject entity) cannot carry across.
|
||||
#[derive(Resource, Default)]
|
||||
struct StableStandIn {
|
||||
/// The stand-in, while one exists.
|
||||
entity: Option<Entity>,
|
||||
/// The display it was spawned for.
|
||||
display: Option<u32>,
|
||||
/// Its mirror children are up. `false` while the display's model is still building, which
|
||||
/// simply retries next frame — the glue pet's assembly discipline.
|
||||
built: bool,
|
||||
}
|
||||
|
||||
/// Keep [`StableStandIn`] pointing at the selected stabled pet: spawn one when the pane wants a
|
||||
/// display it has no world body for, fill its mirror children the moment that display's model
|
||||
/// finishes building, and despawn it the instant the want changes (a different pet, a live pet to
|
||||
/// mirror instead, or nothing selected).
|
||||
fn sync_stable_standin(
|
||||
mut commands: Commands,
|
||||
stable: Res<StableBooth>,
|
||||
creatures: Option<Res<Creatures>>,
|
||||
mut state: ResMut<StableStandIn>,
|
||||
) {
|
||||
// **The live pet wins.** With a world body to mirror there is nothing for a stand-in to do, and
|
||||
// the reference agrees: `SetPetStablePaperdoll` reaches the creature cache only after the
|
||||
// live-pet-by-GUID resolve has failed (§7.1's fall-through at `0x4cb9bc`).
|
||||
let want = stable.unit.is_none().then_some(stable.display_id).flatten();
|
||||
if state.display != want {
|
||||
if let Some(old) = state.entity.take() {
|
||||
commands.entity(old).despawn();
|
||||
}
|
||||
state.display = want;
|
||||
state.built = false;
|
||||
state.entity = want.map(|display| {
|
||||
commands
|
||||
.spawn((
|
||||
PortraitStandIn(display),
|
||||
// Hidden, at the origin: nothing under a stand-in is ever drawn in the world —
|
||||
// the booth re-spawns its own copies of these meshes on its own layer, which is
|
||||
// what it does for a real unit too. The pair is here so the transform and
|
||||
// visibility propagation walk a well-formed tree rather than a rootless one.
|
||||
Transform::default(),
|
||||
Visibility::Hidden,
|
||||
))
|
||||
.id()
|
||||
});
|
||||
}
|
||||
if state.built {
|
||||
return;
|
||||
}
|
||||
let (Some(root), Some(disp), Some(creatures)) = (state.entity, want, creatures.as_deref())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// `None` while the display's model is still loading (`update_display_models` asked for it on
|
||||
// this same want) — leave `built` false and retry next frame.
|
||||
let Some((parts, cards)) = creatures.display_mirror(disp) else {
|
||||
return;
|
||||
};
|
||||
let (n_parts, n_cards) = (parts.len(), cards.len());
|
||||
commands.entity(root).with_children(|kids| {
|
||||
for part in parts {
|
||||
kids.spawn((Transform::default(), Visibility::Inherited, part));
|
||||
}
|
||||
for card in cards {
|
||||
kids.spawn((Transform::default(), Visibility::Inherited, card));
|
||||
}
|
||||
});
|
||||
state.built = true;
|
||||
debug!(
|
||||
"stable booth: stand-in for display {disp} — {n_parts} part(s), {n_cards} camera-facing"
|
||||
);
|
||||
}
|
||||
|
||||
/// The stable window's model pane — the pet `GetSelectedStablePet()` names.
|
||||
///
|
||||
/// The **whole fork is upstream of here**: [`crate::ui_stable`] resolves the selection into either
|
||||
/// a live pet entity or a bare display id, and [`sync_stable_standin`] turns the latter into a
|
||||
/// subject entity. So this is [`sync_petdoll_booth`] with one `or` — which is the point of the
|
||||
/// stand-in: the summoned pet and the stabled pet reach the same bake through the same code, and so
|
||||
/// cannot drift apart in framing, lighting, animation or settle.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sync_stable_booth(
|
||||
mut commands: Commands,
|
||||
mut booths: ResMut<Booths>,
|
||||
mut portraits: ResMut<PortraitImages>,
|
||||
mut booth_light: ResMut<BoothLight>,
|
||||
creatures: Option<Res<Creatures>>,
|
||||
ent_q: Query<&NetEntity>,
|
||||
look: DressedLook,
|
||||
stable: Res<StableBooth>,
|
||||
stand_in: Res<StableStandIn>,
|
||||
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
|
||||
mut wow_mats: ResMut<Assets<WowModelMaterial>>,
|
||||
mut env_cache: Local<Option<bool>>,
|
||||
mut last_pose: Local<Option<(f32, f32)>>,
|
||||
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
|
||||
framing_in: BoothFraming,
|
||||
anim_data: Option<Res<crate::creature_anim::AnimData>>,
|
||||
) {
|
||||
if test_mode(&mut env_cache) {
|
||||
return;
|
||||
}
|
||||
sync_body_booth(
|
||||
&mut palettes,
|
||||
STABLE_SLOT,
|
||||
stable.unit.or(stand_in.entity),
|
||||
stable.yaw,
|
||||
&mut last_pose,
|
||||
framing_in.gx.0,
|
||||
&mut commands,
|
||||
&mut booths,
|
||||
&mut portraits,
|
||||
&mut booth_light,
|
||||
creatures.as_deref(),
|
||||
&ent_q,
|
||||
&look,
|
||||
&mut wow_mats,
|
||||
&mut cams,
|
||||
anim_data.as_deref(),
|
||||
&framing_in.panes,
|
||||
);
|
||||
}
|
||||
|
||||
/// Bake `unit`'s full-body dressed look into the `slot` booth at `yaw` — the shared body of all
|
||||
/// three body booths (decision 0208 §5 for the paper doll, 0631 §4 for inspect, 1057 for the pet
|
||||
/// doll). `unit` is `None` when there is nothing to show, which empties the booth.
|
||||
/// four body booths (decision 0208 §5 for the paper doll, 0631 §4 for inspect, 1057 for the pet
|
||||
/// doll, wow-re `stable-master-window.md` §7.1 for the stable pane). `unit` is `None` when there is
|
||||
/// nothing to show, which empties the booth.
|
||||
///
|
||||
/// `unit` is a **subject**, not necessarily a world unit: a [`PortraitStandIn`] mirrors the same
|
||||
/// way and carries its own display id, which is how the stable pane draws a pet that has no object
|
||||
/// anywhere ([`StableBooth`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sync_body_booth(
|
||||
palettes: &mut benilla_world::rig_palette::RigPalettes,
|
||||
@@ -2248,7 +2489,14 @@ fn sync_body_booth(
|
||||
booth.show_rev,
|
||||
aspect,
|
||||
);
|
||||
let display_id = ent_q.get(unit).ok().and_then(|n| n.display_id);
|
||||
// The display the framing, the rig and the model-root scale all key on. A world unit carries
|
||||
// it on its wire record; a bodiless [`PortraitStandIn`] carries it on itself, and has no wire
|
||||
// record at all — which is the whole of what makes a stabled pet drawable.
|
||||
let display_id = ent_q
|
||||
.get(unit)
|
||||
.ok()
|
||||
.and_then(|n| n.display_id)
|
||||
.or_else(|| look.stand_in_display(unit));
|
||||
// Anchors first, before any teardown — a still-loading display must not be framed from
|
||||
// fabricated zero bounds (see the portrait site, and `booth_anchors`). Resolved out here rather
|
||||
// than inside the bake because the model-root scale below needs them on an otherwise-idle frame
|
||||
|
||||
@@ -37,6 +37,10 @@ pub(crate) struct FeedMemory {
|
||||
items_templates: gate::Watch,
|
||||
cooldown_epoch: gate::Watch,
|
||||
names_generation: gate::Watch,
|
||||
/// `PetitionState::records_epoch` — one step per landed or patched petition record, which is
|
||||
/// what a charter slot's tooltip lines are built from. Lazy, so its arrival moves nothing else
|
||||
/// here (the same reason `names_generation` exists one line up).
|
||||
petition_records: gate::Watch,
|
||||
/// `Items::enchant_display_epoch` — one step per displayable countdown change (the slot
|
||||
/// views read second-floored countdowns), including the last elapse's collapsing push.
|
||||
enchant_deadlines: gate::Watch,
|
||||
@@ -538,6 +542,9 @@ fn resolve_slot(
|
||||
cooldowns: &crate::cooldowns::Cooldowns,
|
||||
spells: Option<&benilla_formats::SpellCatalog>,
|
||||
names: &mut crate::names::NameCache,
|
||||
// The petition record cache, for a charter slot's tooltip lines — a LAZY fill, so it is taken
|
||||
// mutably for the same reason `names` is: the read is what issues the query.
|
||||
petitions: &mut crate::ui_petition::PetitionState,
|
||||
now: std::time::Instant,
|
||||
ui_now: f64,
|
||||
) -> Option<ContainerSlot> {
|
||||
@@ -618,6 +625,25 @@ fn resolve_slot(
|
||||
});
|
||||
};
|
||||
Some(ContainerSlot {
|
||||
// **Line 3 of the tooltip law** — a charter's guild name and master. Gated on the template's
|
||||
// signable flag, then keyed by the petition id the server stuffs into the item's
|
||||
// `ITEM_FIELD_ENCHANTMENT` slot 0 (`PetitionsHandler.cpp:126`) — which is the same field the
|
||||
// client reads it from (`0x5ef337`), and the same one the tooltip's ENCHANT lines are
|
||||
// forced to skip for exactly this reason (`0x52c9e0 test ah,0x20`). So slot 0 on a charter
|
||||
// is a petition id by contract on both ends, and this is its one consumer here.
|
||||
//
|
||||
// The lookup is lazy: hovering an unopened charter is what sends the query, so the first
|
||||
// hover shows the name and the green line alone. The creator line right below has the same
|
||||
// rule.
|
||||
petition: (t.flags & benilla_protocol::messages::ITEM_FLAG_CHARTER != 0)
|
||||
.then(|| {
|
||||
let id = items
|
||||
.object(guid)
|
||||
.and_then(|f| f.item_enchant(0))
|
||||
.unwrap_or(0);
|
||||
petitions.tooltip_view(id as u32, guid, names, commands)
|
||||
})
|
||||
.flatten(),
|
||||
texture: icons
|
||||
.and_then(|i| i.catalog.get(t.display_info_id))
|
||||
.and_then(|d| d.icon.clone()),
|
||||
@@ -726,12 +752,19 @@ pub(crate) fn feed_containers(
|
||||
mut pending: ResMut<PendingItemOps>,
|
||||
mut lock_cleared: ResMut<LockClearedByFailure>,
|
||||
mut names: ResMut<crate::names::NameCache>,
|
||||
clock: Res<crate::ui_script::UiClock>,
|
||||
// Paired into one param (the 16-SystemParam ceiling this signature already sits at): the UI
|
||||
// clock, and the petition record cache a charter slot's tooltip lines read — `ResMut` because
|
||||
// that cache is LAZY and the hover is what issues its query.
|
||||
clock_and_petitions: (
|
||||
Res<crate::ui_script::UiClock>,
|
||||
ResMut<crate::ui_petition::PetitionState>,
|
||||
),
|
||||
mut memory: Local<crate::ui_script::VmMemo<FeedMemory>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let (clock, mut petitions) = clock_and_petitions;
|
||||
let (memory, vm_reset) = memory.get_reset(&script);
|
||||
// The gate (1439): the snapshot below is a function of the self descriptor's slot arrays,
|
||||
// the item stores (both epochs — `is_changed` on `Items`/`NameCache`/`Cooldowns` is
|
||||
@@ -745,6 +778,7 @@ pub(crate) fn feed_containers(
|
||||
let templates_moved = memory.items_templates.moved(items.template_epoch());
|
||||
let cooldowns_moved = memory.cooldown_epoch.moved(cooldowns.feed_epoch());
|
||||
let names_moved = memory.names_generation.moved(names.generation());
|
||||
let petitions_moved = memory.petition_records.moved(petitions.records_epoch());
|
||||
// The DISPLAY epoch (see `feed_char`'s twin comment): the snapshot's countdowns are
|
||||
// second-floored, so one watch step per displayable change replaces the per-frame hold-open
|
||||
// that rebuilt every bag snapshot for the whole life of a ticking enchant.
|
||||
@@ -775,6 +809,7 @@ pub(crate) fn feed_containers(
|
||||
("cooldowns", cooldowns_moved),
|
||||
("sweep", sweep),
|
||||
("names", names_moved),
|
||||
("petition-records", petitions_moved),
|
||||
("deadlines", deadlines_moved),
|
||||
("self", self_changed),
|
||||
("icons", icons_changed),
|
||||
@@ -794,6 +829,7 @@ pub(crate) fn feed_containers(
|
||||
// (`sweep_pending`'s own doc) — the slot triple flips to None right then.
|
||||
|| sweep
|
||||
|| names_moved
|
||||
|| petitions_moved
|
||||
|| deadlines_moved
|
||||
|| self_changed
|
||||
|| icons_changed
|
||||
@@ -910,6 +946,7 @@ pub(crate) fn feed_containers(
|
||||
&cooldowns,
|
||||
spell_catalog,
|
||||
&mut names,
|
||||
&mut petitions,
|
||||
now,
|
||||
ui_now,
|
||||
) {
|
||||
@@ -963,6 +1000,7 @@ pub(crate) fn feed_containers(
|
||||
&cooldowns,
|
||||
spell_catalog,
|
||||
&mut names,
|
||||
&mut petitions,
|
||||
now,
|
||||
ui_now,
|
||||
) {
|
||||
@@ -996,6 +1034,7 @@ pub(crate) fn feed_containers(
|
||||
&cooldowns,
|
||||
spell_catalog,
|
||||
&mut names,
|
||||
&mut petitions,
|
||||
now,
|
||||
ui_now,
|
||||
) {
|
||||
@@ -1045,6 +1084,7 @@ pub(crate) fn feed_containers(
|
||||
&cooldowns,
|
||||
spell_catalog,
|
||||
&mut names,
|
||||
&mut petitions,
|
||||
now,
|
||||
ui_now,
|
||||
) {
|
||||
@@ -1082,6 +1122,7 @@ pub(crate) fn feed_containers(
|
||||
&cooldowns,
|
||||
spell_catalog,
|
||||
&mut names,
|
||||
&mut petitions,
|
||||
now,
|
||||
ui_now,
|
||||
) {
|
||||
|
||||
@@ -121,7 +121,9 @@ pub(super) fn feed_petition(
|
||||
max_signatures: r.required,
|
||||
// Nil until the cache has it — and NOT part of the defer condition (module doc).
|
||||
originator: names.resolve(o.owner, &commands).map(str::to_string),
|
||||
is_originator: o.owner == me,
|
||||
// The RECORD's owner, which is what the binding compares (`0x4f447a`) — not the
|
||||
// packet's. Identical on any sane server; only one of them is the source.
|
||||
is_originator: r.owner == me,
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -206,6 +206,16 @@ pub(crate) struct PetitionState {
|
||||
/// gated on it (`0x4f3f89`), so closing a window while your own signature is in flight must
|
||||
/// **not** put `MSG_PETITION_DECLINE` on the wire.
|
||||
signing: bool,
|
||||
/// One step per landed or patched petition record — the **gate input** the charter tooltip's
|
||||
/// line 3 needs.
|
||||
///
|
||||
/// The record cache is lazy: the hover is what issues the query, so the answer arriving sets
|
||||
/// nothing any consumer already watches. `ui_items`' bag feed gates its whole snapshot rebuild
|
||||
/// on a list of epochs (decision 1439), and without this counter in that list a charter's guild
|
||||
/// lines are resolved once — to `None`, before the record exists — and never again. The live
|
||||
/// probe is what caught it; every unit test passed, because they push the record and the view
|
||||
/// in the same breath.
|
||||
records_epoch: u64,
|
||||
/// Lines composed at apply time, waiting for the feed — which is where the `script` handle
|
||||
/// lives, and so the only place a red `UI_ERROR_MESSAGE` can be fired ([`crate::ui_bank`]'s
|
||||
/// `BankErrors` shape).
|
||||
@@ -229,6 +239,14 @@ struct OpenCharter {
|
||||
/// One petition's record, from `SMSG_PETITION_QUERY_RESPONSE`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Record {
|
||||
/// The charter owner's guid — the record's `+0x8`/`+0xc`.
|
||||
///
|
||||
/// **The record's owner, not the packet's**, and the distinction is the client's own:
|
||||
/// `GetPetitionInfo`'s `isOriginator` compares the active player against `[edi+0x8]`
|
||||
/// (`0x4f447a`/`0x4f4481`), and `CanSignPetition`'s owner refusal reads `[esi+0x8]`. They hold
|
||||
/// the same value on any sane server — `SMSG_PETITION_SHOW_SIGNATURES` and
|
||||
/// `SMSG_PETITION_QUERY_RESPONSE` both name the owner — but only one of them is the source.
|
||||
owner: u64,
|
||||
/// The proposed guild's name.
|
||||
name: String,
|
||||
/// Free text — always empty on a 1.12 server.
|
||||
@@ -273,12 +291,19 @@ impl PetitionState {
|
||||
.send(ClientCommand::PetitionQuery { petition_id, item });
|
||||
}
|
||||
|
||||
/// One step per landed or patched record — see [`Self::records_epoch`]'s own field doc.
|
||||
pub(crate) fn records_epoch(&self) -> u64 {
|
||||
self.records_epoch
|
||||
}
|
||||
|
||||
/// `SMSG_PETITION_QUERY_RESPONSE` — fill the cache.
|
||||
fn apply_record(&mut self, response: PetitionQueryResponse) {
|
||||
self.records_epoch = self.records_epoch.wrapping_add(1);
|
||||
self.querying.remove(&response.petition_id);
|
||||
self.records.insert(
|
||||
response.petition_id,
|
||||
Record {
|
||||
owner: response.owner,
|
||||
name: response.name,
|
||||
body_text: response.body_text,
|
||||
required: response.min_signatures as i32,
|
||||
@@ -287,6 +312,36 @@ impl PetitionState {
|
||||
);
|
||||
}
|
||||
|
||||
/// The charter tooltip's line-3 view for `petition_id` — the guild name and its master.
|
||||
///
|
||||
/// **A lazy cache fill, and the hover is what issues the query.** A charter you have never
|
||||
/// opened has no record, so the first hover returns `None` (the plate shows the item's name and
|
||||
/// the green `<Right Click for Details>` line and nothing between) and the answer arriving
|
||||
/// repaints it. That is [`crate::names::NameCache::resolve`]'s rule, the guild-identity cache's
|
||||
/// rule, and the same shape the tooltip's own creator line already has one field up.
|
||||
///
|
||||
/// `item` rides along because `CMSG_PETITION_QUERY` carries it; vmangos ignores the field, but
|
||||
/// it is sent at its true value like every other field we can fill honestly.
|
||||
pub(crate) fn tooltip_view(
|
||||
&mut self,
|
||||
petition_id: u32,
|
||||
item: u64,
|
||||
names: &mut NameCache,
|
||||
commands: &NetCommands,
|
||||
) -> Option<benilla_ui::script::PetitionSlotView> {
|
||||
if petition_id == 0 {
|
||||
return None;
|
||||
}
|
||||
self.request_record(petition_id, item, commands);
|
||||
let rec = self.records.get(&petition_id)?;
|
||||
let (is_charter, title, owner_guid) = (rec.is_charter, rec.name.clone(), rec.owner);
|
||||
Some(benilla_ui::script::PetitionSlotView {
|
||||
is_charter,
|
||||
title,
|
||||
owner: names.resolve(owner_guid, commands).map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
/// Append one signer to the open charter — the owner's half of a successful sign
|
||||
/// (`0x4f41c0`), and it is an **append, not a re-request**.
|
||||
///
|
||||
@@ -503,6 +558,8 @@ pub(crate) mod apply {
|
||||
return;
|
||||
};
|
||||
petition.records.entry(id).or_default().name = rename.name;
|
||||
// A patched title is a changed record: the tooltip's line 3 reads it too.
|
||||
petition.records_epoch = petition.records_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -812,6 +812,7 @@ fn shipped_bag_frame_drives_end_to_end() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
|
||||
@@ -435,6 +435,7 @@ fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
@@ -516,6 +517,7 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
@@ -625,6 +627,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
@@ -645,6 +648,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
|
||||
slots.insert(
|
||||
2,
|
||||
ContainerSlot {
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
@@ -841,6 +845,7 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
@@ -930,6 +935,7 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
@@ -1062,6 +1068,7 @@ fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
|
||||
@@ -107,6 +107,7 @@ fn backpack_with_fitting_helm() -> benilla_ui::script::ContainerState {
|
||||
slots.insert(
|
||||
1,
|
||||
benilla_ui::script::ContainerSlot {
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
|
||||
@@ -319,6 +319,7 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
|
||||
@@ -32,6 +32,7 @@ fn one_item_backpack() -> ContainerState {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
|
||||
@@ -25,6 +25,7 @@ fn one_item_backpack() -> ContainerState {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
@@ -237,6 +238,7 @@ fn escape_closes_an_open_stack_split_frame() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
|
||||
@@ -700,6 +700,7 @@ fn backpack() -> ContainerState {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
|
||||
@@ -376,6 +376,59 @@ fn auto_unstuck_casts_and_closes() {
|
||||
assert!(!s.eval::<bool>("return HelpFrame:IsVisible()").unwrap());
|
||||
}
|
||||
|
||||
/// **The retail-only text is gone from the Home page** (director's call, 2026-08-29).
|
||||
///
|
||||
/// The page is entirely GlobalStrings off the player's own chain, so three of its strings still
|
||||
/// pointed at `worldofwarcraft.com` — a dead PvP-policy link, and a closing paragraph directing the
|
||||
/// player to Blizzard's forums and policy pages. The window overrides them before the markup
|
||||
/// resolves.
|
||||
///
|
||||
/// This test exists because the failure mode is *invisible in a test suite*: nothing breaks if the
|
||||
/// override is dropped, the page simply starts advertising dead links again. Asserted on the
|
||||
/// rendered FontStrings rather than the globals, so it also catches the markup being re-pointed at
|
||||
/// a different key.
|
||||
#[test]
|
||||
fn the_home_page_advertises_no_dead_retail_links() {
|
||||
let s = setup();
|
||||
s.run("ShowUIPanel(HelpFrame) HelpFrame_ShowFrame(\"Home\")")
|
||||
.unwrap();
|
||||
|
||||
for frame in [
|
||||
"HelpFrameHomePvpPolicyUrl",
|
||||
"HelpFrameHomeText2",
|
||||
"HelpFrameHomeIssue3",
|
||||
"HelpFrameHomeText1",
|
||||
] {
|
||||
let text = s
|
||||
.eval::<String>(&format!("return {frame}:GetText() or \"\""))
|
||||
.unwrap()
|
||||
.to_lowercase();
|
||||
for dead in [
|
||||
"worldofwarcraft.com",
|
||||
"http://",
|
||||
"www.",
|
||||
".shtml",
|
||||
"the forums",
|
||||
] {
|
||||
assert!(
|
||||
!text.contains(dead),
|
||||
"{frame} still carries retail-only text ({dead:?}): {text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The PvP guidance itself is KEPT — only the sentence introducing the link went. A test that
|
||||
// let the whole bullet be emptied would pass on an over-correction, which is the other way to
|
||||
// get this wrong.
|
||||
let pvp = s
|
||||
.eval::<String>("return HelpFrameHomeIssue3:GetText()")
|
||||
.unwrap();
|
||||
assert!(
|
||||
pvp.contains("PVP game mechanics"),
|
||||
"the PvP guidance must survive the link's removal: {pvp:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The geometry oracle** (decision 0675): every element the transcription shares with the
|
||||
/// reference file carries the reference's own `<AbsDimension>` numbers.
|
||||
///
|
||||
|
||||
@@ -187,6 +187,22 @@ fn a_signers_charter_shows_the_sign_face_and_nine_rows() {
|
||||
);
|
||||
}
|
||||
|
||||
// The charter's own static furniture — the three labels, the nine rows and the instructions
|
||||
// all draw, not merely hold text.
|
||||
for part in [
|
||||
"PetitionFrameCharterTitle",
|
||||
"PetitionFrameCharterName",
|
||||
"PetitionFrameMasterTitle",
|
||||
"PetitionFrameMasterName",
|
||||
"PetitionFrameMemberTitle",
|
||||
"PetitionFrameMemberName1",
|
||||
"PetitionFrameMemberName9",
|
||||
"PetitionFrameInstructions",
|
||||
"PetitionFrameCancelButton",
|
||||
"PetitionFrameCloseButton",
|
||||
] {
|
||||
assert!(visible(&s, part), "{part} is on screen");
|
||||
}
|
||||
assert!(visible(&s, "PetitionFrameSignButton"));
|
||||
assert!(!visible(&s, "PetitionFrameRequestButton"));
|
||||
assert!(
|
||||
@@ -348,6 +364,16 @@ fn the_registrar_opens_on_services_and_purchase_is_a_local_panel_swap() {
|
||||
assert!(visible(&s, "GuildRegistrarFrame"));
|
||||
assert!(visible(&s, "GuildRegistrarGreetingFrame"));
|
||||
assert!(!visible(&s, "GuildRegistrarPurchaseFrame"));
|
||||
// **The two service rows must be VISIBLE, not merely labelled.** This assertion is here
|
||||
// because its absence shipped the bug: the rows inherited `hidden="true"` from a template
|
||||
// modelled on the quest list's POOLED rows, which are shown one at a time by code. These are
|
||||
// static and nothing ever calls `:Show()` on them, so both were permanently invisible — the
|
||||
// window came up as a bare "Available Services" heading over blank parchment — while every
|
||||
// `GetText()` assertion below passed, because a hidden button still knows its own label.
|
||||
// Decision 0672's lesson in another key: a frame that loads is not a frame that draws.
|
||||
for row in ["GuildRegistrarButton1", "GuildRegistrarButton2"] {
|
||||
assert!(visible(&s, row), "{row} is on screen, not just loaded");
|
||||
}
|
||||
assert_eq!(
|
||||
text(&s, "GuildRegistrarButton1"),
|
||||
"Purchase a Guild Charter"
|
||||
@@ -361,6 +387,18 @@ fn the_registrar_opens_on_services_and_purchase_is_a_local_panel_swap() {
|
||||
s.run("GuildRegistrarButton1:Click()").unwrap();
|
||||
assert!(visible(&s, "GuildRegistrarPurchaseFrame"));
|
||||
assert!(!visible(&s, "GuildRegistrarGreetingFrame"));
|
||||
// The purchase panel's own furniture, for the reason the services rows above are checked: a
|
||||
// window whose parts load but do not draw passes every text assertion.
|
||||
for part in [
|
||||
"GuildRegistrarPurchaseText",
|
||||
"GuildRegistrarCostLabel",
|
||||
"GuildRegistrarMoneyFrame",
|
||||
"GuildRegistrarFrameEditBox",
|
||||
"GuildRegistrarFramePurchaseButton",
|
||||
"GuildRegistrarFrameCancelButton",
|
||||
] {
|
||||
assert!(visible(&s, part), "{part} is on screen");
|
||||
}
|
||||
assert_eq!(calls(&s), "", "the swap sends nothing");
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ fn seed_items(s: &mut UiScript) {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
|
||||
@@ -130,6 +130,12 @@ pub(crate) fn feed_interact_npc(
|
||||
// session is already gone. Without this arm the window's portrait is the auctioneer's black
|
||||
// disc all over again and its name banner is blank.
|
||||
registrar: Option<Res<crate::ui_petition::GuildRegistrarState>>,
|
||||
// The stable master's, while the pet stable is open (decision 1684) — the black disc a THIRD
|
||||
// time, and this one could not have leaned on the gossip arm even by accident: benilla asks a
|
||||
// menuless stable master for its pet list DIRECTLY on the interact leg (decision 1680, the
|
||||
// client's own `0x5f05bc` path), so there is no gossip session behind it at all. The registrar
|
||||
// arm above is the same shape for the same reason.
|
||||
stable: Option<Res<crate::ui_stable::StableOpen>>,
|
||||
index: Option<Res<GuidIndex>>,
|
||||
mut out: ResMut<InteractNpc>,
|
||||
) {
|
||||
@@ -142,7 +148,8 @@ pub(crate) fn feed_interact_npc(
|
||||
.or_else(|| trade.and_then(|s| s.npc()))
|
||||
.or_else(|| bank.and_then(|s| s.npc()))
|
||||
.or_else(|| auction.and_then(|s| s.npc()))
|
||||
.or_else(|| registrar.and_then(|s| s.npc()));
|
||||
.or_else(|| registrar.and_then(|s| s.npc()))
|
||||
.or_else(|| stable.and_then(|s| s.npc()));
|
||||
// Field 0 is the entity the portrait booth bakes and the facing chain steers by; field 1 is
|
||||
// the same NPC's **guid**, which `crate::ui_unit`'s feed needs to resolve the `"npc"` unit
|
||||
// token's name (a name lives in the `NameCache`, keyed by guid — there is no way back to one
|
||||
@@ -262,13 +269,124 @@ mod tests {
|
||||
"the auctioneer's own portrait"
|
||||
);
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::ui_auction::AuctionOpen>()
|
||||
.clear();
|
||||
|
||||
// The stable master's (decision 1684) — the same black disc, reported by the director on
|
||||
// 2026-08-28. Note WHERE it shipped from: this very test already existed, with the
|
||||
// paragraph above it saying every portrait window must be in the chain — and the stable
|
||||
// still went out black, because the guard is **opt-in per session**. A window nobody
|
||||
// remembers to add here is a window nobody's test covers. Worse for this one than for the
|
||||
// auction house: benilla asks a menuless stable master for its list directly on the
|
||||
// interact leg (decision 1680), so there is no gossip session to accidentally carry it.
|
||||
app.init_resource::<crate::ui_stable::StableOpen>();
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::ui_stable::StableOpen>()
|
||||
.open(0x42, 2, vec![]);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world().resource::<InteractNpc>().0,
|
||||
Some(npc),
|
||||
"the stable master's own portrait"
|
||||
);
|
||||
|
||||
// Every session closes → None again.
|
||||
app.world_mut().resource_mut::<GossipState>().close();
|
||||
app.world_mut().resource_mut::<MerchantOpen>().close();
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::ui_auction::AuctionOpen>()
|
||||
.resource_mut::<crate::ui_stable::StableOpen>()
|
||||
.clear();
|
||||
app.update();
|
||||
assert_eq!(app.world().resource::<InteractNpc>().0, None);
|
||||
}
|
||||
|
||||
/// **The structural tripwire the three black discs earned** (decision 1684).
|
||||
///
|
||||
/// The assertions above are opt-in: each names one session, and a window nobody remembers to
|
||||
/// add is a window nobody covers. That is not hypothetical — the auction house shipped a black
|
||||
/// portrait (0x-2026-08-22), the guild registrar was caught only because someone reasoned about
|
||||
/// the gossip close, and the stable master shipped black on 2026-08-28 with this very test
|
||||
/// already in the file telling everyone to add their session to it.
|
||||
///
|
||||
/// So this one is exhaustive by construction: it reads the app's own sources, finds every
|
||||
/// `impl NpcSession for X`, and requires each `X` to be either **in the `.or` chain** or on the
|
||||
/// **explicit exclusion list** below with a stated reason. A new NPC window cannot be silent —
|
||||
/// it either wires its portrait or it says why it has none.
|
||||
#[test]
|
||||
fn every_npc_session_is_portrait_bound_or_explicitly_excluded() {
|
||||
use std::path::Path;
|
||||
|
||||
/// Sessions that deliberately do NOT own the `"npc"` portrait token, each with the reason
|
||||
/// it does not. Adding a name here is a decision; leaving one out is a black disc.
|
||||
const EXCLUDED: &[(&str, &str)] = &[
|
||||
// Decision 0544: the mail window's icon is authored art, not a unit-model bake, so it
|
||||
// has no portrait to point and must not steal the token from a window that does.
|
||||
("MailOpen", "its window icon is art, not a unit bake (0544)"),
|
||||
// Reached only from inside an open gossip menu, which is still open behind it and is
|
||||
// the FIRST arm of the chain — so the trainer's own NPC is already the answer. Unlike
|
||||
// the registrar's case, the server does not close the menu first.
|
||||
("TalentWipeState", "rides the still-open gossip session"),
|
||||
("BinderState", "rides the still-open gossip session"),
|
||||
];
|
||||
|
||||
// The chain's own source is the authority on what is wired — not a hand-copied list here,
|
||||
// which would be the very drift this test exists to catch.
|
||||
let this_file = include_str!("ui_session.rs");
|
||||
let chain = this_file
|
||||
.split_once("pub(crate) fn feed_interact_npc")
|
||||
.expect("feed_interact_npc")
|
||||
.1
|
||||
.split_once("\n}\n")
|
||||
.expect("end of feed_interact_npc")
|
||||
.0;
|
||||
|
||||
let mut missing = Vec::new();
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut stack = vec![root];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).expect("read_dir") {
|
||||
let path = entry.expect("dir entry").path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
if path.extension().is_none_or(|e| e != "rs") {
|
||||
continue;
|
||||
}
|
||||
// This file itself: the trait lives here and no session implements it here, but
|
||||
// the prose above spells the pattern out — so scanning ourselves finds the doc
|
||||
// comment and nothing real.
|
||||
if path.file_name().is_some_and(|f| f == "ui_session.rs") {
|
||||
continue;
|
||||
}
|
||||
let src = std::fs::read_to_string(&path).expect("read source");
|
||||
for (_, rest) in src
|
||||
.match_indices("impl NpcSession for ")
|
||||
.map(|(i, _)| (i, &src[i + "impl NpcSession for ".len()..]))
|
||||
{
|
||||
let ty: String = rest
|
||||
.chars()
|
||||
.take_while(|c| c.is_alphanumeric() || *c == '_')
|
||||
.collect();
|
||||
if ty.is_empty() || EXCLUDED.iter().any(|(name, _)| *name == ty) {
|
||||
continue;
|
||||
}
|
||||
// Wired = the chain mentions the type. The chain names each session by its
|
||||
// resource type in a `Res<...>` parameter, so a substring test is exact enough
|
||||
// and cannot be fooled by a comment (comments naming a type still mean someone
|
||||
// thought about it, which is the point).
|
||||
if !chain.contains(&ty) {
|
||||
missing.push(format!("{ty} (in {})", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"these NpcSession windows own no `\"npc\"` portrait and are not on the exclusion \
|
||||
list, so each renders a BLACK DISC where the NPC's face goes — wire them into \
|
||||
`feed_interact_npc`'s chain, or add them to EXCLUDED with a reason: {missing:#?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,12 @@ impl Plugin for UiStablePlugin {
|
||||
close_npc_session_out_of_range::<StableOpen>.before(feed_stable),
|
||||
feed_stable.before(UiInput),
|
||||
drain_stable.after(UiInput),
|
||||
// AFTER the VM ticks, unlike every other booth feed: this one reads the
|
||||
// *selection*, which a click on a slot writes during `UiInput` — and
|
||||
// `PetStable_Update`'s own re-pick (`PetStable.lua:44-59`) runs inside the
|
||||
// event dispatch `feed_stable` performs. Reading it before the tick would show
|
||||
// the previous pet for a frame after every click.
|
||||
feed_stable_booth.after(UiInput),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -327,6 +333,85 @@ fn drain_stable(
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the stable window's model pane at the pet `GetSelectedStablePet()` names — the app half of
|
||||
/// the reference's `SetPetStablePaperdoll` (wow-re `ui/scratch/stable-master-window.md` §7.1).
|
||||
///
|
||||
/// **The reference's own fork, and it is the whole of this system.** On `[0xb72250] == -1` — the
|
||||
/// summoned pet — it resolves the live pet by GUID and takes `[unit+0xb30]`'s model; the resolve
|
||||
/// failing (`0x4cb9bc`) falls through to the *same* path everything else takes: map the selection
|
||||
/// back to a record, and go through the **creature cache** on that record's creature entry, with a
|
||||
/// callback that fires `PET_STABLE_UPDATE_PAPERDOLL` when a late answer lands.
|
||||
///
|
||||
/// So the pane has two sources and one of them is not a unit at all:
|
||||
///
|
||||
/// - A **live** pet gets [`StableBooth::unit`], and the pane is the pet paper doll's case exactly
|
||||
/// (decision 1057) — the booth mirrors the world body.
|
||||
/// - Everything else gets [`StableBooth::display_id`], off the creature query's
|
||||
/// [`crate::names::CreatureRecord::display_id`]. That covers every stabled pet *and* slot 0 while
|
||||
/// the pet is dismissed or too far to summon: the server still sends its row, and the reference
|
||||
/// still draws it.
|
||||
///
|
||||
/// A selection with no answer yet is `None`/`None`, which empties the pane; the query's arrival
|
||||
/// re-points it on a later frame, which is what the reference's cache callback is for.
|
||||
fn feed_stable_booth(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
open: Res<StableOpen>,
|
||||
bar: Res<crate::ui_pet::PetBar>,
|
||||
pet: crate::ui_pet::PetUnit,
|
||||
names: Res<NameCache>,
|
||||
mut booth: ResMut<crate::portrait::StableBooth>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
// Written every frame, selection or none — a stale yaw would snap the model the moment one is
|
||||
// picked (the pet paper doll's arrangement).
|
||||
booth.yaw = script.stable_yaw();
|
||||
|
||||
// `GetSelectedStablePet()`'s own answer: `0` = the summoned pet, `1..=2` a bought stable slot,
|
||||
// `-1` nothing. Reading the binding's translation rather than the raw petNumber is deliberate —
|
||||
// it is what already degrades a *stale* selection (a pet the latest list no longer holds) to
|
||||
// "nothing" instead of to a wrong slot.
|
||||
let slot = script.stable_selection();
|
||||
|
||||
let live_pet = bar.spells.pet_guid;
|
||||
let (unit, display_id) = stable_subject(
|
||||
slot,
|
||||
(live_pet != 0).then(|| pet.entity(live_pet)).flatten(),
|
||||
&open.pets,
|
||||
|entry| names.creature_record(entry).map(|r| r.display_id),
|
||||
);
|
||||
booth.unit = unit;
|
||||
booth.display_id = display_id;
|
||||
}
|
||||
|
||||
/// The selection → `(live unit, display id)` fork, pure so the law is testable without a VM, a
|
||||
/// wire or a world (module doc on [`feed_stable_booth`] for the reference's shape).
|
||||
///
|
||||
/// **The live arm consults no row at all** — §7.1's `[0xb72250] == -1` branch reads the live pet's
|
||||
/// GUID out of `[0xb714a0]` and takes its model; the stable array is not in that path. So a
|
||||
/// summoned pet is drawn from its world body whether or not the list happened to carry its row.
|
||||
///
|
||||
/// **The display arm is resolved regardless**, because that is the reference's own order: the live
|
||||
/// resolve failing (a dismissed pet, an object that has not streamed) falls straight through to the
|
||||
/// creature cache, and having the answer already in hand means the pane does not blank for the
|
||||
/// frames a pet spends being dismissed. `0` is the template's "no display" and reads as no answer —
|
||||
/// the reference's cache miss draws nothing either.
|
||||
fn stable_subject(
|
||||
slot: i32,
|
||||
live_pet: Option<Entity>,
|
||||
pets: &[StabledPet],
|
||||
display_of: impl Fn(u32) -> Option<u32>,
|
||||
) -> (Option<Entity>, Option<u32>) {
|
||||
let unit = if slot == 0 { live_pet } else { None };
|
||||
let display = u8::try_from(slot)
|
||||
.ok()
|
||||
.and_then(|slot| pets.iter().find(|p| p.slot == slot))
|
||||
.and_then(|row| display_of(row.creature_entry))
|
||||
.filter(|&d| d != 0);
|
||||
(unit, display)
|
||||
}
|
||||
|
||||
/// Stable refusals staged for the feed to surface on the window's error line — only ever
|
||||
/// `ERR_NOT_ENOUGH_MONEY`, the single code the client speaks for (decision 1677).
|
||||
#[derive(Resource, Default)]
|
||||
@@ -336,3 +421,87 @@ pub(crate) struct StableErrors(pub(crate) Vec<String>);
|
||||
/// table quotes `0`, which is the same shape as "past the ladder": degraded, never wrong.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct StableSlotPrices(pub(crate) benilla_formats::StableSlotPrices);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn row(slot: u8, entry: u32) -> StabledPet {
|
||||
StabledPet {
|
||||
pet_number: u32::from(slot) + 100,
|
||||
creature_entry: entry,
|
||||
level: 30,
|
||||
name: format!("pet{slot}"),
|
||||
loyalty: 6,
|
||||
slot,
|
||||
}
|
||||
}
|
||||
|
||||
/// One display per entry; entry `9` is the template that ships none.
|
||||
fn display_of(entry: u32) -> Option<u32> {
|
||||
match entry {
|
||||
1 => Some(4449),
|
||||
2 => Some(822),
|
||||
9 => Some(0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole fork, arm by arm — this is what a blank pane looks like when it is wrong, and no
|
||||
/// build gate can see it.
|
||||
#[test]
|
||||
fn the_selection_forks_between_a_live_body_and_a_bare_display() {
|
||||
let pets = [row(0, 1), row(1, 2)];
|
||||
let live = Entity::from_raw_u32(7).expect("a test entity id");
|
||||
|
||||
// Nothing selected: neither source, so the pane empties.
|
||||
assert_eq!(
|
||||
stable_subject(-1, Some(live), &pets, display_of),
|
||||
(None, None)
|
||||
);
|
||||
|
||||
// Slot 0 with the pet out: the world body, and the display resolved beside it so a dismiss
|
||||
// does not blank the pane.
|
||||
assert_eq!(
|
||||
stable_subject(0, Some(live), &pets, display_of),
|
||||
(Some(live), Some(4449))
|
||||
);
|
||||
|
||||
// Slot 0 with the pet dismissed (a row, no live guid): the display alone — the case that
|
||||
// has no world entity anywhere and the whole reason the stand-in exists.
|
||||
assert_eq!(
|
||||
stable_subject(0, None, &pets, display_of),
|
||||
(None, Some(4449))
|
||||
);
|
||||
|
||||
// A stabled slot NEVER takes the live body, even while a pet is out: the reference's live
|
||||
// arm is gated on the selection being the summoned pet, not on one existing.
|
||||
assert_eq!(
|
||||
stable_subject(1, Some(live), &pets, display_of),
|
||||
(None, Some(822))
|
||||
);
|
||||
|
||||
// A slot with no row (never bought, or empty) and a slot past the window: nothing.
|
||||
assert_eq!(
|
||||
stable_subject(2, Some(live), &pets, display_of),
|
||||
(None, None)
|
||||
);
|
||||
}
|
||||
|
||||
/// A row whose creature query has not answered yet, and one whose template ships display `0`,
|
||||
/// both read as "no answer" — the reference's cache miss, which draws nothing rather than
|
||||
/// drawing wrong.
|
||||
#[test]
|
||||
fn an_unanswered_or_display_less_template_empties_the_pane() {
|
||||
assert_eq!(
|
||||
stable_subject(1, None, &[row(1, 404)], display_of),
|
||||
(None, None),
|
||||
"the query has not landed yet"
|
||||
);
|
||||
assert_eq!(
|
||||
stable_subject(1, None, &[row(1, 9)], display_of),
|
||||
(None, None),
|
||||
"the template ships no display"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,16 @@ impl Element {
|
||||
.is_some_and(|v| v.eq_ignore_ascii_case("true"))
|
||||
}
|
||||
|
||||
/// The same, but **presence-aware** — `None` when the attribute is absent.
|
||||
///
|
||||
/// [`attr_bool`](Self::attr_bool) folds "absent" and `="false"` together, which is fine for a
|
||||
/// flag whose default is off and *silently wrong* for one whose default is on: it turns an
|
||||
/// explicit opt-out into a no-op. `<EditBox autoFocus="false">` is exactly that flag, and the
|
||||
/// shipped UI writes it ten times.
|
||||
pub fn attr_bool_opt(&self, name: &str) -> Option<bool> {
|
||||
self.attr(name).map(|v| v.eq_ignore_ascii_case("true"))
|
||||
}
|
||||
|
||||
/// The `name` attribute, if any.
|
||||
pub fn name(&self) -> Option<&str> {
|
||||
self.attr("name")
|
||||
|
||||
@@ -876,6 +876,46 @@ mod loader_tests {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **`autoFocus="false"` is an opt-OUT, and the only reason the attribute is ever written.**
|
||||
///
|
||||
/// The ctor leaves `flags = 1` — autoFocus ON — so every one of the ten places the shipped
|
||||
/// 1.12.1 chain names the attribute names it to turn the flag *off* (MailFrame ×3,
|
||||
/// MoneyInputFrame ×3, FriendsFrame ×3, AddonList ×1). The flag loop used to call the setter
|
||||
/// only when an attribute parsed as `true`, which made all ten a no-op: harmless while our own
|
||||
/// default was off, and exactly backwards once it matches the client's (decision 1686).
|
||||
///
|
||||
/// Both directions asserted, because a presence-blind reader passes the `true` half.
|
||||
#[test]
|
||||
fn an_editbox_flag_written_false_in_xml_turns_the_flag_off() {
|
||||
let s = UiScript::new().unwrap();
|
||||
let doc = parse(
|
||||
r#"<Ui>
|
||||
<EditBox name="OptOut" autoFocus="false"/>
|
||||
<EditBox name="OptIn" autoFocus="true"/>
|
||||
<EditBox name="Silent"/>
|
||||
</Ui>"#,
|
||||
);
|
||||
let report = load(&s, &doc, &no_files);
|
||||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||||
|
||||
// The box is hidden first so the Show() is a real transition; an autoFocus box takes the
|
||||
// keyboard on show, which is the observable the flag actually drives.
|
||||
for (name, want) in [("OptOut", false), ("OptIn", true), ("Silent", true)] {
|
||||
s.run(&format!(
|
||||
"{name}:Hide(); {name}:ClearFocus(); {name}:Show()"
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<bool>(&format!("return {name}:HasFocus()"))
|
||||
.unwrap(),
|
||||
want,
|
||||
"{name}: autoFocus should be {want} (absent = the ctor default, ON)",
|
||||
);
|
||||
s.run(&format!("{name}:ClearFocus()")).unwrap();
|
||||
}
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
// ── TexCoords + Font objects (decision 0084 engine slice) ───────────────────────────────────
|
||||
|
||||
/// `<TexCoords left right top bottom>` on a `<Texture>` parses into the region's UV rect and
|
||||
|
||||
@@ -276,10 +276,19 @@ impl Loader<'_> {
|
||||
/// `<EditBox>` extras (RF-0082 §"THE STRUCTURAL KEY"/§3): the `letters` cap → SetMaxLetters,
|
||||
/// `historyLines` → SetHistoryLines (the submitted-line recall ring), and the config flags
|
||||
/// `autoFocus`/`numeric`/`password`/`multiLine`/`ignoreArrows` → their setters.
|
||||
/// A flag absent from the XML stays at its ctor default (`false`) — a **documented divergence**
|
||||
/// from the UI.xsd, whose `autoFocus` default is `true`; benilla applies the C++ ctor default
|
||||
/// (flags=0) uniformly for CreateFrame and XML, since autoFocus never focuses on show and click
|
||||
/// focus is unconditional, so the practical effect is confined to keyboard self-acquire.
|
||||
/// A flag absent from the XML stays at its ctor default, and the ctor's own value is
|
||||
/// `flags = 1` — **`autoFocus` defaults ON**, every other flag off (`0x779a29 mov eax,1` /
|
||||
/// `0x779a2e mov [esi+0x318],eax`; LoadXML's `autoFocus` leg writes nothing for an absent or
|
||||
/// empty attribute, `0x77a0b3`/`0x77a0b8`). So the UI.xsd's `true` default is the client's too,
|
||||
/// and the divergence documented here — benilla applying `flags = 0` uniformly — is retired
|
||||
/// (decision 1686). Its stated justification, "autoFocus never focuses on show", was wow-re's
|
||||
/// own `call`-only-census error, corrected 2026-08-29.
|
||||
///
|
||||
/// **The flags are read presence-aware.** This loop used to call the setter only when an
|
||||
/// attribute parsed as `true`, so `autoFocus="false"` was a no-op — harmless while the default
|
||||
/// was off, and precisely backwards once it is on: the ten boxes in the shipped chain that opt
|
||||
/// OUT (MailFrame ×3, MoneyInputFrame ×3, FriendsFrame ×3, AddonList ×1) are the only places
|
||||
/// the attribute appears at all.
|
||||
/// `<TextInsets>` maps to `SetTextInsets`; `blinkSpeed` → SetBlinkSpeed (the caret
|
||||
/// half-period, `E+0x370`). A declared `<FontString>` is ASSIGNED as the box's text region by
|
||||
/// the special-fontstring pass (`adopt_text_region` — the engine's LoadXML slot, never a
|
||||
@@ -326,8 +335,8 @@ impl Loader<'_> {
|
||||
("multiLine", "SetMultiLine"),
|
||||
("ignoreArrows", "SetIgnoreArrows"),
|
||||
] {
|
||||
if el.attr_bool(attr) {
|
||||
self.call(wrapper, method, true, dbg);
|
||||
if let Some(on) = el.attr_bool_opt(attr) {
|
||||
self.call(wrapper, method, on, dbg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,33 @@ use mlua::{Lua, Value};
|
||||
use super::cursor::{self, CursorItem, CursorPayload};
|
||||
use super::Model;
|
||||
|
||||
/// The charter/petition lines an item tooltip prints under its name — wow-re
|
||||
/// `system/ui/scratch/tooltip-content-law.md`'s **line 3**: *"(petition/guild-charter items) Title /
|
||||
/// Creator / Num-signatures (white; title wraps) — a resolved petition object; keys
|
||||
/// `PETITION_*`/`GUILD_CHARTER_*`"*.
|
||||
///
|
||||
/// The two key families are picked by [`Self::is_charter`], the same record bit that picks
|
||||
/// `GetPetitionInfo`'s first return — so a charter reads *"Guild Name:"* / *"Guild Master:"* and a
|
||||
/// plain petition reads *"Petition:"* / *"Created by"*.
|
||||
///
|
||||
/// **The third line, the signature count, is deliberately NOT here.** The law names it, but neither
|
||||
/// the carve nor the packet says where the number comes from: the petition record carries no count,
|
||||
/// and the only candidate — the item's `ITEM_FIELD_ENCHANTMENT` slot-0 *charges* dword, which
|
||||
/// vmangos's own source documents as "the on-item signature count" — has its write **commented
|
||||
/// out** server-side, so it is always zero and the guess could never be falsified here. Omitted
|
||||
/// rather than invented; the line is one field away the day someone pins the source.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct PetitionSlotView {
|
||||
/// The record's charter bit: `true` picks the `GUILD_CHARTER_*` keys, `false` the `PETITION_*`
|
||||
/// ones.
|
||||
pub is_charter: bool,
|
||||
/// The proposed guild's (or petition's) name.
|
||||
pub title: String,
|
||||
/// The owner's name, app-resolved through the name cache. `None` while that query is in
|
||||
/// flight — the line is withheld, as the creator line is, rather than printed with a hole.
|
||||
pub owner: Option<String>,
|
||||
}
|
||||
|
||||
/// One occupied bag slot, resolved by the app (icon from ItemDisplayInfo, count/quality from the
|
||||
/// item object + template). Plain data.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -78,6 +105,14 @@ pub struct ContainerSlot {
|
||||
/// hands over the row's name plus the three facts the line law needs to place and paint it.
|
||||
/// Empty = unenchanted, or the enchant DBC never loaded. Decisions 0915/0920.
|
||||
pub enchants: Vec<super::EnchantView>,
|
||||
/// The petition this item names, when it is a signable charter — **line 3 of the tooltip's
|
||||
/// emission law**, between the NAME and the green `ITEM_SIGNABLE` line.
|
||||
///
|
||||
/// `None` for every ordinary item, and also for a charter whose petition record has not
|
||||
/// arrived: the hover is what ISSUES that query, so the first hover of an unopened charter
|
||||
/// shows the name and the green line, and the two guild lines appear on the repaint. Exactly
|
||||
/// [`Self::creator`]'s shape one field up, for exactly its reason.
|
||||
pub petition: Option<PetitionSlotView>,
|
||||
}
|
||||
|
||||
/// One enchant slot as the tooltip renders it (wow-re `ui/scratch/tooltip-content-law.md` §E3,
|
||||
@@ -718,6 +753,7 @@ mod tests {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
@@ -883,6 +919,7 @@ mod tests {
|
||||
state.slots.insert(
|
||||
5,
|
||||
ContainerSlot {
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
|
||||
@@ -657,6 +657,7 @@ mod tests {
|
||||
slots.insert(
|
||||
1,
|
||||
crate::script::container::ContainerSlot {
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
|
||||
@@ -314,6 +314,7 @@ mod tests {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
@@ -494,6 +495,7 @@ mod tests {
|
||||
slots.insert(
|
||||
3,
|
||||
ContainerSlot {
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
|
||||
@@ -5,10 +5,19 @@
|
||||
//!
|
||||
//! - **Focus (§1):** a single class-owned focus owner ([`Model::focused_editbox`], the client's
|
||||
//! `DAT_00cf4dc8`). `SetFocus` gates on effective-visibility, is a no-op if already focused, and
|
||||
//! fires `OnEditFocusLost` on the old box then `OnEditFocusGained` on the new. `autoFocus` does NOT
|
||||
//! focus on show — it only lets a box self-acquire on the first keyboard/char event while nothing is
|
||||
//! focused (verified by absence: no OnShow/OnLoad `SetFocus` caller). A LeftButton click focuses
|
||||
//! UNCONDITIONALLY.
|
||||
//! fires `OnEditFocusLost` on the old box then `OnEditFocusGained` on the new. A LeftButton click
|
||||
//! focuses UNCONDITIONALLY — and **collapses the selection to the clicked byte index** on its way
|
||||
//! (`0x77b86f call 0x77ccf0`, immediately before `0x77b881 SetFocus`), so a fresh click-focus leaves
|
||||
//! an EMPTY selection at the click point, never a select-all. `SetFocus` itself writes no selection
|
||||
//! field, and `0x77e3f6` is the only instruction image-wide that gives a box focus — so *every*
|
||||
//! focus gain, whatever triggers it, is selection-neutral.
|
||||
//!
|
||||
//! **`autoFocus` DOES focus on show** (corrected 2026-08-29, wow-re `editbox-selection-focus-law.md`
|
||||
//! §6): the OnShow override tail-jumps `SetFocus` when nothing else holds focus, and the OnHide
|
||||
//! mirror tail-jumps `ClearFocus`. The old "verified by absence" negative came from a `call`-only
|
||||
//! census that could not see a tail-`jmp`. **Not implemented here yet** — see [`EditBoxState::
|
||||
//! auto_focus`](crate::widget::EditBoxState::auto_focus) for why it waits on the attribute default.
|
||||
//! The self-acquire-on-first-key half stands and is what this module implements.
|
||||
//! - **Routing (§2):** a focused box processes and CONSUMES every key/char (`return 1` past the
|
||||
//! guard); an unfocused non-autoFocus box ignores input. The override fires ONLY the specialized
|
||||
//! scripts (Enter/Escape/Space/Tab/TextChanged/TextSet/focus), never generic `OnKeyDown`/`OnChar`.
|
||||
@@ -217,6 +226,10 @@ fn topmost_autofocus(lua: &Lua) -> Option<FrameHandle> {
|
||||
|
||||
/// `SetFocus` (`0x77e3d0`): gate on effective-visibility, no-op if already focused; else fire
|
||||
/// `OnEditFocusLost` on the old box, move focus, fire `OnEditFocusGained` on the new.
|
||||
///
|
||||
/// `0x77e3f6` (inside this) is the ONLY instruction image-wide that grants a box the focus, and it
|
||||
/// writes no selection field — so every focus gain in the client, whatever triggers it, is
|
||||
/// selection-neutral.
|
||||
fn set_focus_handle(lua: &Lua, h: FrameHandle) {
|
||||
let (old_id, new_id) = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
@@ -243,7 +256,43 @@ fn set_focus_handle(lua: &Lua, h: FrameHandle) {
|
||||
fire_script(lua, new_id, "OnEditFocusGained");
|
||||
}
|
||||
|
||||
/// `ClearFocus` (`0x77e410`): only if this box holds focus; fire `OnEditFocusLost`.
|
||||
/// The EditBox's own **OnShow/OnHide vtable overrides** (`0x81c910` slots +0x30/+0x34), run by
|
||||
/// [`crate::script::event::fire_visibility_changes`] after the frame's Lua handler — the order the
|
||||
/// reference has, since both overrides call the base notify *first* and only then act.
|
||||
///
|
||||
/// - **Show** (`0x77a750`): `if ([0xcf4dc8] == 0 && (flags & 1)) SetFocus(this)` — an `autoFocus`
|
||||
/// box grabs the keyboard when it appears, **iff nothing else holds it**. Not a
|
||||
/// "topmost/best" choice: whichever box's show runs first while the focus is free takes it.
|
||||
/// - **Hide** (`0x77a780`): tail-jumps `ClearFocus`, whose own guard makes it per-box — hiding a
|
||||
/// box that does not hold the keyboard writes nothing and fires nothing.
|
||||
///
|
||||
/// Both were missing until 2026-08-29 (decision 1686). wow-re had published "autoFocus does NOT
|
||||
/// focus on show — VERIFIED by enumerating callers" off a census written over `call` alone, which
|
||||
/// cannot see the override's tail-`jmp`; benilla had transcribed the negative.
|
||||
pub(super) fn visibility_focus(lua: &Lua, h: FrameHandle, visible: bool) {
|
||||
if !visible {
|
||||
// The guard lives in `clear_focus_handle`, exactly as it does in `0x77e410` — so this is
|
||||
// called unconditionally here, like the reference's own unconditional tail-jmp.
|
||||
clear_focus_handle(lua, h);
|
||||
return;
|
||||
}
|
||||
let wants = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
model.focused_editbox.is_none()
|
||||
&& matches!(
|
||||
model.arena.frame(h).map(|f| &f.kind_state),
|
||||
Some(KindState::EditBox(eb)) if eb.auto_focus,
|
||||
)
|
||||
};
|
||||
if wants {
|
||||
// `SetFocus` re-checks effective-visibility itself, which is the reference's gate too.
|
||||
set_focus_handle(lua, h);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClearFocus` (`0x77e410`): only if this box holds focus; fire `OnEditFocusLost`. The guard is
|
||||
/// `mov eax,ecx; mov ecx,[0xcf4dc8]; cmp ecx,eax; jne ret` — verified per-box, which is what makes
|
||||
/// the OnHide override's *unconditional* tail-jmp into it harmless.
|
||||
fn clear_focus_handle(lua: &Lua, h: FrameHandle) {
|
||||
let id = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
@@ -272,6 +321,23 @@ fn insert(lua: &Lua, h: FrameHandle, ins: &str, fire_space: bool) {
|
||||
}
|
||||
sync_text_region(lua, h);
|
||||
let id = frame_id_of(lua, h);
|
||||
// **The EditBox DOES fire generic `OnChar`** — with the spliced string as `arg1`, from inside
|
||||
// Insert itself (`0x77c13c`, the varargs firer `0x7026f0` with fmt `"%s"`), before the dirty
|
||||
// flush gets round to `OnTextChanged`. RF-0082's "never generic `OnKeyDown`/`OnChar`" was
|
||||
// scoped to one member of a two-member fire family and missed the varargs half; the
|
||||
// `OnKeyDown` (`+0x188`) half of that claim stands (wow-re, corrected 2026-08-29). It rides
|
||||
// the one choke point every insert path goes through — typed char, the `|`→`||` escape, the
|
||||
// multiLine Enter newline, the Lua `Insert` — and so is absent from `SetText`, which is where
|
||||
// the reference has it absent too.
|
||||
let on_char = lua
|
||||
.create_string(ins)
|
||||
.and_then(|s| event::fire_widget_handler(lua, id, "OnChar", vec![mlua::Value::String(s)]));
|
||||
if let Err(e) = on_char {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
}
|
||||
fire_script(lua, id, "OnTextChanged");
|
||||
if fire_space {
|
||||
for _ in 0..out.spaces {
|
||||
|
||||
@@ -39,10 +39,95 @@ fn autofocus_does_not_focus_on_show_but_self_acquires_first_event() {
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "a");
|
||||
}
|
||||
|
||||
/// **An `autoFocus` box takes the keyboard when it is shown** — the OnShow vtable override
|
||||
/// (`0x81c910` slot +0x30, `0x77a750`), missing here until decision 1686 because wow-re's own
|
||||
/// "verified by absence" negative came from a `call`-only census that could not see its tail-`jmp`.
|
||||
/// Gated on nothing else holding focus, and the gate is the whole of it — no topmost/best choice.
|
||||
#[test]
|
||||
fn an_autofocus_box_takes_the_keyboard_when_it_is_shown() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:Hide()
|
||||
E:ClearFocus()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!s.has_keyboard_focus(), "hidden and unfocused to start");
|
||||
|
||||
s.run("E:Show()").unwrap();
|
||||
assert!(
|
||||
s.eval::<bool>("return E:HasFocus()").unwrap(),
|
||||
"showing an autoFocus box focuses it",
|
||||
);
|
||||
|
||||
// Hiding the box that holds the keyboard releases it (the mirror override, slot +0x34).
|
||||
s.run("E:Hide()").unwrap();
|
||||
assert!(
|
||||
!s.has_keyboard_focus(),
|
||||
"hiding the focused box releases the keyboard",
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The two gates, each shown to bite. A box that opted out does not take the keyboard on show; and
|
||||
/// a box that would have is refused while another still holds it — the reference's
|
||||
/// `if ([0xcf4dc8] == 0 && (flags & 1))`, both halves.
|
||||
#[test]
|
||||
fn the_show_focus_is_refused_without_autofocus_or_with_the_keyboard_taken() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
OPTED_OUT = CreateFrame("EditBox", "OptedOut")
|
||||
OPTED_OUT:SetAutoFocus(false)
|
||||
OPTED_OUT:Hide()
|
||||
OPTED_OUT:ClearFocus()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.run("OPTED_OUT:Show()").unwrap();
|
||||
assert!(
|
||||
!s.has_keyboard_focus(),
|
||||
"autoFocus=false is an opt-out that holds on show",
|
||||
);
|
||||
|
||||
// Now give the keyboard away, and show an autoFocus box into an occupied focus.
|
||||
s.run(
|
||||
r#"
|
||||
HOLDER = CreateFrame("EditBox", "Holder")
|
||||
HOLDER:SetFocus()
|
||||
LATE = CreateFrame("EditBox", "Late")
|
||||
LATE:Hide()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.run("LATE:Show()").unwrap();
|
||||
assert!(
|
||||
s.eval::<bool>("return HOLDER:HasFocus()").unwrap(),
|
||||
"a shown autoFocus box does not steal a focus that is already held",
|
||||
);
|
||||
assert!(!s.eval::<bool>("return LATE:HasFocus()").unwrap());
|
||||
|
||||
// And hiding a box that does NOT hold the keyboard leaves it where it is — `0x77e410`'s own
|
||||
// per-box guard (`cmp ecx,eax; jne ret`), which is what makes the override's unconditional
|
||||
// tail-jmp harmless.
|
||||
s.run("LATE:Hide()").unwrap();
|
||||
assert!(
|
||||
s.eval::<bool>("return HOLDER:HasFocus()").unwrap(),
|
||||
"hiding an unfocused box does not clear somebody else's focus",
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_autofocus_unfocused_box_ignores_input() {
|
||||
let mut s = script();
|
||||
s.run(r#"E = CreateFrame("EditBox", "E")"#).unwrap();
|
||||
// `SetAutoFocus(false)` explicitly: the ctor's `flags = 1` leaves autoFocus **ON** by default
|
||||
// (`0x779a29`/`0x779a2e`, decision 1686), so a bare `CreateFrame("EditBox")` is an autoFocus
|
||||
// box and would self-acquire on the first char. This test is about the other kind.
|
||||
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetAutoFocus(false)"#)
|
||||
.unwrap();
|
||||
assert!(!s.char_input("a"), "no focus, no autoFocus → not consumed");
|
||||
assert!(!s.editbox_action(EditAction::Move {
|
||||
unit: EditUnit::Char,
|
||||
@@ -250,7 +335,8 @@ fn paste_inserts_at_the_cursor_and_replaces_the_selection() {
|
||||
#[test]
|
||||
fn paste_into_an_unfocused_box_is_not_consumed() {
|
||||
let mut s = script();
|
||||
s.run(r#"E = CreateFrame("EditBox", "E")"#).unwrap();
|
||||
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetAutoFocus(false)"#)
|
||||
.unwrap();
|
||||
assert!(!s.paste("hi"), "no focus, no autoFocus → not consumed");
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "");
|
||||
}
|
||||
@@ -458,37 +544,65 @@ fn get_number_parses_the_text() {
|
||||
// ── the EditBox override never fires generic OnChar/OnKeyDown (§2) ────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn key_and_char_paths_never_fire_generic_handlers() {
|
||||
// The law (wow-re `frame-key-script-delivery.md` §1): `CSimpleEditBox` has its OWN input
|
||||
// vtable (`0x77a900` char / `0x77b160` key-down) which handles the event and **never chains to
|
||||
// the base implementation** — so a focused box's typing does not also run a generic `OnChar`
|
||||
// bound on that same box.
|
||||
//
|
||||
// This used to be proved by `SetScript("OnChar", …)` *raising*, which it no longer does
|
||||
// (decision 1319 made the three key names real). The invariant is unchanged and is now
|
||||
// asserted directly: bind the generic handler, type into the focused box, and watch the text
|
||||
// arrive with the handler never called.
|
||||
fn typing_fires_the_generic_on_char_with_what_was_inserted() {
|
||||
// **The half of the old law that was wrong** (wow-re, corrected 2026-08-29; decision 1686).
|
||||
// `CSimpleEditBox` has its own input vtable which does not chain to the base — but Insert
|
||||
// itself fires the generic `OnChar` slot (`+0x180`) at `0x77c13c`, through the **varargs**
|
||||
// firer `0x7026f0` with fmt `"%s"` and the spliced string as the argument. The published
|
||||
// negative came from censusing only the fixed-arity firer `0x702690`: one member of a
|
||||
// two-member family.
|
||||
let mut s = script();
|
||||
s.run(
|
||||
r#"
|
||||
got = {}
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:EnableKeyboard(true)
|
||||
E:SetScript("OnChar", function() table.insert(got, arg1) end)
|
||||
E:SetFocus()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.char_input("k"), "the focused box consumed the character");
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "k");
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(got, ',')").unwrap(),
|
||||
"k",
|
||||
"OnChar fires once, with the inserted string",
|
||||
);
|
||||
|
||||
// `SetText` is NOT an insert path and fires nothing — the seam the reference has too.
|
||||
s.run(r#"E:SetText("zzz")"#).unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(got, ',')").unwrap(),
|
||||
"k",
|
||||
"SetText does not run Insert, so it does not fire OnChar",
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_paths_never_fire_the_generic_on_key_down() {
|
||||
// The surviving half (wow-re `frame-key-script-delivery.md` §1): the box's own key-down
|
||||
// vtable (`0x77b160`) handles the event and never chains to the base, so a focused box's
|
||||
// typing does not also run a generic `OnKeyDown` bound on that same box. Only the `OnChar`
|
||||
// half of the original claim was corrected — this one was re-censused and stands.
|
||||
let mut s = script();
|
||||
s.run(
|
||||
r#"
|
||||
fired = 0
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:EnableKeyboard(true)
|
||||
E:SetScript("OnChar", function() fired = fired + 1 end)
|
||||
E:SetScript("OnKeyDown", function() fired = fired + 1 end)
|
||||
E:SetFocus()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.char_input("k"), "the focused box consumed the character");
|
||||
assert_eq!(
|
||||
s.eval::<String>("return E:GetText()").unwrap(),
|
||||
"k",
|
||||
"…by inserting it through its own path"
|
||||
);
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "k");
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return fired").unwrap(),
|
||||
0,
|
||||
"the box's own vtable never chains to the generic OnChar"
|
||||
"the box's own vtable never chains to the generic OnKeyDown"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
@@ -1120,3 +1234,23 @@ fn max_letters_counts_visible_letters_not_escape_bytes() {
|
||||
assert_eq!(text_of(&s), LINK);
|
||||
assert_eq!(s.eval::<i64>("return E:GetNumLetters()").unwrap(), 9);
|
||||
}
|
||||
|
||||
/// **Creating a box is not showing it** — and that is what keeps the on-show self-focus safe now
|
||||
/// that autoFocus defaults ON (decision 1686). A frame born visible is not an effective-visibility
|
||||
/// *transition*, so it never runs the OnShow override; only a real `Show()` does. Without this,
|
||||
/// loading the shipped chain would hand the keyboard to whichever edit box happened to be
|
||||
/// constructed first, and typing would go into it instead of to the game.
|
||||
///
|
||||
/// Pinned because it is load-bearing by *absence*: nothing in the on-show path mentions creation,
|
||||
/// so the day frame construction starts firing OnShow, this is the test that says what it costs.
|
||||
#[test]
|
||||
fn creating_a_box_does_not_focus_it_the_way_showing_one_does() {
|
||||
let s = script();
|
||||
s.run(r#"E = CreateFrame("EditBox", "E")"#).unwrap();
|
||||
assert!(
|
||||
!s.has_keyboard_focus(),
|
||||
"a box born visible has not been SHOWN, so it takes no keyboard",
|
||||
);
|
||||
assert!(!s.eval::<bool>("return E:HasFocus()").unwrap());
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -194,6 +194,14 @@ pub(super) fn fire_visibility_changes(lua: &Lua, changed: Vec<FrameHandle>) {
|
||||
.expect("model")
|
||||
.record_script_error(e.to_string());
|
||||
}
|
||||
// **And the EditBox's own OnShow/OnHide vtable overrides** (`0x81c910` +0x30/+0x34): an
|
||||
// `autoFocus` box grabs the keyboard when it appears if nothing else holds it, and hiding
|
||||
// the box that holds it releases it. Both overrides call the base notify FIRST and act
|
||||
// after, which is why this sits below the `fire` and not above it. A no-op for every frame
|
||||
// that is not an EditBox. Here rather than at the Lua `Show`/`Hide` bindings for the same
|
||||
// reason the toplevel raise is: every visibility transition this engine performs runs
|
||||
// through this seam, and a second one is how a path gets forgotten.
|
||||
super::editbox::visibility_focus(lua, h, visible);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +158,8 @@ pub use char_stats::{
|
||||
pub use chat_send::ChatSend;
|
||||
pub use chat_window::ChatWindowLook;
|
||||
pub use container::{
|
||||
ContainerMove, ContainerSlot, ContainerState, EnchantView, RandomPropertyView, UiCursorMode,
|
||||
ContainerMove, ContainerSlot, ContainerState, EnchantView, PetitionSlotView,
|
||||
RandomPropertyView, UiCursorMode,
|
||||
};
|
||||
pub use craft::{CraftReagent, CraftRecipe, CraftState, CraftTooltip};
|
||||
pub use cursor::{
|
||||
@@ -1119,8 +1120,10 @@ impl UiScript {
|
||||
// benilla speaks *key names*, not scancodes — the host maps its window keycodes to these. The
|
||||
// routing is the client's exactly: if a box is focused it processes and CONSUMES every event; if
|
||||
// none is focused, the topmost effectively-visible `autoFocus` box self-acquires focus and
|
||||
// processes this same event; otherwise nothing is consumed. `autoFocus` never focuses on show —
|
||||
// only this self-acquire path, a click, or Lua `SetFocus` focuses a box.
|
||||
// processes this same event; otherwise nothing is consumed. Today the ways a box gets focus here
|
||||
// are this self-acquire path, a click, and Lua `SetFocus` — the reference has a fourth, an
|
||||
// `autoFocus` box focusing itself on SHOW, which we do not implement yet (the flag's own doc
|
||||
// carries the correction and what it waits on).
|
||||
|
||||
/// A typed character (may be multi-byte UTF-8) arriving from the host. Routes per §1/§2 and, on a
|
||||
/// focused box, inserts it (numeric/cap/password rules apply) or — for the Ctrl+A control code —
|
||||
|
||||
@@ -145,6 +145,13 @@ pub(crate) struct StableModel {
|
||||
pub(crate) selected: i32,
|
||||
pub(crate) intents: Vec<StableIntent>,
|
||||
pub(crate) close: bool,
|
||||
/// The model pane's bake yaw, in radians — the fifth of the pane scalars beside
|
||||
/// `paperdoll_yaw` / `inspect_yaw` / `pet_paperdoll_yaw` / `dressup_yaw`, written by
|
||||
/// `BenillaStableModel_SetFacing` and sampled each frame by the app onto the `"stable"` booth
|
||||
/// slot. It lives on the window's own transient state rather than on [`super::Model`] because
|
||||
/// that is what it is: a pane facing that must survive the snapshot replacement every list
|
||||
/// packet performs, exactly like [`Self::selected`].
|
||||
pub(crate) yaw: f32,
|
||||
}
|
||||
|
||||
/// The zero state is the client's `0` — "nothing selected" — not `-1`, which is its encoding for
|
||||
@@ -159,6 +166,9 @@ impl Default for StableModel {
|
||||
selected: 0,
|
||||
intents: Vec::new(),
|
||||
close: false,
|
||||
// `0.0` like every other pane scalar: `BenillaPaperDollModel_OnLoad` seeds the
|
||||
// reference's own 0.61 default through `frame.setFacing` when the window loads.
|
||||
yaw: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,6 +211,13 @@ impl super::UiScript {
|
||||
let model = self.model_mut();
|
||||
selected_slot(&model.stable)
|
||||
}
|
||||
|
||||
/// The stable model pane's bake yaw (`BenillaStableModel_SetFacing` wrote it) — the exact twin
|
||||
/// of [`Self::paperdoll_yaw`] / [`Self::pet_paperdoll_yaw`], sampled each frame by the app onto
|
||||
/// the `"stable"` booth slot.
|
||||
pub fn stable_yaw(&self) -> f32 {
|
||||
self.model_ref().stable.yaw
|
||||
}
|
||||
}
|
||||
|
||||
/// The petNumber→slot translation behind `GetSelectedStablePet` (§7.1).
|
||||
@@ -560,6 +577,22 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// BenillaStableModel_SetFacing(radians) — the stable pane's own bake yaw, the exact twin of
|
||||
// `BenillaPaperDollModel_SetFacing` / `BenillaPetPaperDollModel_SetFacing`. Benilla-named for
|
||||
// the same reason they are: the real client's pane is a live `<PlayerModel>` widget whose
|
||||
// `SetRotation` is a widget method, and our booth still carries one scalar (decision 0208 §5).
|
||||
// `PetStable.xml`'s `BenillaPetStableModel_SetFacing` shim forwards to this.
|
||||
g.set(
|
||||
"BenillaStableModel_SetFacing",
|
||||
lua.create_function(|lua, radians: f32| {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.stable
|
||||
.yaw = radians;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// ClosePetStables() — client-side close, no packet exists (vmangos has no close opcode): flag
|
||||
// the app to clear its session.
|
||||
g.set(
|
||||
|
||||
@@ -64,6 +64,7 @@ fn shift_compare_fires_seats_and_renders_the_compare_shape() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
|
||||
@@ -430,6 +430,150 @@ fn required_level_one_is_hidden() {
|
||||
/// wow-re `right-click-open.md` §1 re-derived the `p6` leg selector that had it suppressed).
|
||||
/// A **running cooldown** takes SetBagItem's other leg and suppresses the line — the two are
|
||||
/// structurally exclusive on this binding (decision 0896).
|
||||
/// **Line 3 — the charter's guild name and master**, between the NAME and the green
|
||||
/// `ITEM_SIGNABLE` (wow-re `tooltip-content-law.md`'s emission order: 2 NAME, 3 the petition
|
||||
/// triple, 4 SIGNABLE).
|
||||
///
|
||||
/// The director's report was that our charter tooltip showed the name and the green line with
|
||||
/// nothing between them, where the real client prints "Guild Name: BTC" / "Guild Master:
|
||||
/// Twowarrior". The ORDER is the half a text-only assertion would miss: these lines sit above the
|
||||
/// green one, not below it.
|
||||
///
|
||||
/// Also pinned: the two key families (a plain petition reads "Petition:" / "Created by"), and that
|
||||
/// an unresolved owner withholds only its own line — the creator line's rule, and the reason a
|
||||
/// first hover of an unopened charter is not blank but partial.
|
||||
#[test]
|
||||
fn charter_lines_sit_between_the_name_and_the_signable_line() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_item_template(
|
||||
5863,
|
||||
ItemTemplateView {
|
||||
name: "Guild Charter".into(),
|
||||
quality: 1,
|
||||
flags: 0x2000, // ITEM_FLAG_CHARTER — the green line's own gate
|
||||
max_count: 1,
|
||||
bonding: 1,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let charter = |p: Option<PetitionSlotView>| ContainerSlot {
|
||||
item_id: 5863,
|
||||
count: 1,
|
||||
quality: Some(1),
|
||||
already_bound: true,
|
||||
petition: p,
|
||||
..Default::default()
|
||||
};
|
||||
let mut slots = HashMap::new();
|
||||
slots.insert(
|
||||
1,
|
||||
charter(Some(PetitionSlotView {
|
||||
is_charter: true,
|
||||
title: "BTC".into(),
|
||||
owner: Some("Twowarrior".into()),
|
||||
})),
|
||||
);
|
||||
// The owner's name still in flight — the title line stands, its own does not.
|
||||
slots.insert(
|
||||
2,
|
||||
charter(Some(PetitionSlotView {
|
||||
is_charter: true,
|
||||
title: "BTC".into(),
|
||||
owner: None,
|
||||
})),
|
||||
);
|
||||
// No record yet at all: the plate is the name and the green line, as it was before this.
|
||||
slots.insert(3, charter(None));
|
||||
// A non-charter petition takes the OTHER key family.
|
||||
slots.insert(
|
||||
4,
|
||||
charter(Some(PetitionSlotView {
|
||||
is_charter: false,
|
||||
title: "Something".into(),
|
||||
owner: Some("Someone".into()),
|
||||
})),
|
||||
);
|
||||
s.set_container(
|
||||
0,
|
||||
Some(ContainerState {
|
||||
name: Some("Backpack".into()),
|
||||
num_slots: 4,
|
||||
slots,
|
||||
}),
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
local a = CreateFrame("Button", "SlotC"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
|
||||
local tt = CreateFrame("GameTooltip", "TT")
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetBagItem(0, 1)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let lines = lines_of(&mut s);
|
||||
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert_eq!(
|
||||
texts,
|
||||
vec![
|
||||
"Guild Charter",
|
||||
"Guild Name: BTC",
|
||||
"Guild Master: Twowarrior",
|
||||
"<Right Click for Details>",
|
||||
"Soulbound",
|
||||
"Unique",
|
||||
],
|
||||
"the two guild lines sit ABOVE the green line, not below it"
|
||||
);
|
||||
assert_eq!(lines[1].1, [1.0, 1.0, 1.0, 1.0], "the title line is white");
|
||||
assert_eq!(lines[2].1, [1.0, 1.0, 1.0, 1.0], "the master line is white");
|
||||
assert_eq!(lines[3].1, [0.0, 1.0, 0.0, 1.0], "SIGNABLE is still green");
|
||||
|
||||
let hover = |s: &mut UiScript, slot: u32| {
|
||||
s.run(&format!(
|
||||
r#"TT:SetOwner(getglobal("SlotC"), "ANCHOR_RIGHT"); TT:SetBagItem(0, {slot})"#
|
||||
))
|
||||
.unwrap();
|
||||
lines_of(s)
|
||||
.into_iter()
|
||||
.map(|(t, _)| t)
|
||||
.collect::<Vec<String>>()
|
||||
};
|
||||
assert_eq!(
|
||||
hover(&mut s, 2),
|
||||
vec![
|
||||
"Guild Charter",
|
||||
"Guild Name: BTC",
|
||||
"<Right Click for Details>",
|
||||
"Soulbound",
|
||||
"Unique"
|
||||
],
|
||||
"an unresolved owner withholds ITS line only — the repaint fills it"
|
||||
);
|
||||
assert_eq!(
|
||||
hover(&mut s, 3),
|
||||
vec![
|
||||
"Guild Charter",
|
||||
"<Right Click for Details>",
|
||||
"Soulbound",
|
||||
"Unique"
|
||||
],
|
||||
"no record yet: exactly the plate we shipped before, not a blank one"
|
||||
);
|
||||
assert_eq!(
|
||||
hover(&mut s, 4),
|
||||
vec![
|
||||
"Guild Charter",
|
||||
"Petition: Something",
|
||||
"Created by Someone",
|
||||
"<Right Click for Details>",
|
||||
"Soulbound",
|
||||
"Unique"
|
||||
],
|
||||
"the record's charter bit picks the key family"
|
||||
);
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_tail_creator_and_readable() {
|
||||
let mut s = script();
|
||||
|
||||
@@ -377,6 +377,9 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
has_text: false,
|
||||
flags: s.flags,
|
||||
already_bound: s.already_bound,
|
||||
// No petition line on the doll: a charter has `InventoryType = 0`
|
||||
// and cannot be equipped, so this hover can never be over one.
|
||||
petition: None,
|
||||
enchants: s.enchants.clone(),
|
||||
// `SetInventoryItem 0x532ee0` also has p6=0 legs (`0x533106`,
|
||||
// `0x5332ad`) — the "this binding can never emit OPENABLE" claim
|
||||
@@ -464,6 +467,11 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
has_text: s.readable,
|
||||
flags: s.flags,
|
||||
already_bound: s.already_bound,
|
||||
// Line 3 — the charter's guild name and master. Only the BAG hover
|
||||
// carries it: a charter has `InventoryType = 0` and cannot be worn,
|
||||
// so the doll site above has no petition to show and does not ask
|
||||
// its slot view for one.
|
||||
petition: s.petition.clone(),
|
||||
enchants: s.enchants.clone(),
|
||||
// p6 == 0 ⇔ no running cooldown. A clam shows the green line; the
|
||||
// same clam mid-cooldown would show ITEM_COOLDOWN_TIME instead
|
||||
|
||||
@@ -43,6 +43,9 @@ pub(super) struct ItemInstance {
|
||||
/// [`Self::enchants`] — that list is a *display* view and drops rows the line law hides.
|
||||
/// Drives §6's Soulbound override; `false` on every template/link source.
|
||||
pub already_bound: bool,
|
||||
/// The petition this charter names — **line 3**, between the NAME and `ITEM_SIGNABLE`. See
|
||||
/// [`crate::script::PetitionSlotView`], which carries the reason its third line is unbuilt.
|
||||
pub petition: Option<crate::script::PetitionSlotView>,
|
||||
/// The instance's enchant slots, app-resolved and in slot order (law line 17 / §1-ENCHANT,
|
||||
/// decisions 0915/0920) — see [`crate::script::EnchantView`]. Empty on an unenchanted item and
|
||||
/// on every template/link source (no instance, nothing enchanted).
|
||||
@@ -150,6 +153,28 @@ pub(super) fn render_view(
|
||||
.and_then(|i| i.name.clone())
|
||||
.unwrap_or_else(|| v.name.clone());
|
||||
add((name, name_color))?;
|
||||
// Line 3 — the petition block, ABOVE the green line and below the name: "Guild Name: X" then
|
||||
// "Guild Master: Y" for a charter, "Petition: X" / "Created by Y" for a plain petition. The
|
||||
// keys are picked by the record's own charter bit, the same bit `GetPetitionInfo`'s first
|
||||
// return reads.
|
||||
//
|
||||
// Each line is withheld while its source is unresolved rather than printed with a hole: an
|
||||
// uncached owner name shows the title alone, and the repaint fills it in — the creator line's
|
||||
// rule, and the reference's own (its resolve callback repaints the tooltip; ours is the
|
||||
// container re-enter loop).
|
||||
if let Some(p) = inst.and_then(|i| i.petition.as_ref()) {
|
||||
let (title_fmt, creator_fmt) = if p.is_charter {
|
||||
("Guild Name: %s", "Guild Master: %s")
|
||||
} else {
|
||||
("Petition: %s", "Created by %s")
|
||||
};
|
||||
if !p.title.is_empty() {
|
||||
add((title_fmt.replacen("%s", &p.title, 1), WHITE))?;
|
||||
}
|
||||
if let Some(owner) = p.owner.as_deref().filter(|o| !o.is_empty()) {
|
||||
add((creator_fmt.replacen("%s", owner, 1), WHITE))?;
|
||||
}
|
||||
}
|
||||
// ITEM_SIGNABLE (green) — Flags bit 0x2000 (petitions).
|
||||
if v.flags & 0x2000 != 0 {
|
||||
add(("<Right Click for Details>".into(), GREEN))?;
|
||||
|
||||
@@ -60,9 +60,28 @@ pub struct EditBoxState {
|
||||
/// Selection end (`E+0x360`). A non-empty selection is `sel_start != sel_end`; every insert
|
||||
/// replaces it first (RF-0082 §3).
|
||||
pub sel_end: usize,
|
||||
/// `autoFocus` (`flags@E+0x318` bit0): consulted **only** in the keyboard self-acquire guard —
|
||||
/// it does NOT focus on show (RF-0082 §1). The box grabs focus the first time a key/char event
|
||||
/// reaches it while nothing is focused, and processes that same event.
|
||||
/// `autoFocus` (`flags@E+0x318` bit0). Two acquisition paths, and benilla implements only the
|
||||
/// second:
|
||||
///
|
||||
/// 1. **On SHOW** — the box self-focuses when it becomes visible, gated on nothing else holding
|
||||
/// focus. The EditBox's own OnShow vtable override (`0x81c910` slot +0x30, `0x77a750`) fires
|
||||
/// the Lua handler and then tail-jumps `SetFocus`:
|
||||
/// `if ([0xcf4dc8] == 0 && (flags & 1)) jmp 0x77e3d0` @`0x77a76d`. The mirror slot +0x34
|
||||
/// (`0x77a780`) tail-jumps `ClearFocus`, so **hiding an edit box releases the keyboard**.
|
||||
/// 2. **On the first key/char event** while nothing is focused — the self-acquire guard; the box
|
||||
/// grabs focus and processes that same event.
|
||||
///
|
||||
/// **This corrects what stood here.** Both this comment and wow-re's own `ui.md`/RF-0082 said
|
||||
/// autoFocus does NOT focus on show, "verified by absence" — a census written over `call` alone,
|
||||
/// which cannot see a tail-`jmp`. A `(call|jmp)` census finds the eleventh site (wow-re
|
||||
/// `editbox-selection-focus-law.md` §6, 2026-08-29, dispatched from benilla's login work). Path 1
|
||||
/// is a real behavioural gap here, not just a stale sentence.
|
||||
///
|
||||
/// **`false` at construction is OUR value, not a verified one.** The client's construction-time
|
||||
/// bit0 is unrecorded, and every `autoFocus` in the shipped 1.12.1 chain is `="false"` — ten
|
||||
/// opt-outs, no opt-ins — which is the authoring signature of a default that is ON. Out to wow-re;
|
||||
/// until it answers, this default and the missing path 1 are deliberately left alone together,
|
||||
/// because turning path 1 on under a wrong default is what would actually break something.
|
||||
pub auto_focus: bool,
|
||||
/// `multiLine` (bit1): Enter inserts a newline (rather than firing `OnEnterPressed`) and `\n` is
|
||||
/// accepted into the buffer.
|
||||
@@ -229,7 +248,9 @@ impl Default for EditBoxState {
|
||||
cursor: 0,
|
||||
sel_start: 0,
|
||||
sel_end: 0,
|
||||
auto_focus: false,
|
||||
// `flags = 1` at construction (`0x779a29`/`0x779a2e`): bit0 (autoFocus) SET, every
|
||||
// other flag clear. A box that says nothing self-focuses when shown.
|
||||
auto_focus: true,
|
||||
multi_line: false,
|
||||
numeric: false,
|
||||
password: false,
|
||||
@@ -842,8 +863,9 @@ impl EditBoxState {
|
||||
self.collapse();
|
||||
}
|
||||
|
||||
/// Collapse the selection onto the caret.
|
||||
fn collapse(&mut self) {
|
||||
/// Collapse the selection onto the caret — the client's own `0x77ccf0` (RF-0082 §4), which
|
||||
/// every delete path runs and which a screen losing the keyboard runs on the box it is leaving.
|
||||
pub fn collapse(&mut self) {
|
||||
self.sel_start = self.cursor;
|
||||
self.sel_end = self.cursor;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user