diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index 2b467237..c87e3134 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -618,10 +618,14 @@
Graphics = { "RowUiScale", "RowWorldDetail", "RowVerticalSync" },
Nameplates = { "RowPlayerNames", "RowNpcNames", "RowOwnName" },
-- The stock globals that were already defined AND consumed when 1134 opened the store
- -- (1136; Buff Durations 1139), in the reference's own on-screen order — its Basic
- -- Options anchor chain, Display block (CheckButton42 → 39 → 66) before Help (29).
+ -- (1136; Buff Durations 1139), plus the two equipment-display toggles (1472), in the
+ -- reference's own on-screen order. Its Basic Options Display box is three COLUMNS read
+ -- left to right: A gives Status Text (CheckButton2), B gives Show Cloak → Show Helm
+ -- (31 → 20, sitting under the name toggles that live on our Nameplates page), C gives
+ -- the 42 → 39 → 66 chain. Help (29) closes the page, as before.
Interface = {
- "RowStatusText", "RowInstantQuestText", "RowBuffDurations", "RowAutoQuestWatch",
+ "RowStatusText", "RowShowCloak", "RowShowHelm",
+ "RowInstantQuestText", "RowBuffDurations", "RowAutoQuestWatch",
"RowNewbieTips",
},
-- The two chat-bubble switches (decision 1139), in the reference's relative order —
@@ -670,15 +674,23 @@
end
end
- -- ── The row's STORE: a CVar, or a saved-variable global (decision 1134) ────────────
- -- 1.12's own Interface panel has TWO stores, not one: each UIOptionsFrameCheckButtons
- -- entry carries either `cvar = "name"` (an engine knob) or `uvar = "GLOBAL"` (a
- -- RegisterForSave'd Lua global), and UIOptionsFrame_Load/_Save fork on exactly that field
- -- (UIOptionsFrame.lua l.230-377). Values are strings either way — "1"/"0" for a toggle —
- -- so only the accessor differs, and every row in this file now reads and writes through
- -- this trio instead of calling Get/SetCVar directly.
+ -- ── The row's STORE: a CVar, a saved-variable global, or an API pair (1134, 1472) ──
+ -- 1.12's own Interface panel has THREE stores, not one: each UIOptionsFrameCheckButtons
+ -- entry carries `cvar = "name"` (an engine knob), `uvar = "GLOBAL"` (a RegisterForSave'd
+ -- Lua global), or a `func`/`setFunc` pair (an engine getter/setter with no client-side
+ -- store at all) — and UIOptionsFrame_Load/_Save fork on exactly that field
+ -- (UIOptionsFrame.lua l.230-377; the func fork at l.237-238 and l.296-297). Values are
+ -- strings in all three — "1"/"0" for a toggle — so only the accessor differs, and every row
+ -- in this file reads and writes through this trio instead of calling Get/SetCVar directly.
+ --
+ -- The third kind is what Show Helm / Show Cloak are: the preference is a bit of the
+ -- character's own server-side PLAYER_FLAGS, so there is nothing local to save and nothing
+ -- to restore at load — the getter IS the state (decision 1472).
function OptionsRow_Get(row)
if row.uvar then return getglobal(row.uvar) end
+ if row.getFunc then
+ if row.getFunc() then return "1" else return "0" end
+ end
return GetCVar(row.cvar)
end
@@ -699,6 +711,14 @@
if row.applyFunc then row.applyFunc() end
return
end
+ -- An API row hands the setter the row's own "1"/"0" STRING, which is what 1.12 does
+ -- (UIOptionsFrame_Save l.290-297 builds value.value as a string and passes it
+ -- straight through) — so the engine binding reads its argument numerically and "0"
+ -- means off. No applyFunc: the setter IS the side effect.
+ if row.setFunc then
+ row.setFunc(value)
+ return
+ end
SetCVar(row.cvar, value, row.cvarEvent)
end
@@ -710,6 +730,7 @@
-- drift from it, where the reference's table carries a second hand-written copy.
function OptionsRow_Default(row)
if row.uvar then return row.udefault end
+ if row.getFunc then return row.fdefault end
return GetCVarDefault(row.cvar)
end
@@ -722,6 +743,17 @@
row.udefault = getglobal(uvar)
end
+ -- A row with no store: an engine getter/setter pair, 1.12's own `func`/`setFunc` shape
+ -- (decision 1472). `default` has to be stated because there is no registered default to
+ -- ask for — the reference states it the same way, as a hand-written arm of
+ -- UIOptionsFrame_SetDefaults (l.631-634, SHOW_CLOAK and SHOW_HELM both SetChecked(1)).
+ function OptionsFuncRow_OnLoad(row, getFunc, setFunc, default, label, tip, child)
+ OptionsRow_OnLoad(row, nil, label, tip, child)
+ row.getFunc = getFunc
+ row.setFunc = setFunc
+ row.fdefault = default
+ end
+
-- The other end of `applyFunc`, and the reason a saved "off" used to come back on
-- (decision 1139). A CVar row needs nothing here — the host reads its own table at boot.
-- A GLOBAL row does: the whole UI's XML runs BEFORE the saved chunk executes over it
@@ -2327,13 +2359,17 @@
+
+
+ OptionsFuncRow_OnLoad(self, ShowingCloak, ShowCloak, "1",
+ "Show Cloak", "OPTION_TOOLTIP_SHOW_CLOAK")
+
+
+
+
+
+
+
+
+ OptionsFuncRow_OnLoad(self, ShowingHelm, ShowHelm, "1",
+ "Show Helm", "OPTION_TOOLTIP_SHOW_HELM")
+
+
+
+
+
+
+
OptionsGlobalRow_OnLoad(self, "QUEST_FADING_DISABLE",
"Instant Quest Text", "OPTION_TOOLTIP_SHOW_QUEST_FADING")
diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs
index c913d1ac..dbdcbcbd 100644
--- a/crates/benilla-app/src/capture/mod.rs
+++ b/crates/benilla-app/src/capture/mod.rs
@@ -112,9 +112,10 @@ pub(crate) use probe_partner::ProbePartnerPlugin;
pub(crate) use probe_rig::ProbeRigPlugin;
pub(crate) use probe_taxi::ProbeTaxiPlugin;
pub(crate) use probes::{
- fx_draw_census_plugin, EntityCensusPlugin, GroundCensusPlugin, LiveFpsPlugin, NodeProbePlugin,
- ParticleCensusPlugin, ProbeChatPlugin, ProbeClock, ProbeExitPlugin, ProbeFocusPlugin,
- ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin, SchedCensusPlugin, UnitVisualsPlugin,
+ fx_draw_census_plugin, DressCensusPlugin, EntityCensusPlugin, GroundCensusPlugin,
+ LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin, ProbeClock,
+ ProbeExitPlugin, ProbeFocusPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin,
+ SchedCensusPlugin, UnitVisualsPlugin,
};
use scenarios::GlueScreen;
use scenarios::{Scenario, SubjectKind, UiFixture, GLUE_SCENARIOS, GROUND_EYE, SCENARIOS};
diff --git a/crates/benilla-app/src/capture/probes/dress_census.rs b/crates/benilla-app/src/capture/probes/dress_census.rs
new file mode 100644
index 00000000..a3a50541
--- /dev/null
+++ b/crates/benilla-app/src/capture/probes/dress_census.rs
@@ -0,0 +1,172 @@
+//! The **dress census** (`WOW_DRESS_CENSUS=[,]`) — one line per streamed player near
+//! the body saying what the wire asked for, what we resolved, and what is actually hanging off the
+//! skeleton right now.
+//!
+//! It exists because those are three different things and a screenshot conflates all three. B123 —
+//! *"show helm and cloak preferences are not taken into account"* — is exactly a gap between the
+//! first and the third: the descriptor carried `PLAYER_FLAGS_HIDE_HELM`, the resolver ignored it,
+//! and a helm model hung off the head. Nothing in a picture separates that from "this character
+//! simply has a helm equipped", which is why the report needed a reader rather than an eye.
+//!
+//! ```text
+//! DRESS_CENSUS t=20.0 players=2 hiding-helm=1 hiding-cloak=1 contradictions=0 radius=120 body=(-8949.95,-132.49,83.57)
+//! DRESS 0xF150000000000038 d= 0.0 flags=0x00020c02 hide=helm+cloak helm=0 cloak=0 settled=1 spawned=[main,shL,shR] Probethree
+//! DRESS 0xF150000000000079 d= 12.4 flags=0x00020002 hide=- helm=32154 cloak=41205 settled=1 spawned=[main,off,helm,shL,shR] Naz
+//! ```
+//!
+//! - **`flags` / `hide`** are the wire's own: `PLAYER_FLAGS` and its `HIDE_HELM 0x400` /
+//! `HIDE_CLOAK 0x800` bits, which are PUBLIC — so every player in range is readable here, not
+//! only our own body, and "does a remote player's preference reach us" is a line rather than an
+//! argument.
+//! - **`helm` / `cloak`** are the resolved `ItemDisplayInfo` ids ([`Equipment`]) — `0` means the
+//! body is dressed without that piece, whether because nothing is equipped there or because the
+//! preference suppressed it.
+//! - **`spawned`** is the attach-slot list actually standing under the unit
+//! ([`HeldAttached::spawned_slots`]) — the visual truth, read off the ECS rather than inferred.
+//! - **`contradictions`** is the headline number and the whole point: a body whose flags say hide
+//! while a `helm` display or a spawned `helm` slot says otherwise is tagged `!!` and counted.
+//! B123 reproduces as `contradictions=1`; the fix reads `contradictions=0` with `hide=` still
+//! set, which is the distinction "the helm is gone" alone cannot make (it is also what a naked
+//! head looks like when the *preference* never arrived).
+//!
+//! ```text
+//! WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR= WOW_NOSOUND=1 \
+//! WOW_DRESS_CENSUS="20,10" WOW_PROBE_EXIT_AT=45 cargo run -q -p benilla
+//! ```
+
+use benilla_assets::coords::bevy_to_wow;
+use benilla_protocol::EntityKind;
+use bevy::prelude::*;
+
+use super::ProbeClock;
+use crate::entities::{Equipment, HeldAttached, ATTACH_SLOT_NAMES};
+use crate::names::NameCache;
+use crate::net::{Guid, NetEntity, ObjectStore, SelfPlayer};
+
+/// How far from the body the census looks, in yards — the unit-visual census's radius, for the
+/// same reason: comfortably past what the server streams, so an empty census means an empty scene.
+const DEFAULT_RADIUS: f32 = 120.0;
+
+pub(crate) struct DressCensusPlugin;
+
+impl Plugin for DressCensusPlugin {
+ fn build(&self, app: &mut App) {
+ let raw = std::env::var("WOW_DRESS_CENSUS").unwrap_or_default();
+ let mut parts = raw
+ .split(',')
+ .map(|s| s.trim().parse::().unwrap_or(0.0));
+ let at = parts.next().filter(|v| *v > 0.0).unwrap_or(20.0);
+ let every = parts.next().unwrap_or(0.0);
+ app.insert_resource(DressCensus { next: at, every })
+ .add_systems(Update, fire_dress_census);
+ }
+}
+
+/// [`DressCensusPlugin`] state: when the next census fires, and how often after that (`0` = once).
+#[derive(Resource)]
+struct DressCensus {
+ next: f32,
+ every: f32,
+}
+
+/// What the census reads per entity: identity, kind, pose, the resolved worn set, and the attach
+/// roots actually standing.
+type DressQuery = (
+ &'static Guid,
+ &'static NetEntity,
+ &'static Transform,
+ &'static ObjectStore,
+ Option<&'static Equipment>,
+ Option<&'static HeldAttached>,
+);
+
+/// One line per streamed player within [`DEFAULT_RADIUS`], contradictions first.
+fn fire_dress_census(
+ mut probe: ResMut,
+ time: ProbeClock,
+ names: Res,
+ body: Query<&Transform, With>,
+ entities: Query,
+) {
+ let now = time.elapsed_secs();
+ if probe.next <= 0.0 || now < probe.next {
+ return;
+ }
+ probe.next = if probe.every > 0.0 {
+ now + probe.every
+ } else {
+ -1.0
+ };
+ let Ok(body) = body.single() else {
+ println!("DRESS_CENSUS t={now:.1} NO BODY — not in world, nothing measured");
+ return;
+ };
+ let radius = std::env::var("WOW_DRESS_CENSUS_RADIUS")
+ .ok()
+ .and_then(|v| v.parse::().ok())
+ .unwrap_or(DEFAULT_RADIUS);
+
+ // `(is_contradiction, sort key, line)`. A contradiction sorts to the top: it is the finding.
+ let mut rows: Vec<(bool, i64, String)> = Vec::new();
+ let (mut hiding_helm, mut hiding_cloak, mut bad) = (0u32, 0u32, 0u32);
+ for (guid, net, t, store, equipment, attached) in &entities {
+ if net.kind != EntityKind::Player
+ || t.translation.distance_squared(body.translation) > radius * radius
+ {
+ continue;
+ }
+ let flags = store.0.player_flags();
+ let (hide_helm, hide_cloak) = (store.0.player_hides_helm(), store.0.player_hides_cloak());
+ hiding_helm += u32::from(hide_helm);
+ hiding_cloak += u32::from(hide_cloak);
+ let eq = equipment.copied().unwrap_or_default();
+ let spawned: Vec<&str> = attached
+ .map(|a| a.spawned_slots())
+ .into_iter()
+ .flatten()
+ .enumerate()
+ .filter_map(|(i, e)| e.map(|_| ATTACH_SLOT_NAMES[i]))
+ .collect();
+ // The contradiction: the wire asked for a piece to be hidden and it is dressed anyway —
+ // either resolved onto the body or standing as an attach model. This is B123's symptom,
+ // stated as a predicate instead of a screenshot.
+ let helm_shown = eq.helm != 0 || spawned.contains(&"helm");
+ let contradiction = (hide_helm && helm_shown) || (hide_cloak && eq.cloak != 0);
+ bad += u32::from(contradiction);
+ let hide = match (hide_helm, hide_cloak) {
+ (true, true) => "helm+cloak",
+ (true, false) => "helm",
+ (false, true) => "cloak",
+ (false, false) => "-",
+ };
+ let dist = t.translation.distance(body.translation);
+ rows.push((
+ contradiction,
+ (dist * 100.0) as i64,
+ format!(
+ "DRESS {:#018x} d={dist:6.1} flags={flags:#010x} hide={hide:<10} \
+ helm={:<6} cloak={:<6} settled={} spawned=[{}] {}{}",
+ guid.0,
+ eq.helm,
+ eq.cloak,
+ u8::from(eq.settled),
+ spawned.join(","),
+ names.peek(guid.0).unwrap_or("?"),
+ if contradiction { " !!" } else { "" },
+ ),
+ ));
+ }
+ rows.sort_by_key(|(bad, dist, _)| (!*bad, *dist));
+ let at = bevy_to_wow(body.translation);
+ println!(
+ "DRESS_CENSUS t={now:.1} players={} hiding-helm={hiding_helm} hiding-cloak={hiding_cloak} \
+ contradictions={bad} radius={radius:.0} body=({:.2},{:.2},{:.2})",
+ rows.len(),
+ at[0],
+ at[1],
+ at[2],
+ );
+ for (_, _, line) in &rows {
+ println!("{line}");
+ }
+}
diff --git a/crates/benilla-app/src/capture/probes/mod.rs b/crates/benilla-app/src/capture/probes/mod.rs
index 892b8b43..d92792cb 100644
--- a/crates/benilla-app/src/capture/probes/mod.rs
+++ b/crates/benilla-app/src/capture/probes/mod.rs
@@ -40,6 +40,13 @@ pub(crate) use ground_census::GroundCensusPlugin;
mod visual_census;
pub(crate) use visual_census::UnitVisualsPlugin;
+/// The dress census — per-player "what did the wire ask for, what did we resolve, what is actually
+/// hanging off the skeleton": the three things a screenshot of a geared character conflates, and
+/// the reader that turns "my show-helm preference is ignored" into a counted contradiction (B123,
+/// decision 1472).
+mod dress_census;
+pub(crate) use dress_census::DressCensusPlugin;
+
/// The two exclusive-`World` reflection dumps: the bevy_ui node inventory and the archetype
/// census — "what is resident right now, and what is it made of".
mod world_census;
diff --git a/crates/benilla-app/src/dev.rs b/crates/benilla-app/src/dev.rs
index 9c5d62ca..d19500e3 100644
--- a/crates/benilla-app/src/dev.rs
+++ b/crates/benilla-app/src/dev.rs
@@ -194,6 +194,14 @@ impl Plugin for DevProbesPlugin {
if std::env::var("WOW_UNIT_VISUALS").is_ok() {
app.add_plugins(crate::capture::UnitVisualsPlugin);
}
+ // The dress census: `WOW_DRESS_CENSUS=[,]` prints one line per streamed
+ // PLAYER — what the wire asked for (`PLAYER_FLAGS`' hide bits), what we resolved
+ // (helm/cloak display ids) and what is actually attached — plus a `contradictions=`
+ // count for a body dressed in a piece its own preference asked us to hide. B123's
+ // instrument (see `capture::DressCensusPlugin`, decision 1472).
+ if std::env::var("WOW_DRESS_CENSUS").is_ok() {
+ app.add_plugins(crate::capture::DressCensusPlugin);
+ }
// The entity census: `WOW_ENTITY_CENSUS=` prints per-archetype entity counts once —
// what the resident entity count is made of (see `capture::EntityCensusPlugin`).
if std::env::var("WOW_ENTITY_CENSUS").is_ok() {
diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs
index 5aee80d5..51d0448e 100644
--- a/crates/benilla-app/src/entities.rs
+++ b/crates/benilla-app/src/entities.rs
@@ -116,6 +116,8 @@ use chain_beam::{simulate_chain_beams, spawn_chain_beams};
pub(crate) use attach::equip_slot;
pub(crate) use equipment::ItemDisplays;
pub(crate) use equipment::{BoneAttach, Equipment};
+// The instruments' read of what a body is actually WEARING vs what it resolved (`WOW_DRESS_CENSUS`).
+pub(crate) use equipment::{HeldAttached, ATTACH_SLOT_NAMES};
/// The overhead attachment slot (`PlayerName`, id 18). The anchor of the overhead name, the
/// floating combat text, and the questgiver marker alike.
diff --git a/crates/benilla-app/src/entities/attach/preview.rs b/crates/benilla-app/src/entities/attach/preview.rs
index 6acc3818..a7080b6c 100644
--- a/crates/benilla-app/src/entities/attach/preview.rs
+++ b/crates/benilla-app/src/entities/attach/preview.rs
@@ -353,8 +353,13 @@ pub(in crate::entities) fn build_dressup_preview(
hair_color: look.hair_color,
facial_hair: look.facial_hair,
equipment: look.equipment,
- // The world path honours no hide-helm/hide-cloak flag for a live player either (the helm
- // comes straight off the visible-item slot), so the preview of that player carries none.
+ // **No hide flags here, because the hiding already happened upstream** (decision 1472).
+ // The dressing room DOES honour show-helm/show-cloak — byte-verified: `SetUnit 0x476cb0`
+ // clones the live player's per-bodyslot display pointers, so a suppressed piece is not in
+ // what is cloned — but a TRY-ON must preview the helm regardless, and only
+ // [`crate::ui_dressup`]'s look builder knows which slots are worn and which are tried on.
+ // So it applies the preference there, per slot, and hands us an already-correct array;
+ // re-applying a flag mask over it here would hide the very item being previewed.
flags: 0,
// The widget's law, not the mannequin's (decision 1076). Unconditional, because the ranged
// slot of a dressing-room look is only ever filled by a TRY-ON: `crate::ui_dressup` leaves
diff --git a/crates/benilla-app/src/entities/equipment/mod.rs b/crates/benilla-app/src/entities/equipment/mod.rs
index 8e396712..3a6e144b 100644
--- a/crates/benilla-app/src/entities/equipment/mod.rs
+++ b/crates/benilla-app/src/entities/equipment/mod.rs
@@ -237,11 +237,29 @@ pub(crate) struct BoneAttach {
/// per slot (decision 0826): a slot that didn't change keeps its root, and a slot that only changed
/// attach point has its root moved. Only a slot whose item really changed is despawned and rebuilt.
#[derive(Component, Default)]
-pub(super) struct HeldAttached {
+pub(crate) struct HeldAttached {
applied: HeldItems,
spawned: [Option; ATTACH_SLOTS],
}
+impl HeldAttached {
+ /// The spawned root per attach slot, read-only — for the **instruments** (`WOW_DRESS_CENSUS`),
+ /// which need the *visual* truth and not just the resolution: "is a helm model actually hanging
+ /// off this body right now" is a different question from "did we resolve a helm display id",
+ /// and a report about a piece of gear showing when it should not (B123) turns on the gap
+ /// between them. Nothing in gameplay reads this — the diff owns the array (decision 0026's
+ /// dev seam: dev may see anything, nothing may depend on dev).
+ pub(crate) fn spawned_slots(&self) -> &[Option; ATTACH_SLOTS] {
+ &self.spawned
+ }
+}
+
+/// The attach-slot names, in [`HeldAttached::spawned_slots`] order — the instruments' labels for
+/// the eight slots [`resolve_equipment`](super::equipment::resolve) fills.
+pub(crate) const ATTACH_SLOT_NAMES: [&str; ATTACH_SLOTS] = [
+ "main", "off", "ranged", "helm", "shL", "shR", "ammo", "quiver",
+];
+
/// The glow id of a slot the reference never glows: **helm (attach 11), shoulders (5/6) and the
/// quiver (26) push a literal `0`** into the item-attach primitive `0x4798c0` — byte-read at
/// `0x479aa2`/`0x479e5f`/`0x479cf2`/`0x479db4` (decision 0805). Only the weapon/shield hand attach
diff --git a/crates/benilla-app/src/entities/equipment/resolve.rs b/crates/benilla-app/src/entities/equipment/resolve.rs
index b7aef47c..25f1c5dc 100644
--- a/crates/benilla-app/src/entities/equipment/resolve.rs
+++ b/crates/benilla-app/src/entities/equipment/resolve.rs
@@ -153,6 +153,23 @@ pub(in crate::entities) fn resolve_equipment(
continue;
}
let s = &store.0;
+ // The two **equipment-display preferences** (decision 1472, B123): `PLAYER_FLAGS`'
+ // `HIDE_HELM 0x400` / `HIDE_CLOAK 0x800`, read off THIS unit's own descriptor. The field is
+ // public, so this is per rendered body and not a local setting — a remote player who hides
+ // their helm hides it on our screen, exactly as ours hides on theirs. A creature has no
+ // `PLAYER_FLAGS` and reads `false` for both (its head/shoulder columns come from
+ // CreatureDisplayInfoExtra, which carries no such preference).
+ //
+ // The suppression is a **display id of zero**, applied below to every place the piece is
+ // resolved — which is the same thing "no helm equipped" means everywhere else in this
+ // module, so the whole downstream chain follows for free: the helm's attach sub-model is not
+ // requested, its RF-0083 HelmetGeosetVisData hide-masks are not applied (hair, facial hair
+ // and ears come back), the cloak's geoset group is not selected and its cape texture is not
+ // resolved. It is also the shape the glue lane has honoured since 0465 (`attach::preview`
+ // zeroes the same two slots off the char-enum record's `CHARACTER_FLAG_HIDE_*`, which
+ // vmangos round-trips into these very bits at login) — the world was the half that never
+ // consumed it.
+ let (hide_helm, hide_cloak) = (s.player_hides_helm(), s.player_hides_cloak());
// Worn armor (players; decision 0074): resolve the composite slots' entries → display ids.
// `settled` only once every non-empty entry has an answer, so the first attach composites the
// dressed atlas directly (the template cache makes later logins instant).
@@ -171,6 +188,10 @@ pub(in crate::entities) fn resolve_equipment(
}
}
// The cloak (equipment slot 14): geoset + cape texture, resolved the same way.
+ // The hide preference zeroes the RESOLVED id rather than skipping the lookup, so
+ // `settled` keeps meaning "every worn entry has an answer" — switching the preference
+ // back on then re-dresses from a warm template cache instead of stalling a frame on a
+ // round trip the player would see as a flicker.
if let Some(entry) = s.player_visible_item_entry(14).filter(|e| *e != 0) {
match templates.held(entry, &net) {
Some(t) => eq.cloak = t.display_info_id,
@@ -185,6 +206,12 @@ pub(in crate::entities) fn resolve_equipment(
None => eq.settled = false,
}
}
+ if hide_cloak {
+ eq.cloak = 0;
+ }
+ if hide_helm {
+ eq.helm = 0;
+ }
if current_equipment != Some(&eq) {
commands.entity(entity).insert(eq);
}
@@ -412,7 +439,9 @@ pub(in crate::entities) fn resolve_equipment(
.filter(|d| *d != 0)
.unwrap_or(0)
};
- let helm = resolve(0);
+ // The helm's attach sub-model. Zero when hidden — the same id the geoset half
+ // above was given, so the two halves of one preference can never disagree.
+ let helm = if hide_helm { 0 } else { resolve(0) };
let shoulder = resolve(2);
Some((helm, shoulder, race, sex))
}
@@ -476,7 +505,14 @@ pub(in crate::entities) fn resolve_equipment(
#[cfg(test)]
mod tests {
- use super::{ammo_attach, attach_id, placement};
+ use bevy::prelude::*;
+
+ use super::super::{Equipment, HeldItems, ItemDisplays};
+ use super::{ammo_attach, attach_id, placement, resolve_equipment};
+ use crate::items::Items;
+ use crate::net::{ClientCommand, NetCommands, NetEntity, ObjectStore};
+ use benilla_protocol::messages::{ItemInfo, ObjectFields};
+ use benilla_protocol::EntityKind;
/// The ranged slot's whole placement law: in hand only while ranged-drawn (state 2) — bow
/// (INVTYPE_RANGED 15) to the left hand, gun/crossbow/wand/thrown (RANGEDRIGHT 26 / THROWN 25)
@@ -518,4 +554,107 @@ mod tests {
assert_eq!(ammo_attach(Some(0x1a), true), None); // gun/xbow/wand — the gunXbow early return
assert_eq!(ammo_attach(None, true), None); // no ranged record
}
+
+ /// `PLAYER_FLAGS` (field 190) and the visible-item block (`PLAYER_VISIBLE_ITEM_1_CREATOR` 258,
+ /// +12 per slot, the entry at +2) by their raw wire indices — the constants are crate-private
+ /// to benilla-protocol, so every descriptor fixture in this crate spells them out.
+ const PLAYER_FLAGS: u16 = 190;
+ const BYTES_0: u16 = 36;
+
+ fn wearing(flags: u32, entries: &[(u8, u32)]) -> ObjectStore {
+ let mut pairs = vec![
+ (BYTES_0, 1 | 1 << 8), // race 1 (human), class 1, gender 0 (male)
+ (PLAYER_FLAGS, flags),
+ ];
+ for (slot, entry) in entries {
+ pairs.push((258 + 2 + 12 * u16::from(*slot), *entry));
+ }
+ ObjectStore(ObjectFields::from_pairs(&pairs))
+ }
+
+ fn worn(display_info_id: u32, inventory_type: u32) -> ItemInfo {
+ ItemInfo {
+ display_info_id,
+ inventory_type,
+ ..crate::items::test_template("Worn")
+ }
+ }
+
+ /// Run one `resolve_equipment` pass over a player wearing helm 900 / cloak 800 / chest 700,
+ /// with `flags` on their descriptor, and return what the body was dressed with: the
+ /// [`Equipment`] triple and whether the helm's ATTACH sub-model slot was filled.
+ fn dress(flags: u32) -> (Equipment, bool) {
+ let mut app = App::new();
+ app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default()));
+ let mut items = Items::default();
+ items.insert_template(100, Some(worn(900, 1))); // INVTYPE_HEAD
+ items.insert_template(200, Some(worn(800, 16))); // INVTYPE_CLOAK
+ items.insert_template(300, Some(worn(700, 5))); // INVTYPE_CHEST
+ let (tx, rx) = crossbeam_channel::unbounded::();
+ app.insert_resource(items);
+ app.insert_resource(NetCommands(tx));
+ app.insert_resource(ItemDisplays::icons_for_tests(
+ benilla_formats::ItemDisplayCatalog::from_displays(std::collections::HashMap::new()),
+ ));
+ let player = app
+ .world_mut()
+ .spawn((
+ NetEntity {
+ kind: EntityKind::Player,
+ display_id: Some(49),
+ scale: 1.0,
+ },
+ wearing(flags, &[(0, 100), (14, 200), (4, 300)]),
+ ))
+ .id();
+ app.add_systems(Update, resolve_equipment);
+ app.update();
+ drop(rx);
+ let w = app.world();
+ let eq = *w.get::(player).expect("equipment resolved");
+ let helm_attached = w
+ .get::(player)
+ .is_some_and(|h| h.slots[3].is_some());
+ (eq, helm_attached)
+ }
+
+ /// **B123** (decision 1472): the two equipment-display preferences are consumed on the WORLD
+ /// body, not only on the character-select one. `PLAYER_FLAGS_HIDE_HELM 0x400` /
+ /// `HIDE_CLOAK 0x800` zero the resolved display id, which is what makes every downstream
+ /// consumer follow — no cape geoset, no cape texture, no helm attach model, and no RF-0083
+ /// hide-mask stripping the hair.
+ #[test]
+ fn the_hide_preferences_undress_the_helm_and_cloak_on_a_world_body() {
+ let (shown, helm_attached) = dress(0);
+ assert_eq!(shown.helm, 900, "no preference set: the helm is worn");
+ assert_eq!(shown.cloak, 800, "…and so is the cloak");
+ assert!(
+ helm_attached,
+ "…and the helm's attach sub-model is asked for"
+ );
+
+ let (hidden, helm_attached) = dress(0x400 | 0x800);
+ assert_eq!(hidden.helm, 0, "hide-helm zeroes the head slot");
+ assert_eq!(hidden.cloak, 0, "hide-cloak zeroes the back slot");
+ assert!(
+ !helm_attached,
+ "the ATTACH half follows the geoset half — the two can never disagree"
+ );
+ assert_eq!(
+ hidden.bodyslots, shown.bodyslots,
+ "and nothing else the player is wearing moves"
+ );
+ assert!(hidden.settled, "the worn set is still fully resolved");
+ }
+
+ /// The two bits are independent: hiding one leaves the other worn.
+ #[test]
+ fn the_two_hide_preferences_do_not_reach_each_other() {
+ let (helm_only, helm_attached) = dress(0x400);
+ assert_eq!((helm_only.helm, helm_only.cloak), (0, 800));
+ assert!(!helm_attached);
+ let (cloak_only, helm_attached) = dress(0x800);
+ assert_eq!((cloak_only.helm, cloak_only.cloak), (900, 0));
+ assert!(helm_attached);
+ }
}
diff --git a/crates/benilla-app/src/minimap/composite.rs b/crates/benilla-app/src/minimap/composite.rs
new file mode 100644
index 00000000..86dfe2bf
--- /dev/null
+++ b/crates/benilla-app/src/minimap/composite.rs
@@ -0,0 +1,238 @@
+//! The WMO-interior minimap's **offscreen composite** — the client's own compositing pipeline
+//! (decision 1466, from wow-re `system/minimap/scratch/wmo-interior-minimap-composite.md`).
+//!
+//! Indoors the reference does not draw the group tiles onto the screen. It draws them into a fixed
+//! **256 × 256** render target created once at `0x4eda42`/`0x4eda48`, under an ortho half-extent of
+//! **1.5 × the view radius** (`0x4ec130`, the `× 1.5` at `0x80308c`), cleared **colour-only** to
+//! opaque black (the packed `0xAARRGGBB` unpacked at `0x59b910`), with **blending disabled** and an
+//! **alpha test** at `GEQUAL 224/255` — then blits the **middle two-thirds** of that target, which
+//! is what nets the `1.0 × radius` on screen.
+//!
+//! **Both halves of that are load-bearing, and they only work together.** The alpha test is what
+//! stops two group tiles that meet along a shared wall from leaving the clear colour between them
+//! (blended, each contributes a filtered partial edge and up to 25% of the black shows through —
+//! B141's "odd black lines", at *every* joint). But the target's resolution is what stops the
+//! **genuine** gaps in the bake from reading: `3 · radius / 256` model-yd per texel is 0.703 yd at
+//! the default indoor zoom against the tiles' authored 0.5 — a ~1.4× **minification before the
+//! test**, in which a one-texel bake gap is simply never sampled. Drawing the tiles alpha-tested
+//! straight to the screen, where we rasterise ~1.85× *finer* than the client ever did, turns those
+//! same gaps from a soft grey line into a hard black one — measured, and the reason this module
+//! exists rather than just the render state (1466's "why part 1 alone makes it worse").
+//!
+//! Mechanically: [`super::emit_minimap`]'s interior branch fills [`MinimapComposite`] with this
+//! frame's tiles in **target space** instead of pushing them at the screen; [`drive_composite`]
+//! materialises them as pooled quads on the composite camera's own render layer; and the interior
+//! branch draws ONE screen quad sampling the target, masked to the minimap circle as usual.
+//!
+//! The tiles ride a **shared unit-quad mesh and a per-texture material**, moving on their
+//! `Transform` alone (1463's law): the composite re-aims every frame the player walks, and
+//! rebuilding ~50 meshes a frame to express a pan is exactly the churn that pass avoids.
+
+use bevy::asset::RenderAssetUsages;
+use bevy::camera::visibility::RenderLayers;
+use bevy::camera::{ClearColorConfig, RenderTarget, ScalingMode};
+use bevy::prelude::*;
+use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat, TextureUsages};
+
+use crate::portrait::MINIMAP_COMPOSITE_LAYER;
+use crate::ui_pass::UiQuadMaterial;
+
+/// The composite target's edge, in texels — the client's `mov edx,0x100` at `0x4eda42`. It is a
+/// **fidelity constant, not a quality knob**: raising it re-opens the hairlines this module exists
+/// to close, because the bake's own one-texel gaps stop being sub-texel (see the module docs).
+pub(super) const RT_SIZE: u32 = 256;
+
+/// The composite's ortho half-extent as a multiple of the on-screen view radius (`0x80308c`). The
+/// target holds 1.5 × what is shown; the blit takes the middle two-thirds back out.
+pub(super) const RT_HALF_EXTENT_SCALE: f32 = 1.5;
+
+/// The fraction of the target's edge the blit shows — `1 / RT_HALF_EXTENT_SCALE`, i.e. the middle
+/// two-thirds, which is what nets `1.0 × radius` on screen.
+pub(super) const RT_BLIT_FRACTION: f32 = 1.0 / RT_HALF_EXTENT_SCALE;
+
+/// One tile to composite, already in **target space**: the camera's y-up units, origin at the
+/// target's centre (= the player), `RT_SIZE / 2` units to an edge.
+pub(super) struct CompositeTile {
+ pub(super) texture: Handle,
+ /// Centre, in target units.
+ pub(super) center: Vec2,
+ /// Size, in target units.
+ pub(super) size: Vec2,
+ /// The placement yaw, as a **clockwise-on-screen** angle (what the screen path uses); negated
+ /// on the way into the camera's y-up frame.
+ pub(super) rotation: f32,
+ /// Draw order within the composite — ascending draws later (on top), matching the group sort.
+ pub(super) order: usize,
+}
+
+/// This frame's interior composite: the tiles to draw and whether the composite is live at all.
+/// Written by [`super::emit_minimap`], consumed by [`drive_composite`].
+#[derive(Resource, Default)]
+pub(super) struct MinimapComposite {
+ /// `false` = outdoors (or no minimap widget): the camera is switched off and the pool retires.
+ pub(super) active: bool,
+ pub(super) tiles: Vec,
+}
+
+/// The composite's durable pieces: the target image, its camera, the shared quad mesh, the
+/// per-texture material cache and the entity pool.
+#[derive(Resource)]
+pub(super) struct CompositeRig {
+ /// The render target — sampled by the blit quad on the screen lane.
+ pub(super) image: Handle,
+ camera: Entity,
+ quad: Handle,
+ /// Materials keyed by tile texture. A WMO's tile set is small and stable while you are inside
+ /// it, so this fills once per building rather than per frame.
+ materials: bevy::platform::collections::HashMap, Handle>,
+ pool: Vec,
+}
+
+/// Build the target, its camera and the shared quad once, at startup.
+///
+/// The target is **un-encoded** and float, exactly like the portrait booths' (decisions 0254/0804):
+/// the UI arc composites in gamma bytes and does its one sRGB encode at the end, so a target that
+/// pre-encoded would land a second encode downstream — and quantising un-encoded values to 8 bits
+/// is B126's banding collapse, which a map of large flat colour fields would show as plainly as the
+/// glue screens did. The tile draw therefore writes the sampled texel **un-encoded** (the
+/// [`UiQuad::alpha_test`](crate::ui_pass::UiQuad::alpha_test) arm in `ui_quad.wgsl`), and the blit
+/// quad encodes it on the way to the screen like any other UI texture.
+pub(super) fn setup_composite(
+ mut commands: Commands,
+ mut images: ResMut>,
+ mut meshes: ResMut>,
+) {
+ let mut image = Image::new_fill(
+ Extent3d {
+ width: RT_SIZE,
+ height: RT_SIZE,
+ depth_or_array_layers: 1,
+ },
+ TextureDimension::D2,
+ &[0; 8],
+ TextureFormat::Rgba16Float,
+ RenderAssetUsages::default(),
+ );
+ image.texture_descriptor.usage =
+ TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST | TextureUsages::RENDER_ATTACHMENT;
+ let image = images.add(image);
+
+ // A 1×1 quad centred on the origin: every tile is this mesh under its own Transform, so a
+ // panning composite never rewrites a vertex buffer (1463).
+ let quad = meshes.add(Rectangle::new(1.0, 1.0));
+
+ let camera = commands
+ .spawn((
+ Camera2d,
+ // **No MSAA** — the client's target is a plain RGBA8 surface with none, and here it is
+ // load-bearing rather than cosmetic: multisampling an alpha-TESTED edge hands back
+ // partial coverage, and partial coverage over an opaque black clear is precisely the
+ // grey seam this whole module exists to remove. The test's whole point is that a
+ // fragment is all or nothing.
+ bevy::render::view::Msaa::Off,
+ RenderLayers::layer(MINIMAP_COMPOSITE_LAYER),
+ RenderTarget::Image(image.clone().into()),
+ Camera {
+ // The client's clear: colour only, opaque black (`0x4ec8ef`, mask 1). Everything
+ // the bake does not cover reads as this — including, faithfully, the exterior
+ // group's whole footprint, which has no authored tile at all.
+ clear_color: ClearColorConfig::Custom(Color::BLACK),
+ // Off until the player is indoors; `drive_composite` owns the switch.
+ is_active: false,
+ ..default()
+ },
+ // One target unit = one target texel, origin at the centre.
+ Projection::Orthographic(OrthographicProjection {
+ scaling_mode: ScalingMode::Fixed {
+ #[allow(clippy::cast_precision_loss)] // 256 is exact in f32
+ width: RT_SIZE as f32,
+ #[allow(clippy::cast_precision_loss)]
+ height: RT_SIZE as f32,
+ },
+ ..OrthographicProjection::default_2d()
+ }),
+ ))
+ .id();
+
+ commands.insert_resource(CompositeRig {
+ image,
+ camera,
+ quad,
+ materials: bevy::platform::collections::HashMap::default(),
+ pool: Vec::new(),
+ });
+}
+
+/// Materialise [`MinimapComposite`] onto the composite camera: one pooled entity per tile, each the
+/// shared quad under a Transform, each wearing its texture's cached material.
+pub(super) fn drive_composite(
+ mut composite: ResMut,
+ rig: Option>,
+ mut commands: Commands,
+ mut materials: ResMut>,
+ mut cameras: Query<&mut Camera>,
+) {
+ let Some(mut rig) = rig else { return };
+ if let Ok(mut cam) = cameras.get_mut(rig.camera) {
+ let want = composite.active;
+ if cam.is_active != want {
+ cam.is_active = want;
+ }
+ }
+ if !composite.active {
+ for e in rig.pool.drain(..) {
+ commands.entity(e).despawn();
+ }
+ composite.tiles.clear();
+ return;
+ }
+
+ // Ascending order draws later. The composite camera's own ortho spans ±0.5·RT_SIZE in z by
+ // default, so keep the span well inside it however many tiles a zoom level asks for.
+ let count = composite.tiles.len().max(1);
+ for (i, tile) in composite.tiles.iter().enumerate() {
+ let material = rig
+ .materials
+ .entry(tile.texture.id())
+ .or_insert_with(|| {
+ materials.add(UiQuadMaterial::interior_tile(
+ tile.texture.clone(),
+ super::INTERIOR_TILE_ALPHA_REF,
+ ))
+ })
+ .clone();
+ #[allow(clippy::cast_precision_loss)] // tile counts are in the tens
+ let z = -50.0 + (tile.order as f32 / count as f32) * 100.0;
+ let transform = Transform {
+ translation: tile.center.extend(z),
+ // The screen path's angle is clockwise-on-screen; the camera's frame is y-up.
+ rotation: Quat::from_rotation_z(-tile.rotation),
+ scale: tile.size.extend(1.0),
+ };
+ match rig.pool.get(i) {
+ Some(&e) => {
+ commands.entity(e).insert((
+ Mesh2d(rig.quad.clone()),
+ MeshMaterial2d(material),
+ transform,
+ ));
+ }
+ None => {
+ let e = commands
+ .spawn((
+ Mesh2d(rig.quad.clone()),
+ MeshMaterial2d(material),
+ transform,
+ RenderLayers::layer(MINIMAP_COMPOSITE_LAYER),
+ ))
+ .id();
+ rig.pool.push(e);
+ }
+ }
+ }
+ let used = composite.tiles.len();
+ for e in rig.pool.drain(used..) {
+ commands.entity(e).despawn();
+ }
+ composite.tiles.clear();
+}
diff --git a/crates/benilla-app/src/minimap/mod.rs b/crates/benilla-app/src/minimap/mod.rs
index 30240f90..b9a266b7 100644
--- a/crates/benilla-app/src/minimap/mod.rs
+++ b/crates/benilla-app/src/minimap/mod.rs
@@ -28,11 +28,12 @@
//! hover tooltip).
mod blips;
+mod composite;
mod interior;
use std::collections::HashMap;
-use bevy::math::Rect;
+use bevy::math::{Affine3A, Rect};
use bevy::prelude::*;
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
@@ -45,7 +46,7 @@ use interior::{interior_group_selection, wmo_minimap_stem};
use benilla_ui::widget::MINIMAP_DEFAULT_ZOOM;
use crate::player::Player;
-use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuadMask, UiQuads};
+use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuadMask, UiQuads, UvRect};
use benilla_assets::MapCatalogRes;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_world::wmo_portal::{
@@ -79,6 +80,28 @@ const ZOOM_CHUNKS: [f32; 6] = [14.0, 12.0, 10.0, 8.0, 6.0, 4.0];
/// the field through a computed pointer. Superseded in wow-re; do not "restore" a constant here.
const INTERIOR_ZOOM_RADIUS: [f32; 6] = [150.0, 120.0, 90.0, 60.0, 40.0, 25.0];
+/// The client's half-texel UV inset, as the quad scale that reproduces it: a tile spanning
+/// `extent` yards is baked at [`YD_PER_TEXEL`](benilla_assets::minimap_grid::YD_PER_TEXEL), so it
+/// is `W = extent / 0.5` texels wide, and mapping texel *centres* to the quad's edges instead of
+/// texel *edges* stretches it by `W / (W − 1)`. See the call site for why it matters.
+fn texel_stretch(extent_yd: f32) -> f32 {
+ let texels = extent_yd / benilla_assets::minimap_grid::YD_PER_TEXEL;
+ if texels > 1.0 {
+ texels / (texels - 1.0)
+ } else {
+ 1.0
+ }
+}
+
+/// The interior tile draw's **alpha-test reference** — `224/255`, the client's
+/// `glAlphaFunc(GL_GEQUAL, 0.87843144)`. It is never set explicitly: the tile draw sets EGxBlend
+/// **1** (whose applicator `glDisable`s blending), and `SetRenderState`'s id-7→id-8 cascade reads
+/// `.data 0x85ad20[1] = 224` and multiplies by the f32 reciprocal of 255 — `0x3F60E0E2`, one ULP
+/// above `224/255` (wow-re `system/minimap/scratch/wmo-interior-minimap-composite.md`, VERIFIED).
+/// Written as the exact f32 the client computes rather than the ratio, because that ULP is the
+/// value fragments are compared against.
+const INTERIOR_TILE_ALPHA_REF: f32 = f32::from_bits(0x3F60_E0E2);
+
/// The corpse blip's edge as a fraction of the widget side (the POIIcons cell is authored 16px on
/// a 140px minimap ≈ 0.11; INTERIM eyeball beside [`ARROW_FRACTION`]'s).
const CORPSE_BLIP_FRACTION: f32 = 0.11;
@@ -271,6 +294,57 @@ fn setup_minimap(
}
}
+/// The minimap's **one** containment verdict — which map family draws, and (through
+/// [`feed_minimap_inside`]) which zoom index the +/- buttons drive. The client keeps a single flag
+/// for both (`0xceaa60`), so this must be computed once and shared: two probes drifting apart is
+/// how the map ends up drawn at the wrong scale for the family it is showing.
+///
+/// The reference hard-switches map families on interior containment: standing inside a WMO group it
+/// draws that building's OWN minimap tiles and SUPPRESSES the terrain (mutually exclusive, not a
+/// transparent overlay). Returns the placement, model, `md5translate.trs` path stem and seed group
+/// of the WMO the player is in, or `None` for the terrain family.
+///
+/// **The gate is the client's one indoor byte** (`0xbc8300`), and that byte is the CGLight node's
+/// down-ray bit `[node+0x90] & 1` (`0x670547` — wow-re `wmo-interior-minimap-composite.md`, which
+/// CORRECTED the old note's "containment resolves to a group with `0x10` set": that `0x10` is a
+/// ctor-set class tag, not a group flag). The predicate is a **position cast, faces only**: the
+/// nearest surface within 1000 yd straight down — terrain racing the WMO faces, closer wins and the
+/// WMO wins ties — is a WMO face whose group lacks MOGP `0x8`. Terrain below ⇒ outdoors, whatever
+/// building you are geometrically inside. That is exactly [`CurrentAreaInterior`]'s law
+/// (`wmo_portal::area_down_ray`, the zone-text bit), so the gate reads it rather than re-deriving
+/// one: the portal-crossing leg in [`down_ray_seeds`] belongs to the CAMERA's current-group system
+/// and claiming an interior through a doorway plane under the eye is the abbey-yard bug's shape.
+/// [`down_ray_seeds`] still supplies the flood SEED once the gate has said indoors.
+fn minimap_interior<'a>(
+ player: &Player,
+ instances: &Query<&WmoPortalInstance>,
+ wmos: &'a Assets,
+ world: &benilla_world::world_point::WorldPoint,
+ asset_server: &AssetServer,
+) -> Option<(Affine3A, &'a WmoModel, String, usize)> {
+ if !player.active || player.detached || world.area_interior().is_none() {
+ return None;
+ }
+ let eye = player.pos + Vec3::Y * INTERIOR_PROBE_HEIGHT;
+ // The down-ray races the terrain, exactly as the interior/zone tracker does — standing on the
+ // grass above a mine's tunnels is not standing in the mine.
+ let terrain = world.terrain_height_under(eye);
+ instances.iter().find_map(|inst| {
+ let model = wmos.get(&inst.handle)?;
+ if model.wmo_id == 0 {
+ return None;
+ }
+ let local_from_world = inst.world_from_local.inverse();
+ let eye_local = bevy_to_wow(local_from_world.transform_point3(eye));
+ let terrain_local = terrain.map(|z| terrain_z_local(&local_from_world, eye, z));
+ let in_group = down_ray_seeds(model, eye_local, terrain_local).in_group?;
+ let stem = asset_server
+ .get_path(inst.handle.id())
+ .and_then(|p| wmo_minimap_stem(&p.path().to_string_lossy()))?;
+ Some((inst.world_from_local, model, stem, in_group))
+ })
+}
+
/// Fills the extracted widget hole: the visible tile quads (clipped to the widget, masked to the
/// circle) and the player arrow, appended at the widget's own z (stable sort keeps append order
/// within a key, so the arrow rides above the tiles and below the widget's children).
@@ -289,6 +363,8 @@ fn emit_minimap(
asset_server: Res,
death_net: Res,
blip_inputs: blips::BlipInputs,
+ mut composite: ResMut,
+ rig: Option>,
mut quads: ResMut,
) {
let (
@@ -317,6 +393,11 @@ fn emit_minimap(
if side <= 0.0 {
return;
}
+ // The composite is off unless the interior branch below turns it on this frame.
+ composite.active = false;
+ let Some(rt_image) = rig.map(|r| r.image.clone()) else {
+ return; // the composite rig's Startup system has not run yet
+ };
// `WOW_MM_ZOOM=0..5` forces the zoom level of whichever map is showing — a capture instrument
// (pairs with the `WOW_MM_PROBE` interior probe). Indoors and outdoors each carry their own
// persisted index, so the override stands in for both.
@@ -339,35 +420,12 @@ fn emit_minimap(
rect: slot.rect,
});
- // The reference hard-switches map families on interior containment: standing inside a WMO group,
- // it draws that building's OWN minimap tiles and SUPPRESSES the terrain (mutually exclusive, not
- // a transparent overlay). Find the WMO the player is in via the same down-ray as the interior
- // audio/zone tracker (`wmo_portal`), plus its `md5translate.trs` path stem.
- let interior = (player.active && !player.detached).then(|| {
- let eye = player.pos + Vec3::Y * INTERIOR_PROBE_HEIGHT;
- // The down-ray races the terrain, exactly as the interior/zone tracker does — standing on the
- // grass above a mine's tunnels is not standing in the mine.
- let terrain = world.terrain_height_under(eye);
- instances.iter().find_map(|inst| {
- let model = wmos.get(&inst.handle)?;
- if model.wmo_id == 0 {
- return None;
- }
- let local_from_world = inst.world_from_local.inverse();
- let eye_local = bevy_to_wow(local_from_world.transform_point3(eye));
- let terrain_local = terrain.map(|z| terrain_z_local(&local_from_world, eye, z));
- let in_group = down_ray_seeds(model, eye_local, terrain_local).in_group?;
- let stem = asset_server
- .get_path(inst.handle.id())
- .and_then(|p| wmo_minimap_stem(&p.path().to_string_lossy()))?;
- Some((inst.world_from_local, model, stem, in_group))
- })
- });
+ let interior = minimap_interior(&player, &instances, &wmos, &world, &asset_server);
// The player's containment verdict, kept as a bool for the quest-dot grey (the branch
// below consumes `interior` itself).
- let player_indoors = matches!(&interior, Some(Some(_)));
- if let Some(Some((world_from_local, model, stem, in_group))) = interior {
+ let player_indoors = interior.is_some();
+ if let Some((world_from_local, model, stem, in_group)) = interior {
// INTERIOR: the WMO's own per-group tiles, drawn FULL WHITE (the day-night tint is outdoor-
// only). The tiles are baked in the WMO's MODEL frame (north = model +X, sized to the model
// footprint — verified against the 97°-yaw Goldshire Inn: group 3's tile is 64×32 px = its
@@ -378,17 +436,6 @@ fn emit_minimap(
cache.interior.clear();
cache.interior_stem = Some(stem.clone());
}
- // Black disc behind the (mostly-transparent) tiles — the reference clears the interior
- // minimap to black. Appended first (same z) so the stable sort keeps it under the tiles.
- quads.overlays.push(UiQuad {
- rect: slot.rect,
- z_key: slot.z,
- color: [0.0, 0.0, 0.0, slot.alpha],
- clip: Some(slot.rect),
- mask: mask.clone(),
- ..default()
- });
-
// INTERIOR ZOOM: indoors has its OWN zoom index and its own table — the view radius is
// `radiusTable[inside_zoom]` in raw yards (150 widest … 25 tightest), not the outdoor chunk
// half-extent. The zoom buttons drive `inside_zoom` while you're inside, and it persists
@@ -397,18 +444,27 @@ fn emit_minimap(
let px_per_yd = (side * 0.5) / radius;
blip_px_per_yd = px_per_yd;
- // A model point → its north-up minimap screen px (through the placement to world, then the
- // same world→screen map the terrain tiles use: screen up = world +X north, left = +Y west).
- let to_screen = |m: [f32; 3]| {
+ // The tiles are composited into the client's own 256² TARGET, not drawn at the screen —
+ // both halves of the mechanism matter and only work together (decision 1466; the module
+ // docs in [`composite`] carry the why). Target space: y-UP, origin at the target's centre
+ // (= the player), and `RT_HALF_EXTENT_SCALE · radius` yards to an edge.
+ #[allow(clippy::cast_precision_loss)] // 256 is exact in f32
+ let units_per_yd =
+ (composite::RT_SIZE as f32 * 0.5) / (composite::RT_HALF_EXTENT_SCALE * radius);
+ // A model point → its north-up target position (through the placement to world, then the
+ // same north-up map the terrain tiles use: up = world +X north, left = +Y west).
+ let to_target = |m: [f32; 3]| {
let w = bevy_to_wow(world_from_local.transform_point3(wow_to_bevy(m)));
- Vec2::new(
- center.x + (wy - w[1]) * px_per_yd,
- center.y - (w[0] - wx) * px_per_yd,
- )
+ Vec2::new((wy - w[1]) * units_per_yd, (w[0] - wx) * units_per_yd)
};
- // The one placement rotation: where the model +X axis points on the screen (same per tile).
- let x_axis = to_screen([1.0, 0.0, 0.0]) - to_screen([0.0, 0.0, 0.0]);
- let rotation = x_axis.y.atan2(x_axis.x);
+ // The one placement rotation: where the model +X axis points, as a CLOCKWISE-on-screen
+ // angle (the target's y-up frame negates it at the Transform). Same for every tile.
+ let x_axis = to_target([1.0, 0.0, 0.0]) - to_target([0.0, 0.0, 0.0]);
+ let rotation = (-x_axis.y).atan2(x_axis.x);
+ // The target's own edge, in target units, for the window cull below.
+ #[allow(clippy::cast_precision_loss)]
+ let rt_half = composite::RT_SIZE as f32 * 0.5;
+ composite.active = true;
// GROUP SELECTION: the portal flood-fill from the player's current group (wow-re
// `wmo-interior-minimap.md` Sub-Q4b, byte-verified) — NOT draw-every-group. Only the groups
@@ -447,9 +503,11 @@ fn emit_minimap(
for row in 0..ny {
let mcx = gn.bbox_min[0] + (col as f32 + 0.5) * tw_x;
let mcy = gn.bbox_min[1] + (row as f32 + 0.5) * tw_y;
- let sc = to_screen([mcx, mcy, mid_z]);
- // Window cull: skip tiles whose centre lands well outside the disc.
- if sc.distance(center) > side * 0.5 + tw_x.max(tw_y) * px_per_yd {
+ let tc = to_target([mcx, mcy, mid_z]);
+ // Window cull: skip tiles whose centre lands well outside the TARGET (which
+ // holds 1.5× what the blit shows, so this is wider than the visible disc —
+ // deliberately: the client composites the same margin).
+ if tc.length() > rt_half + tw_x.max(tw_y) * units_per_yd {
continue;
}
let handle = cache.interior.entry((gi, col, row)).or_insert_with(|| {
@@ -459,21 +517,48 @@ fn emit_minimap(
let Some(handle) = handle else {
continue; // this group cell has no authored tile
};
- quads.overlays.push(UiQuad {
- rect: Rect::from_center_size(
- sc,
- Vec2::new(tw_x * px_per_yd, tw_y * px_per_yd),
+ let order = composite.tiles.len();
+ composite.tiles.push(composite::CompositeTile {
+ texture: handle.clone(),
+ center: tc,
+ // The client's HALF-TEXEL UV INSET (`0x4ebfca`–`0x4ec03b`: `du = 0.5/W`,
+ // `dv = 0.5/H`), expressed as the scale it is: sampling `[du, 1-du]` across
+ // a quad of size `S` is the same as sampling `[0, 1]` across a quad of
+ // `S·W/(W−1)` about the same centre, so the shared unit mesh keeps its UVs
+ // and the inset rides the Transform like everything else here. The quad
+ // edges then land on the outermost texel CENTRES, which stretches each
+ // tile's art outward by half a texel per side — and that is what closes
+ // the ~20% of group joints that butt rather than overlap. Without it a
+ // butt joint leaves a texel-wide strip that no tile covers, and the clear
+ // reads through it for the whole length of the wall (B141's residue after
+ // the alpha test alone).
+ size: Vec2::new(
+ tw_x * units_per_yd * texel_stretch(tw_x),
+ tw_y * units_per_yd * texel_stretch(tw_y),
),
- z_key: slot.z,
- texture: Some(handle.clone()),
- color: [1.0, 1.0, 1.0, slot.alpha],
rotation,
- mask: mask.clone(),
- ..default()
+ order,
});
}
}
}
+
+ // THE BLIT: the target's middle two-thirds, which is what nets `1.0 · radius` on screen
+ // (the client's `0x4ec440` under EGxBlend 2). One quad, masked to the minimap circle — the
+ // round cut belongs HERE and not to the tiles, exactly as the reference's does. The target
+ // is opaque everywhere (its clear is opaque black), so this quad IS the black backing the
+ // screen path used to push separately.
+ let lo = 0.5 - composite::RT_BLIT_FRACTION * 0.5;
+ let hi = 0.5 + composite::RT_BLIT_FRACTION * 0.5;
+ quads.overlays.push(UiQuad {
+ rect: Rect::from_center_size(center, Vec2::splat(side)),
+ z_key: slot.z,
+ texture: Some(rt_image),
+ uv: UvRect::from_tex_coords([lo, hi, lo, hi]),
+ color: [1.0, 1.0, 1.0, slot.alpha],
+ mask: mask.clone(),
+ ..default()
+ });
} else if let Some(dir) = catalog.0.directory(map.0) {
// OUTDOOR: the ADT terrain tiles, MODULATEd by the day-night light tint (not full white —
// else too bright, the reference's CWorldFrame minimap draw). Absent lighting ⇒ white.
@@ -701,8 +786,11 @@ fn emit_minimap(
/// Push the player's WMO-containment state onto the Minimap widget (the client's `0xceaa60`), so the
/// zoom buttons drive the **indoor** zoom index while indoors and the outdoor one while outside, each
/// persisting across the transition. Runs before the script tick, so a `SetZoom` fired from a button
-/// handler this frame routes to the right index. `CurrentWmoInterior` is the same containment test the
-/// interior audio/zone tracker uses.
+/// handler this frame routes to the right index. The verdict is [`minimap_interior`]'s — the SAME
+/// one [`emit_minimap`] draws by, because the client keeps one flag for both (it used to read the
+/// camera-eye `CurrentWmoInterior` instead, which is a different ray *and*, since 1466, a different
+/// mask: the buttons would have kept driving the indoor index on a Stormwind street the map was
+/// drawing from terrain tiles).
/// The state is pushed on the inside↔outside *edge* and whenever the VM's Minimap-creation count
/// moved (the cluster XML loads late, and an addon can build one whenever it likes — the counter
/// is what keeps "a widget created after the last transition is still told" true without walking
@@ -712,14 +800,19 @@ fn emit_minimap(
/// — the client's own signal for "the effective zoom changed" (FrameXML `Minimap_OnEvent`). Without it
/// the buttons keep the level you left (e.g. `ZoomIn` greyed from an outdoor max-zoom, still greyed
/// indoors at level 3), which is the director's report (2026-07-09).
+#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
fn feed_minimap_inside(
script: Option>,
world: benilla_world::world_point::WorldPoint,
+ player: Res,
+ instances: Query<&WmoPortalInstance>,
+ wmos: Res>,
+ asset_server: Res,
mut was_inside: Local>>,
mut pushed_at: Local>,
) {
let Some(mut script) = script else { return };
- let inside = world.interior().is_some();
+ let inside = minimap_interior(&player, &instances, &wmos, &world, &asset_server).is_some();
let edge = *was_inside.get(&script) != Some(inside);
let created = script.minimap_widgets_created();
if edge || *pushed_at.get(&script) != created {
@@ -772,11 +865,17 @@ impl Plugin for MinimapPlugin {
.init_resource::()
.init_resource::()
.init_resource::()
+ .init_resource::()
.add_systems(Startup, setup_minimap.after(AssetSet::Open))
+ .add_systems(Startup, composite::setup_composite)
.add_systems(
Update,
(
emit_minimap.in_set(UiQuadAppend),
+ // After the emit that fills it: the composite camera draws what THIS frame's
+ // interior branch asked for, so the target the blit quad samples is never a
+ // frame behind the pan.
+ composite::drive_composite.after(UiQuadAppend),
// Before the script tick, so a zoom button pressed this frame routes to the
// indoor/outdoor index that matches where the player actually is.
feed_minimap_inside.before(crate::ui_script::UiInput),
diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs
index 672b7221..1e4d65a4 100644
--- a/crates/benilla-app/src/net.rs
+++ b/crates/benilla-app/src/net.rs
@@ -1401,6 +1401,13 @@ pub(crate) enum ClientCommand {
/// the unit popup's PvP row, both through the VM's intent queue. Nothing local changes; the
/// answer is the descriptor's PvP bit (and flagging *off* waits out the server's 300 s timer).
TogglePvp,
+ /// Ask to flip our own show-helm preference (`CMSG_TOGGLE_HELM`, empty body — decision 1472):
+ /// the Options window's *Show Helm* row, through the VM's intent queue. Nothing local changes;
+ /// the answer is `PLAYER_FLAGS`' `HIDE_HELM` bit, which is what dresses the body — ours and
+ /// every other player's, since the field is public.
+ ToggleHelm,
+ /// The cloak half of [`Self::ToggleHelm`] (`CMSG_TOGGLE_CLOAK`, empty body).
+ ToggleCloak,
// ── The guild family (writer bodies in benilla-protocol `world/writer/guild.rs`) ──────────
//
// Three things shape every caller of this band. **Members are addressed by NAME**, not by
diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs
index 79c02ae6..63004c5b 100644
--- a/crates/benilla-app/src/net/io.rs
+++ b/crates/benilla-app/src/net/io.rs
@@ -917,6 +917,8 @@ fn writer_loop(
ClientCommand::DuelAccepted { arbiter } => w.duel_accepted(arbiter),
ClientCommand::DuelCancelled { arbiter } => w.duel_cancelled(arbiter),
ClientCommand::TogglePvp => w.toggle_pvp(),
+ ClientCommand::ToggleHelm => w.toggle_helm(),
+ ClientCommand::ToggleCloak => w.toggle_cloak(),
ClientCommand::FriendListRequest => w.friend_list(),
ClientCommand::AddFriend { name } => w.add_friend(&name),
ClientCommand::DelFriend { guid } => w.del_friend(guid),
diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs
index 95b70c9d..d2f68008 100644
--- a/crates/benilla-app/src/portrait/mod.rs
+++ b/crates/benilla-app/src/portrait/mod.rs
@@ -143,6 +143,12 @@ pub(super) const DRESSUP_LAYER: usize = GLUE_LAYER + 1;
/// rule as [`GLUE_LAYER`]). This camera exists only while the warm pass runs
/// ([`spawn_warm_booth`]); nothing but menagerie rigs ever rides its layer.
pub(crate) const WARM_BOOTH_LAYER: usize = DRESSUP_LAYER + 1;
+/// The **minimap interior composite**'s render layer (decision 1466) — the next one past the warm
+/// booth's. Not a portrait booth, but it is an offscreen camera with its own layer, and 0775's rule
+/// is that EVERY such layer is computed in this one ladder: the two booths that each worked out
+/// "the next layer past the paper doll's" in their own file landed on the same number, and the
+/// clash was silent in both rendering and the emitter→camera match.
+pub(crate) const MINIMAP_COMPOSITE_LAYER: usize = WARM_BOOTH_LAYER + 1;
// The ladder must stay collision-free: a booth camera's layer is its identity for both rendering
// and the emitter→camera match, and the failure above was silent in both.
@@ -154,7 +160,8 @@ const _: () = assert!(
&& PAPERDOLL_LAYER != GLUE_LAYER
&& INSPECT_LAYER != GLUE_LAYER
&& DRESSUP_LAYER > GLUE_LAYER
- && WARM_BOOTH_LAYER > DRESSUP_LAYER,
+ && WARM_BOOTH_LAYER > DRESSUP_LAYER
+ && MINIMAP_COMPOSITE_LAYER > WARM_BOOTH_LAYER,
"booth render layers must be distinct — see GLUE_LAYER"
);
const _: () = assert!(
diff --git a/crates/benilla-app/src/shaders/ui_quad.wgsl b/crates/benilla-app/src/shaders/ui_quad.wgsl
index 2c2e3766..ced11920 100644
--- a/crates/benilla-app/src/shaders/ui_quad.wgsl
+++ b/crates/benilla-app/src/shaders/ui_quad.wgsl
@@ -76,6 +76,21 @@
// how a transparent-clear pane lost the R14 pauldrons' fire entirely and kept a weapon glow only
// where it overlapped the model's own opaque pixels.
@group(2) @binding(8) var premultiplied: u32;
+// **Alpha TEST** reference — `<= 0` disables. The WMO-interior minimap tiles, and nothing else so
+// far: the reference draws them under EGxBlend **1**, whose applicator `glDisable`s blending
+// outright, with the `SetRenderState` id-7→id-8 cascade arming `glAlphaFunc(GL_GEQUAL,
+// 0.87843144)` — `.data 0x85ad20[1] = 224`, times the f32 reciprocal of 255 (wow-re
+// `system/minimap/scratch/wmo-interior-minimap-composite.md`). So a tile fragment either writes
+// FULLY OPAQUE or is discarded; partial coverage does not exist on that path, and two overlapping
+// group tiles can never leave the clear colour showing between them. Blending them the ordinary
+// way leaves `(1−a)(1−b)` of the black clear at every boundary — B141's "odd black lines".
+//
+// The tested value is `texel.a × colour.a`, the client's MODULATE of the texel against the
+// stride-0 vertex dword `(frameAlpha << 24) | 0xFFFFFF`. The SCREEN MASK is deliberately NOT in
+// it: the reference alpha-tests each tile into an offscreen and cuts the round mask at the BLIT,
+// so folding the mask ramp into the test would saw the disc's soft rim into a hard, undersized
+// circle.
+@group(2) @binding(9) var alpha_ref: f32;
// ITU-R BT.601 luma — the `PARAM c[0]` of that shader, read as raw f32 words: `0x3E991687`,
// `0x3F1645A2`, `0x3DE978D5`. Not `(0.3, 0.3, 0.3)`, not `(0.3, 0.59, 0.11)` (this file's own first
@@ -128,6 +143,22 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 {
let m = textureSampleLevel(mask_texture, mask_sampler, clamp(muv, vec2(0.0), vec2(1.0)), 0.0).a;
k *= m * inside;
}
+ // The alpha TEST arm (see `alpha_ref`): pass ⇒ fully opaque, fail ⇒ nothing.
+ //
+ // It returns the **un-encoded** texel, not `rgb`: this arm draws only into the minimap's own
+ // 256² composite target, which is un-encoded float like the portrait booths' (decisions
+ // 0254/0804 — the UI arc does its one sRGB encode at the end, so a target that pre-encoded
+ // would land a second one downstream). The blit quad that samples that target is an ordinary
+ // quad and takes the `linear_to_srgb` above, which is where the authored byte comes back.
+ // Nothing is lost by skipping it here: the composite does no colour arithmetic at all — no
+ // blend, no day-night tint, a white vertex colour — so this path is a pure copy, and there is
+ // no gamma-space multiply to preserve.
+ if alpha_ref > 0.0 {
+ if t.a * c.a < alpha_ref {
+ discard;
+ }
+ return vec4(t.rgb * c.rgb, 1.0);
+ }
let a = t.a * k;
// Premultiply in GAMMA (decision 0160's lesson, here for the UI): the hardware `SrcAlpha` factor
// would weight a linearized colour and inflate every soft edge and every dim additive skirt.
diff --git a/crates/benilla-app/src/ui_dressup.rs b/crates/benilla-app/src/ui_dressup.rs
index 1e20a970..cffb6314 100644
--- a/crates/benilla-app/src/ui_dressup.rs
+++ b/crates/benilla-app/src/ui_dressup.rs
@@ -261,6 +261,20 @@ fn player_look(
equipment[idx] = worn;
continue;
}
+ // …and past that point we are dressing the player's OWN gear, which is where the two
+ // equipment-display preferences apply — and where the reference applies them too, by
+ // construction rather than by a test (decision 1472). `DressUpModel::SetUnit 0x476cb0`
+ // `rep movsd`s all twelve per-bodyslot `ItemDisplayInfo` pointers verbatim off the live
+ // player (head `+0x4a8`, cloak `+0x4d0`) and deep-copies its attach tree, so a piece the
+ // world already suppressed is simply not in what gets cloned: **hidden in the world ⇒
+ // hidden in the dressing room.** A TRY-ON gates on nothing and previews the helm or cloak
+ // regardless — which is the branch above, and the reason this test sits *below* it — and
+ // `Dress()` (the Reset button) drops the substitutions and so re-clones back to hidden.
+ // (wow-re `object-layer/scratch/helm-cloak-hide.md` §8, byte-verified; this replaced an
+ // INFERRED guess that the mannequin was unconditional.)
+ if (idx == 0 && s.player_hides_helm()) || (idx == 14 && s.player_hides_cloak()) {
+ continue;
+ }
let Some(entry) = s.player_visible_item_entry(slot).filter(|e| *e != 0) else {
continue;
};
@@ -612,4 +626,55 @@ mod tests {
);
}
}
+
+ /// **The dressing room inherits the equipment-display preferences, and a try-on overrides
+ /// them** (decision 1472; wow-re `object-layer/scratch/helm-cloak-hide.md` §8, byte-verified
+ /// off `DressUpModel::SetUnit 0x476cb0`'s verbatim clone of the live player's display
+ /// pointers). Hidden helm + hidden cloak, so the player's own two pieces are absent from the
+ /// look — and then a tried-on helm shows anyway, because previewing it is the whole feature.
+ #[test]
+ fn a_hidden_helm_stays_hidden_on_the_mannequin_until_one_is_tried_on() {
+ let (cmds, _rx) = commands();
+ let mut items = Items::default();
+ items.insert_template(1000, Some(worn("Worn Helm", 5000, 1)));
+ items.insert_template(1100, Some(worn("Worn Cloak", 5100, 16)));
+ items.insert_template(1200, Some(worn("Worn Chest", 5200, 5)));
+ items.insert_template(2000, Some(worn("Shiny Helm", 7000, 1)));
+ // `PLAYER_FLAGS` (field 190) carrying HIDE_HELM 0x400 | HIDE_CLOAK 0x800.
+ let mut store = player(&[(0, 1000), (14, 1100), (4, 1200)]);
+ store
+ .0
+ .merge(ObjectFields::from_pairs(&[(190u16, 0x400 | 0x800)]));
+
+ let mut room = DressUpRoom::default();
+ room.apply(DressUpIntent::Dress);
+ let look = player_look(&store, &net(), &room, &mut items, &cmds).expect("a look");
+ assert_eq!(
+ look.equipment[0].display_id, 0,
+ "the player's own helm is hidden"
+ );
+ assert_eq!(look.equipment[14].display_id, 0, "and so is their cloak");
+ assert_eq!(
+ look.equipment[4].display_id, 5200,
+ "everything else they are wearing is untouched"
+ );
+
+ // A try-on is not their own gear, so nothing suppresses it — this is the branch the
+ // reference reaches by never testing the flag on the TryOn path at all.
+ room.apply(DressUpIntent::TryOn(2000));
+ room.resolve_pending(&mut items, &cmds);
+ let look = player_look(&store, &net(), &room, &mut items, &cmds).expect("a look");
+ assert_eq!(
+ look.equipment[0].display_id, 7000,
+ "the tried-on helm previews regardless of the preference"
+ );
+
+ // …and Reset drops the substitution, so the mannequin goes back to bare-headed.
+ room.apply(DressUpIntent::Dress);
+ let look = player_look(&store, &net(), &room, &mut items, &cmds).expect("a look");
+ assert_eq!(
+ look.equipment[0].display_id, 0,
+ "Reset re-clones the hidden helm away"
+ );
+ }
}
diff --git a/crates/benilla-app/src/ui_pass.rs b/crates/benilla-app/src/ui_pass.rs
index de736ddb..b586e68e 100644
--- a/crates/benilla-app/src/ui_pass.rs
+++ b/crates/benilla-app/src/ui_pass.rs
@@ -183,6 +183,19 @@ pub(crate) struct UiQuad {
/// Splits the run like `additive`/`circular`; a booth target is its own texture anyway, so no
/// batching is lost.
pub premultiplied: bool,
+ /// **Alpha TEST** instead of alpha blend, at this reference value on the client's
+ /// `texel.a × vertexColour.a` — `Some(224.0 / 255.0)` is the WMO-interior minimap tile draw and
+ /// nothing else so far. A passing fragment writes FULLY OPAQUE (the screen mask still cuts it);
+ /// a failing one is discarded, so partial coverage never exists on this path.
+ ///
+ /// The reference's minimap tile draw sets EGxBlend **1**, whose applicator `glDisable`s
+ /// blending outright, and the `SetRenderState` id-7→id-8 cascade then arms
+ /// `glAlphaFunc(GL_GEQUAL, 0.87843144)` (`.data 0x85ad20[1] = 224`; wow-re
+ /// `wmo-interior-minimap-composite.md`). Blending those tiles instead leaves `(1−a)(1−b)` of
+ /// the black clear at EVERY boundary where two group tiles meet — up to 25% black where the
+ /// two filtered edges are complementary — which is B141's "odd black lines" (they recoloured
+ /// with the backing quad, which is how they were caught). Splits the batch run.
+ pub alpha_test: Option,
/// CPU-clip stand-in for a real scissor rect (see the module doc). `None` = unclipped.
pub clip: Option,
/// Rotate the quad's corners by this many radians **clockwise on screen** about the rect's
@@ -229,6 +242,7 @@ impl Default for UiQuad {
circular: false,
desaturated: false,
premultiplied: false,
+ alpha_test: None,
clip: None,
rotation: 0.0,
mask: None,
@@ -312,6 +326,9 @@ pub(crate) struct UiQuadMaterial {
/// The texel is already premultiplied (a booth bake) — see [`UiQuad::premultiplied`].
#[uniform(8)]
premultiplied: u32,
+ /// Alpha-TEST reference on `texel.a × colour.a`; `<= 0` disables — see [`UiQuad::alpha_test`].
+ #[uniform(9)]
+ alpha_ref: f32,
/// The screen-anchored mask span in **physical framebuffer px** (`min.xy, max.xy` — the
/// fragment shader compares `@builtin(position)`, which is physical): [`UiQuadMask::rect`]
/// scaled by the window's scale factor at mesh build. `z <= x` (degenerate) disables masking.
@@ -324,6 +341,30 @@ pub(crate) struct UiQuadMaterial {
mask: Option>,
}
+impl UiQuadMaterial {
+ /// The **WMO-interior minimap tile** material (decision 1466): the texture, an alpha TEST at
+ /// `alpha_ref`, and nothing else — no tint, no mask, no circle, no desaturate. It is built here
+ /// rather than through the quad stream because these tiles do not draw at the screen at all:
+ /// they draw into the minimap's own 256² composite target on its own camera
+ /// ([`crate::minimap`]'s `composite`), one shared quad mesh per tile under a Transform.
+ ///
+ /// The forced `(One, OneMinusSrcAlpha)` blend in [`Self::specialize`] is what makes this
+ /// reproduce the client's *disabled* blending exactly: a fragment that passes the test emits
+ /// `a = 1`, so the state degenerates to a plain replace — and one that fails emits nothing.
+ pub(crate) fn interior_tile(texture: Handle, alpha_ref: f32) -> Self {
+ Self {
+ additive: 0,
+ texture: Some(texture),
+ circular: 0,
+ desaturate: 0,
+ premultiplied: 0,
+ alpha_ref,
+ mask_rect: Vec4::new(0.0, 0.0, -1.0, -1.0),
+ mask: None,
+ }
+ }
+}
+
impl Material2d for UiQuadMaterial {
fn fragment_shader() -> ShaderRef {
"embedded://benilla_app/shaders/ui_quad.wgsl".into()
@@ -560,6 +601,7 @@ struct Run {
circular: bool,
desaturated: bool,
premultiplied: bool,
+ alpha_test: Option,
mask: Option,
positions: Vec<[f32; 3]>,
uvs: Vec<[f32; 2]>,
@@ -574,6 +616,7 @@ impl Run {
circular: bool,
desaturated: bool,
premultiplied: bool,
+ alpha_test: Option,
mask: Option,
) -> Self {
Self {
@@ -582,6 +625,7 @@ impl Run {
circular,
desaturated,
premultiplied,
+ alpha_test,
mask,
positions: Vec::new(),
uvs: Vec::new(),
@@ -741,6 +785,7 @@ type MatKey = (
bool,
bool,
bool,
+ u32,
Option>,
[u32; 4],
);
@@ -926,6 +971,7 @@ fn rebuild_ui_mesh(
&& r.circular == q.circular
&& r.desaturated == q.desaturated
&& r.premultiplied == q.premultiplied
+ && r.alpha_test == q.alpha_test
&& r.mask == q.mask
});
if !same_run {
@@ -935,6 +981,7 @@ fn rebuild_ui_mesh(
q.circular,
q.desaturated,
q.premultiplied,
+ q.alpha_test,
q.mask.clone(),
));
}
@@ -996,12 +1043,14 @@ fn rebuild_ui_mesh(
run.mask.as_ref().map(|m| m.texture.id())
);
}
+ let alpha_ref = run.alpha_test.unwrap_or(0.0);
let key: MatKey = (
run.texture.id(),
run.additive,
run.circular,
run.desaturated,
run.premultiplied,
+ alpha_ref.to_bits(),
mask.as_ref().map(bevy::asset::Handle::id),
mask_rect.to_array().map(f32::to_bits),
);
@@ -1121,6 +1170,7 @@ fn rebuild_ui_mesh(
circular: u32::from(run.circular),
desaturate: u32::from(run.desaturated),
premultiplied: u32::from(run.premultiplied),
+ alpha_ref,
mask_rect,
mask,
})
diff --git a/crates/benilla-app/src/ui_script/extract/text.rs b/crates/benilla-app/src/ui_script/extract/text.rs
index e17880a8..0132c13a 100644
--- a/crates/benilla-app/src/ui_script/extract/text.rs
+++ b/crates/benilla-app/src/ui_script/extract/text.rs
@@ -160,6 +160,7 @@ pub(super) fn emit(
circular: false,
desaturated: false,
premultiplied: false,
+ alpha_test: None,
clip: text_clip,
rotation: 0.0,
mask: None,
@@ -344,6 +345,7 @@ pub(super) fn emit(
circular: false,
desaturated: false,
premultiplied: false,
+ alpha_test: None,
clip: text_clip,
rotation: 0.0,
mask: None,
diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs
index c8b44a38..28009302 100644
--- a/crates/benilla-app/src/ui_script/options_tests.rs
+++ b/crates/benilla-app/src/ui_script/options_tests.rs
@@ -21,7 +21,7 @@
//! farclip min-anchored 60), the 1.12 master→ambience dependency greys, and Defaults walks the
//! visible page back to the registered defaults.
-use benilla_ui::script::{QuadContent, SoundRequest, UiScript};
+use benilla_ui::script::{QuadContent, SoundRequest, UiScript, WornDisplay};
/// The window's real neighbourhood, in the manifest's own order (options before the menu — the
/// game_menu_tests::harness_with idiom, minus the extras this file never needs).
@@ -1700,8 +1700,10 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// 20 CVar rows (Social's two bubble switches are 1139's; Status Bar Text, Mouse Sensitivity
// and Max Camera Distance 1140's; Graphics' Vertical Sync is 1394's) + the Combat page's 14
// saved-variable rows (1134) + the Interface page's 4 (3 from 1136, Buff Durations 1139) and
- // the Action Bars page's 1 (1136).
- assert_eq!(checked, 39, "every row but one carries a live 1.12 key");
+ // the Action Bars page's 1 (1136) + the Interface page's 2 API rows (Show Cloak / Show Helm,
+ // 1472) — which is the point of counting here rather than per page: the third store's rows are
+ // held to the same "the key is 1.12's own and it resolves" bar as the other two.
+ assert_eq!(checked, 41, "every row but one carries a live 1.12 key");
assert_eq!(
untipped,
vec!["ControlsRowAutoLoot".to_string()],
@@ -1792,8 +1794,9 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
}
// 20 of the 21 CVar rows (Social's two are 1139's; Status Bar Text, Mouse Sensitivity and
// Max Camera Distance 1140's; Vertical Sync 1394's), plus the Combat page's 14 saved-variable
- // rows (1134), the Interface page's 4 (1136, + Buff Durations 1139) and Action Bars' 1 (1136).
- assert_eq!(raised, 39, "every row but Auto Loot has a 1.12 description");
+ // rows (1134), the Interface page's 4 (1136, + Buff Durations 1139), Action Bars' 1 (1136) and
+ // the Interface page's 2 API rows (Show Cloak / Show Helm, 1472).
+ assert_eq!(raised, 41, "every row but Auto Loot has a 1.12 description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
@@ -2159,6 +2162,117 @@ fn the_interface_page_writes_the_three_stock_globals() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+/// The **equipment-display rows** — Show Cloak and Show Helm (decision 1472, B123). They are the
+/// window's third kind of row and the only one with no store at all: the preference is a bit of the
+/// character's own server-side `PLAYER_FLAGS`, so the row reads an engine getter and writes an
+/// engine setter, 1.12's own `func`/`setFunc` pair for exactly these two entries.
+///
+/// Three things are asserted that no other row shape can be: the read comes from the API rather
+/// than a saved value (a page revisit re-asks), the write leaves both the CVar table and the global
+/// namespace untouched and produces a **wire** intent instead, and the getter follows the click
+/// immediately — before the server's descriptor answers — because the wire verb is a blind flip and
+/// a second click inside that round trip would otherwise compute the wrong direction.
+#[test]
+fn the_equipment_display_rows_read_and_write_through_the_api_not_a_store() {
+ let mut s = interface_harness();
+ s.run("ShowUIPanel(OptionsFrame)").unwrap();
+ s.run("OptionsFrameCategoryListRowInterface:Click()")
+ .unwrap();
+
+ // Both ship shown — the model's own default, and the reference's hand-written one.
+ for row in ["RowShowHelm", "RowShowCloak"] {
+ assert!(
+ s.eval::(&format!(
+ "return OptionsFrameContainerBodyInterface{row}Check:GetChecked() and true or false"
+ ))
+ .unwrap(),
+ "{row} reads the API"
+ );
+ }
+
+ let _ = s.take_cvar_changes();
+ let _ = s.take_worn_display_toggles();
+ s.run("OptionsFrameContainerBodyInterfaceRowShowHelmCheck:Click()")
+ .unwrap();
+ assert_eq!(
+ s.take_worn_display_toggles(),
+ vec![WornDisplay::Helm],
+ "the click is a CMSG_TOGGLE_HELM intent, not a stored value"
+ );
+ assert!(
+ s.take_cvar_changes().is_empty(),
+ "an API row must not touch the CVar table"
+ );
+ assert!(
+ !s.eval::("return ShowingHelm() and true or false")
+ .unwrap(),
+ "the getter follows the click at once — the server has not answered yet"
+ );
+ assert!(
+ s.eval::("return ShowingCloak() and true or false")
+ .unwrap(),
+ "and the other slot is a different bit"
+ );
+
+ // The read is a re-ask, not a remembered string: leave the page and come back.
+ s.run("OptionsFrameCategoryListRowAudio:Click()").unwrap();
+ s.run("OptionsFrameCategoryListRowInterface:Click()")
+ .unwrap();
+ assert!(
+ !s.eval::(
+ "return OptionsFrameContainerBodyInterfaceRowShowHelmCheck:GetChecked() \
+ and true or false"
+ )
+ .unwrap(),
+ "the revisit re-asks the getter"
+ );
+
+ // The wire is the truth: a descriptor edge that disagrees wins over the optimistic belief.
+ s.set_worn_display(true, true);
+ s.run("OptionsFrameCategoryListRowInterface:Click()")
+ .unwrap();
+ assert!(
+ s.eval::(
+ "return OptionsFrameContainerBodyInterfaceRowShowHelmCheck:GetChecked() \
+ and true or false"
+ )
+ .unwrap(),
+ "the server said the helm is shown, so the row says so"
+ );
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
+
+/// Defaults on an API row: both boxes go back to shown, and the flip is sent **only** for the one
+/// that was actually off — the setter is a *set* over a wire verb that is a blind *toggle*, so a
+/// no-op default must not queue a packet that would turn the preference on its head.
+#[test]
+fn defaults_sends_a_flip_only_for_the_row_that_moved() {
+ let mut s = interface_harness();
+ s.run("ShowUIPanel(OptionsFrame)").unwrap();
+ s.run("OptionsFrameCategoryListRowInterface:Click()")
+ .unwrap();
+ s.run("OptionsFrameContainerBodyInterfaceRowShowCloakCheck:Click()")
+ .unwrap();
+ let _ = s.take_worn_display_toggles();
+ assert!(!s
+ .eval::("return ShowingCloak() and true or false")
+ .unwrap());
+
+ s.run("OptionsFrameContainerDefaults:Click()").unwrap();
+ assert_eq!(
+ s.take_worn_display_toggles(),
+ vec![WornDisplay::Cloak],
+ "only the cloak had moved"
+ );
+ assert!(s
+ .eval::("return ShowingCloak() and true or false")
+ .unwrap());
+ assert!(s
+ .eval::("return ShowingHelm() and true or false")
+ .unwrap());
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
+
/// Defaults here restores **our** shipped assignments, which is the visible payoff of capturing the
/// default at OnLoad instead of restating it (1134 §1): `QUEST_FADING_DISABLE` ships `"1"` by
/// direction (2026-07-17) where 1.12's own table hand-writes `default = "0"`. The page walks back
diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs
index 7b52c658..589da836 100644
--- a/crates/benilla-app/src/ui_unit.rs
+++ b/crates/benilla-app/src/ui_unit.rs
@@ -24,7 +24,7 @@ use std::collections::HashMap;
use bevy::prelude::*;
-use benilla_ui::script::{power_token, ScriptValue, UiScript, UnitState};
+use benilla_ui::script::{power_token, ScriptValue, UiScript, UnitState, WornDisplay};
use crate::names::NameCache;
use crate::net::{Guid, NetCommands, ObjectStore, Reputations, SelfPlayer};
@@ -142,6 +142,11 @@ struct UnitFeedMemo {
/// (decision 0652). `None` until first seen: the reference reacts to a *changed*-bits mask, so
/// the descriptor that first carries the flag at login announces nothing.
pvp_desired: Option,
+ /// The self unit's last `(HIDE_HELM, HIDE_CLOAK)` pair, for the worn-display push (decision
+ /// 1472). Pushed on the **edge** and never per frame: the Options row's setter flips the VM's
+ /// belief optimistically and the server's answer is a round trip away, so a per-frame push
+ /// would snap the value back to the stale descriptor in between.
+ worn_hidden: Option<(bool, bool)>,
}
/// Adds the per-frame unit feed. The `Unit*` bindings themselves live in `benilla-ui`; this only
@@ -178,6 +183,7 @@ impl Plugin for UiUnitPlugin {
.before(UiInput),
)
.add_systems(Update, drain_pvp_toggles.after(UiInput))
+ .add_systems(Update, drain_worn_display_toggles.after(UiInput))
.add_systems(Update, feed_default_language.in_set(UnitFeed))
// `load_exhaustion_rows` pushes into the VM, so it runs per VM in `Update` (1290);
// `load_default_languages` only builds a Bevy resource and stays a one-shot.
@@ -623,6 +629,24 @@ fn drain_pvp_toggles(script: Option>, commands: Res>, commands: Res) {
+ let Some(mut script) = script else {
+ return;
+ };
+ for which in script.take_worn_display_toggles() {
+ let _ = commands.0.send(match which {
+ WornDisplay::Helm => crate::net::ClientCommand::ToggleHelm,
+ WornDisplay::Cloak => crate::net::ClientCommand::ToggleCloak,
+ });
+ }
+}
+
/// `PLAYER_FLAGS_PVP_DESIRED` — the PvP *preference* bit (vmangos `PlayerDefines.h`), which is what
/// `CMSG_TOGGLE_PVP` flips. Not to be confused with `UNIT_FIELD_FLAGS`' `PVP` bit `0x1000`, the flag
/// the icon draws: the preference clears instantly, the flag lingers for the server's timer.
@@ -1059,6 +1083,11 @@ fn feed_units(
memo.last_combo = None;
memo.in_combat = None;
memo.pvp_desired = None;
+ // The worn-display pair is a player-global like the rest, and forgetting it is what makes
+ // the next character's preference reach the VM at all: the push is an EDGE, so a memo
+ // carrying the last body's bits would silently skip a new body that happens to disagree
+ // with the VM's fresh "both shown" default (decision 1472).
+ memo.worn_hidden = None;
}
for (token, snap) in [("player", &player), ("target", &target)] {
@@ -1131,6 +1160,20 @@ fn feed_units(
memo.pvp_desired = Some(desired);
}
+ // The worn-display pair (decision 1472): `PLAYER_FLAGS`' two hide bits, mirrored into the VM
+ // so `ShowingHelm()`/`ShowingCloak()` — the Options rows' getters — read the server's truth.
+ // On the EDGE, not per frame: the setter flips the VM's belief the instant the box is clicked,
+ // and the descriptor only catches up a round trip later. Re-pushing the stale pair in between
+ // would un-click the box and make a second click compute the wrong flip.
+ if let Some((store, _)) = self_pair {
+ let hidden = (store.0.player_hides_helm(), store.0.player_hides_cloak());
+ if memo.worn_hidden != Some(hidden) {
+ gate.audit("feed_units", "the worn-display pair");
+ memo.worn_hidden = Some(hidden);
+ script.set_worn_display(!hidden.0, !hidden.1);
+ }
+ }
+
// The combo-point feed: `PLAYER_FIELD_BYTES` byte 1 and the `PLAYER_FIELD_COMBO_TARGET` GUID
// it is banked against, both PRIVATE, pushed as the pair the server writes (0869, 0875).
//
diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs
index b408d434..c970e02d 100644
--- a/crates/benilla-protocol/src/messages/opcode.rs
+++ b/crates/benilla-protocol/src/messages/opcode.rs
@@ -720,6 +720,23 @@ pub const CMSG_UNLEARN_SKILL: u16 = 0x0202; // 514
/// and turning the preference *off* changes nothing until the server's 300 s drop timer expires.
pub const CMSG_TOGGLE_PVP: u16 = 0x0253; // 595
+/// The two **equipment-display** toggles — "show my helm" / "show my cloak" (VERIFIED vmangos
+/// `Opcodes_1_12_1.h`: 697/698, handlers `HandleShowingHelmOpcode`/`HandleShowingCloakOpcode` in
+/// `Handlers/CharacterHandler.cpp:753-761`). Both are **empty-bodied pure toggles**: the handler
+/// is a bare `ToggleFlag(PLAYER_FLAGS, PLAYER_FLAGS_HIDE_HELM | HIDE_CLOAK)` with no target-state
+/// form at all, unlike [`CMSG_TOGGLE_PVP`] above — so a client that wants a *specific* state must
+/// compare against the flag it already holds and send only on a difference.
+///
+/// No ack, exactly like the PvP toggle: the answer is the `PLAYER_FLAGS` bit arriving in the next
+/// descriptor update. That field is `UF_FLAG_PUBLIC` (vmangos `UpdateFields_1_12_1`), which is what
+/// makes the preference *everyone's* — a remote player's hidden helm hides on our screen too, off
+/// their own descriptor. The server round-trips the same preference through the char-enum record's
+/// `CHARACTER_FLAG_HIDE_HELM`/`HIDE_CLOAK` at load and save (`Player.cpp:14839-14842` /
+/// `16504-16505`), so the glue lane's flags and this one are the same stored bit (decision 1472).
+pub const CMSG_TOGGLE_HELM: u16 = 0x02B9; // 697
+/// The cloak half of [`CMSG_TOGGLE_HELM`] — same shape, `PLAYER_FLAGS_HIDE_CLOAK`.
+pub const CMSG_TOGGLE_CLOAK: u16 = 0x02BA; // 698
+
// The solo-loot wire family (VERIFIED vmangos `Opcodes_1_12_1.h`: 264, 349-355, 357-358).
// Bodies in [`super::loot`].
pub const CMSG_AUTOSTORE_LOOT_ITEM: u16 = 0x0108; // 264
diff --git a/crates/benilla-protocol/src/messages/update_object/fields/player.rs b/crates/benilla-protocol/src/messages/update_object/fields/player.rs
index 4a0eee57..01ca4d31 100644
--- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs
+++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs
@@ -426,6 +426,22 @@ impl ObjectFields {
pub fn player_is_ghost(&self) -> bool {
self.player_flags() & 0x10 != 0
}
+ /// Whether this player has asked for their **helm** not to be drawn —
+ /// `PLAYER_FLAGS_HIDE_HELM (0x400)` (vmangos `Player.h:325`), flipped by `CMSG_TOGGLE_HELM`
+ /// and persisted per character through the char-enum record's `CHARACTER_FLAG_HIDE_HELM`.
+ ///
+ /// `PLAYER_FLAGS` is `UF_FLAG_PUBLIC`, which is the whole point of reading it *here* rather
+ /// than off a local setting: the preference streams for every player in range, so a remote
+ /// player's hidden helm hides on our screen too, and ours hides on theirs (decision 1472).
+ /// Creatures have no `PLAYER_FLAGS` and read `false`.
+ pub fn player_hides_helm(&self) -> bool {
+ self.player_flags() & 0x400 != 0
+ }
+ /// Whether this player has asked for their **cloak** not to be drawn —
+ /// `PLAYER_FLAGS_HIDE_CLOAK (0x800)`, [`Self::player_hides_helm`]'s twin in every respect.
+ pub fn player_hides_cloak(&self) -> bool {
+ self.player_flags() & 0x800 != 0
+ }
/// Whether the player is inside a rest area (inn/city) — `PLAYER_FLAGS_RESTING (0x20)`
/// (vmangos `Player.h:320`), set/cleared by the area-trigger/zone rest checks
/// (`SetRestType`). The real client's `IsResting()` and the player frame's flashing zzz
diff --git a/crates/benilla-protocol/src/world/writer/mod.rs b/crates/benilla-protocol/src/world/writer/mod.rs
index 9a15c5cc..960c2fe2 100644
--- a/crates/benilla-protocol/src/world/writer/mod.rs
+++ b/crates/benilla-protocol/src/world/writer/mod.rs
@@ -40,7 +40,7 @@
//! | [`trade`] | the player-trade dance |
//! | [`duel`] | accept and cancel |
//! | [`mail`] | the mailbox |
-//! | [`pvp`] | the one PvP-flag toggle |
+//! | [`player_flags`] | the empty-bodied `PLAYER_FLAGS` toggles: PvP, show-helm, show-cloak |
//! | [`social`] | friends, ignores, and `/who` |
//! | [`area_trigger`] | the one "I walked into trigger N" report |
@@ -70,9 +70,9 @@ mod loot;
mod mail;
mod names;
mod pet;
+mod player_flags;
mod pose;
mod progression;
-mod pvp;
mod quest;
mod reputation;
mod selection;
diff --git a/crates/benilla-protocol/src/world/writer/player_flags.rs b/crates/benilla-protocol/src/world/writer/player_flags.rs
new file mode 100644
index 00000000..84fea4d8
--- /dev/null
+++ b/crates/benilla-protocol/src/world/writer/player_flags.rs
@@ -0,0 +1,40 @@
+//! The **`PLAYER_FLAGS` toggles** — the three empty-bodied sends by which the player asks the
+//! server to flip one bit of their own `PLAYER_FLAGS`: the PvP flag (decision 0646), and the two
+//! equipment-display preferences, show-helm and show-cloak (decision 1472).
+//!
+//! What makes these one family, and unlike [`super::pose`]'s client-volunteered body state: **the
+//! server owns the bit, and the only answer is the descriptor.** Nothing here has an ack, nothing
+//! here has a body — the packet *is* the verb — and each one's effect comes back as a field in the
+//! next `SMSG_UPDATE_OBJECT`. So there is no body builder in [`crate::messages`] to mirror, and a
+//! caller that wants a *specific* state rather than a flip must compare against the flag it is
+//! already holding and send only on a difference.
+
+use anyhow::Result;
+
+use crate::messages::opcode;
+
+use super::WorldWriter;
+
+impl WorldWriter {
+ /// Ask the server to flip our own PvP flag (`CMSG_TOGGLE_PVP`, empty body) — `/pvp` and the
+ /// unit popup's PvP row. There is no ack and no immediate local effect: flagging *on* comes
+ /// back as the `UNIT_FIELD_FLAGS` PvP bit within the next descriptor update, while flagging
+ /// *off* only clears the preference — the flag itself survives until vmangos' 300 s drop
+ /// timer expires (`Player::UpdatePvP`). The client predicts neither.
+ pub fn toggle_pvp(&mut self) -> Result<()> {
+ self.send(opcode::CMSG_TOGGLE_PVP, &[])
+ }
+
+ /// Ask the server to flip our show-helm preference (`CMSG_TOGGLE_HELM`, empty body) — the
+ /// Options window's *Show Helm* row, through the VM's intent queue. The answer is
+ /// `PLAYER_FLAGS`' `HIDE_HELM` bit in the next descriptor update, which is what every client
+ /// in range dresses our body from (decision 1472).
+ pub fn toggle_helm(&mut self) -> Result<()> {
+ self.send(opcode::CMSG_TOGGLE_HELM, &[])
+ }
+
+ /// The cloak half of [`Self::toggle_helm`] (`CMSG_TOGGLE_CLOAK`, empty body).
+ pub fn toggle_cloak(&mut self) -> Result<()> {
+ self.send(opcode::CMSG_TOGGLE_CLOAK, &[])
+ }
+}
diff --git a/crates/benilla-protocol/src/world/writer/pvp.rs b/crates/benilla-protocol/src/world/writer/pvp.rs
deleted file mode 100644
index aa4a7874..00000000
--- a/crates/benilla-protocol/src/world/writer/pvp.rs
+++ /dev/null
@@ -1,23 +0,0 @@
-//! The PvP-flag `WorldWriter` send — the one outbound verb the whole PvP-flag family has
-//! (decision 0646).
-//!
-//! There is no body builder in [`crate::messages`] to mirror here: the packet is empty, which is
-//! itself the meaningful choice (see [`opcode::CMSG_TOGGLE_PVP`] — a one-byte body would mean
-//! "set this exact state" instead).
-
-use anyhow::Result;
-
-use crate::messages::opcode;
-
-use super::WorldWriter;
-
-impl WorldWriter {
- /// Ask the server to flip our own PvP flag (`CMSG_TOGGLE_PVP`, empty body) — `/pvp` and the
- /// unit popup's PvP row. There is no ack and no immediate local effect: flagging *on* comes
- /// back as the `UNIT_FIELD_FLAGS` PvP bit within the next descriptor update, while flagging
- /// *off* only clears the preference — the flag itself survives until vmangos' 300 s drop
- /// timer expires (`Player::UpdatePvP`). The client predicts neither.
- pub fn toggle_pvp(&mut self) -> Result<()> {
- self.send(opcode::CMSG_TOGGLE_PVP, &[])
- }
-}
diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs
index e32ca999..059ebcab 100644
--- a/crates/benilla-ui/src/script/mod.rs
+++ b/crates/benilla-ui/src/script/mod.rs
@@ -126,6 +126,7 @@ mod types;
mod unit;
mod weapon_enchant;
mod worldmap;
+mod worn_display;
pub use action::{ActionSlot, ActionState};
pub use addon::AddOnInfo;
@@ -200,6 +201,7 @@ pub(crate) use types::{FontExplicit, MeasuredText, RegionData};
pub use unit::{grey_band, level_reads_unknown, power_token, unit_is_grey, UnitState};
pub use weapon_enchant::WeaponEnchant;
pub use worldmap::{WorldMapContinentView, WorldMapOverlayView, WorldMapState, WorldMapZoneView};
+pub use worn_display::WornDisplay;
use mlua::Lua;
@@ -518,6 +520,7 @@ impl UiScript {
follow::install(&lua)?;
session::install(&lua)?;
pvp::install(&lua)?;
+ worn_display::install(&lua)?;
death::install(&lua)?;
aura::install(&lua)?;
cvars::install(&lua)?;
diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs
index 26f9ec34..eecf7d23 100644
--- a/crates/benilla-ui/src/script/model.rs
+++ b/crates/benilla-ui/src/script/model.rs
@@ -447,6 +447,19 @@ pub(crate) struct Model {
/// [`super::UiScript::take_pvp_toggles`] drain — the outbound seam ([`pvp`]). A count, not a
/// payload: `CMSG_TOGGLE_PVP` carries no body.
pub(crate) pvp_toggles: u32,
+ /// The two **equipment-display** preferences as the VM currently believes them —
+ /// `ShowingHelm()` / `ShowingCloak()` ([`worn_display`], decision 1472). Not a setting: the
+ /// truth is `PLAYER_FLAGS`' `HIDE_HELM`/`HIDE_CLOAK` bits, pushed here on the descriptor edge
+ /// by [`super::UiScript::set_worn_display`]. Both start **shown**, which is both the reference's
+ /// panel default and the zero-flags state a fresh character logs in with.
+ pub(crate) helm_shown: bool,
+ /// The cloak half of [`Self::helm_shown`].
+ pub(crate) cloak_shown: bool,
+ /// Worn-display flips (`ShowHelm`/`ShowCloak` asked for a state we were not in) queued since
+ /// the app's last [`super::UiScript::take_worn_display_toggles`] drain — one
+ /// `CMSG_TOGGLE_HELM`/`CMSG_TOGGLE_CLOAK` each. A list, not a count like [`Self::pvp_toggles`]:
+ /// the two slots are different packets.
+ pub(crate) worn_display_toggles: Vec,
/// Sounds queued by the Lua `PlaySound`/`PlaySoundFile` bindings since the app's last
/// [`UiScript::take_sounds`] drain — the outbound Lua→app intent seam ([`sound`]).
@@ -1269,6 +1282,9 @@ impl Model {
follow_requests: Vec::new(),
session_requests: Vec::new(),
pvp_toggles: 0,
+ helm_shown: true,
+ cloak_shown: true,
+ worn_display_toggles: Vec::new(),
sound_queue: Vec::new(),
cvars: HashMap::new(),
cvars_saved_base: HashMap::new(),
diff --git a/crates/benilla-ui/src/script/worn_display.rs b/crates/benilla-ui/src/script/worn_display.rs
new file mode 100644
index 00000000..6565d124
--- /dev/null
+++ b/crates/benilla-ui/src/script/worn_display.rs
@@ -0,0 +1,219 @@
+//! The **equipment-display** API surface — `ShowHelm`/`ShowingHelm`, `ShowCloak`/`ShowingCloak`
+//! (decision 1472): the player's two "don't draw this piece of my gear" preferences.
+//!
+//! Two bits, and they are not the UI's to keep. The preference lives server-side in the character's
+//! own `PLAYER_FLAGS` (`HIDE_HELM 0x400` / `HIDE_CLOAK 0x800`), which is a **public** descriptor
+//! field — so it dresses our body on every client that can see us, and every other player's on
+//! ours. That is why there is no CVar and no saved variable behind this row: the wire flag is the
+//! single source of truth, it is per-character rather than per-install, and it is already persisted
+//! by whoever we are logged into.
+//!
+//! **The setter is a set; the wire verb is a flip.** `CMSG_TOGGLE_HELM`/`CMSG_TOGGLE_CLOAK` carry no
+//! body and have no target-state form at all (vmangos `HandleShowingHelmOpcode` is a bare
+//! `ToggleFlag`), so `ShowHelm(v)` compares `v` against what we currently believe and queues a flip
+//! only on a difference. The belief is updated **optimistically at the call** and overwritten only
+//! when the *wire* bit actually moves ([`super::UiScript::set_worn_display`], pushed on the
+//! descriptor edge and not per frame) — without that, the value would snap back to the stale
+//! descriptor for the length of the round trip and a second click inside that window would compute
+//! the wrong flip.
+//!
+//! The 1.12 panel calls the setter with the **strings** `"1"` / `"0"` (`UIOptionsFrame_Save`
+//! l.286-297, `value.setFunc(value.value)`), so the argument is read *numerically* — plain Lua
+//! truthiness would make `"0"` mean "show" and the reference's own Options panel could never turn
+//! a helm off. See [`shown_arg`].
+
+use mlua::{Lua, Value};
+
+use super::Model;
+
+/// Which of the two worn-display preferences a queued flip is for. One entry per
+/// `CMSG_TOGGLE_HELM`/`CMSG_TOGGLE_CLOAK` the app should send.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum WornDisplay {
+ /// The head slot — `PLAYER_FLAGS_HIDE_HELM`.
+ Helm,
+ /// The back slot — `PLAYER_FLAGS_HIDE_CLOAK`.
+ Cloak,
+}
+
+impl super::UiScript {
+ /// Drain the worn-display flips queued since the last call — one `CMSG_TOGGLE_HELM` /
+ /// `CMSG_TOGGLE_CLOAK` each. A list rather than a count because the two slots are distinct
+ /// packets; within one slot, two flips in a frame are two sends, exactly like the PvP toggle.
+ pub fn take_worn_display_toggles(&mut self) -> Vec {
+ std::mem::take(&mut self.model_mut().worn_display_toggles)
+ }
+
+ /// Push the wire truth for both preferences — the app calls this **on a `PLAYER_FLAGS` edge**,
+ /// never per frame, so an optimistic flip made by [`install`]'s setters survives until the
+ /// server's answer actually arrives (see the module doc).
+ pub fn set_worn_display(&mut self, helm_shown: bool, cloak_shown: bool) {
+ let mut model = self.model_mut();
+ model.helm_shown = helm_shown;
+ model.cloak_shown = cloak_shown;
+ }
+
+ /// What the VM currently believes — the app's own read, for the drain's compare and for tests.
+ pub fn worn_display(&self) -> (bool, bool) {
+ let model = self.model_ref();
+ (model.helm_shown, model.cloak_shown)
+ }
+}
+
+/// The setter's argument convention: **numeric**, not Lua-truthy. `nil`/`false`/`0`/`"0"` read as
+/// "hide"; `true`, any non-zero number and any non-numeric string read as "show".
+///
+/// The distinction is load-bearing rather than pedantic. 1.12's own Interface panel hands this
+/// binding the *string* `"0"` to mean off (`UIOptionsFrame_Save`), and in Lua 5.0 `"0"` is truthy —
+/// so a plain `lua_toboolean` reading would make the reference's own Show Helm checkbox a one-way
+/// switch. Numeric-string coercion is what makes it work, and it is the same shape
+/// [`super::action::truthy_nonzero`] already carries for `UseAction`'s `checkCursor`.
+fn shown_arg(v: &Value) -> bool {
+ match v {
+ Value::Nil => false,
+ Value::Boolean(b) => *b,
+ Value::Integer(i) => *i != 0,
+ Value::Number(n) => *n != 0.0,
+ // A numeric string coerces like Lua's own arithmetic does; a non-numeric one is not a
+ // value this API is ever handed, and reads as "show" rather than silently hiding gear.
+ Value::String(s) => s
+ .to_str()
+ .ok()
+ .and_then(|t| t.trim().parse::().ok())
+ .is_none_or(|n| n != 0.0),
+ _ => true,
+ }
+}
+
+/// Ask for a state, and queue the flip only if we are not already in it.
+fn want(model: &mut Model, which: WornDisplay, show: bool) {
+ let held = match which {
+ WornDisplay::Helm => &mut model.helm_shown,
+ WornDisplay::Cloak => &mut model.cloak_shown,
+ };
+ if *held == show {
+ return;
+ }
+ *held = show; // optimistic — the wire edge overwrites it when the server answers
+ model.worn_display_toggles.push(which);
+}
+
+/// Register the equipment-display globals.
+pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
+ let g = lua.globals();
+
+ for (name, which) in [
+ ("ShowHelm", WornDisplay::Helm),
+ ("ShowCloak", WornDisplay::Cloak),
+ ] {
+ g.set(
+ name,
+ lua.create_function(move |lua, v: Value| {
+ let mut model = lua.app_data_mut::().expect("model app_data");
+ want(&mut model, which, shown_arg(&v));
+ Ok(())
+ })?,
+ )?;
+ }
+
+ // The getters the Options row reads its checked state from — `1`/`nil`, the 1.12 boolean
+ // return the panel feeds straight into `SetChecked`.
+ for (name, which) in [
+ ("ShowingHelm", WornDisplay::Helm),
+ ("ShowingCloak", WornDisplay::Cloak),
+ ] {
+ g.set(
+ name,
+ lua.create_function(move |lua, ()| {
+ let model = lua.app_data_ref::().expect("model app_data");
+ let shown = match which {
+ WornDisplay::Helm => model.helm_shown,
+ WornDisplay::Cloak => model.cloak_shown,
+ };
+ Ok(shown.then_some(1u32))
+ })?,
+ )?;
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::WornDisplay;
+ use crate::script::UiScript;
+
+ /// The argument convention, and the one that would silently break the feature: 1.12's own
+ /// Options panel calls the setter with the **string** `"0"` to mean "hide", and in Lua 5.0 that
+ /// string is truthy. A plain truthiness reading would make Show Helm a one-way switch — on
+ /// forever, off never — with no error anywhere to say so.
+ #[test]
+ fn the_setter_reads_its_argument_numerically_so_the_string_zero_hides() {
+ let mut s = UiScript::new().unwrap();
+ assert!(
+ s.eval::("return ShowingHelm() == 1").unwrap(),
+ "shown by default"
+ );
+
+ s.run(r#"ShowHelm("0")"#).unwrap();
+ assert!(s.eval::("return ShowingHelm() == nil").unwrap());
+ assert_eq!(s.take_worn_display_toggles(), vec![WornDisplay::Helm]);
+
+ s.run(r#"ShowHelm("1")"#).unwrap();
+ assert!(s.eval::("return ShowingHelm() == 1").unwrap());
+ assert_eq!(s.take_worn_display_toggles(), vec![WornDisplay::Helm]);
+
+ // The other spellings the API is handed elsewhere in 1.12: a bare number and nil.
+ s.run("ShowCloak(0)").unwrap();
+ assert!(s.eval::("return ShowingCloak() == nil").unwrap());
+ s.run("ShowCloak(1)").unwrap();
+ assert!(s.eval::("return ShowingCloak() == 1").unwrap());
+ s.run("ShowCloak(nil)").unwrap();
+ assert!(s.eval::("return ShowingCloak() == nil").unwrap());
+ assert_eq!(
+ s.take_worn_display_toggles(),
+ vec![WornDisplay::Cloak, WornDisplay::Cloak, WornDisplay::Cloak]
+ );
+ }
+
+ /// The wire verb is a blind flip, so **asking for the state we are already in must send
+ /// nothing** — otherwise the Options window's Defaults button, which writes every row on the
+ /// page unconditionally, would invert whichever preference was already at its default.
+ #[test]
+ fn asking_for_the_state_we_are_already_in_sends_nothing() {
+ let mut s = UiScript::new().unwrap();
+ s.run("ShowHelm(1) ShowCloak(1)").unwrap();
+ assert!(
+ s.take_worn_display_toggles().is_empty(),
+ "both already shown — no packet"
+ );
+
+ s.run("ShowHelm(0)").unwrap();
+ assert_eq!(s.take_worn_display_toggles(), vec![WornDisplay::Helm]);
+ s.run("ShowHelm(0)").unwrap();
+ assert!(
+ s.take_worn_display_toggles().is_empty(),
+ "the belief moved at the first call, so the second is a no-op"
+ );
+ }
+
+ /// The descriptor is the truth: a wire push overrides whatever the VM believed. This is the
+ /// edge the app feeds — it is why the app pushes on a `PLAYER_FLAGS` CHANGE and not per frame.
+ #[test]
+ fn the_wire_push_overrides_the_optimistic_belief() {
+ let mut s = UiScript::new().unwrap();
+ s.run("ShowHelm(0)").unwrap();
+ let _ = s.take_worn_display_toggles();
+ assert_eq!(s.worn_display(), (false, true));
+
+ // The server answers something else entirely (another client toggled it, or our flip
+ // crossed a login) — the descriptor wins, and the next ask is computed from it.
+ s.set_worn_display(true, false);
+ assert_eq!(s.worn_display(), (true, false));
+ s.run("ShowHelm(0)").unwrap();
+ assert_eq!(
+ s.take_worn_display_toggles(),
+ vec![WornDisplay::Helm],
+ "re-asked against the server's value, not the stale belief"
+ );
+ }
+}