show helm and show cloak work, and the minimap composites its interior

- show-helm / show-cloak preferences dress the world body at last: the preference is `PLAYER_FLAGS` 0x400/0x800 — a public descriptor field, so it dresses every player you can see — and equipment resolve now reads it in both places the helm resolves (geosets and attachments) and the one the cloak does; character select has honoured the same stored bit since day one, which is why the two disagreed
- the toggles reach the wire (`CMSG_TOGGLE_HELM`/`CMSG_TOGGLE_CLOAK`), the `ShowHelm`/`ShowingHelm`/`ShowCloak`/`ShowingCloak` script API exists, the dressing room follows the world, and Options → Interface finally has the two rows
- the minimap's interior draw starts compositing into the client's own 256² target, and the interior gate reads the client's indoor byte — the ground work for the black-lines fix that lands next
- `WOW_DRESS_CENSUS` reports what the wire asked for vs what is on the body

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-21 17:56:53 +07:00
co-authored by Claude Fable 5
parent 23f23d36e0
commit 441cd33338
28 changed files with 1484 additions and 122 deletions
+82 -18
View File
@@ -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 @@
</Frames>
</Frame>
<!-- The INTERFACE page (decision 1136; the Buff Durations row is
1139) — the second store's second page, and the one 1134 §3
named as ready: stock globals already defined AND consumed,
needing only a row. Rows in the reference's own on-screen
order — Basic Options' anchor chain runs CheckButton42 → 39 →
62 → 66 in Display, then 29 in Help, and we hold all of those
but 62 (HIDE_OUTDOOR_WORLD_STATE: there is no zone-objective
tracker to hide). Labels and OPTION_TOOLTIP_* keys are its
1139, the two equipment-display rows 1472) — the second
store's second page, and the one 1134 §3 named as ready: stock
globals already defined AND consumed, needing only a row. Rows
in the reference's own on-screen order — Basic Options' anchor
chain runs CheckButton42 → 39 → 62 → 66 in Display, then 29 in
Help, and we hold all of those but 62
(HIDE_OUTDOOR_WORLD_STATE: there is no zone-objective tracker
to hide); Show Cloak → Show Helm (31 → 20) are that box's
middle column, seated after Status Text because the two rows
above them there — the player/NPC name toggles — live on our
Nameplates page. Labels and OPTION_TOOLTIP_* keys are its
GlobalStrings verbatim.
Only Buff Durations carries an `applyFunc`, and the split is
@@ -2368,11 +2404,39 @@
self.cvarEvent = "STATUS_BAR_TEXT"
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowInstantQuestText" inherits="OptionsCheckboxRowTemplate">
<!-- The two equipment-display toggles (decision 1472,
B123). Neither has a store: the preference is a bit of
this character's own server-side PLAYER_FLAGS, so the
row reads the engine getter and writes the engine
setter — 1.12's own `func`/`setFunc` pair for exactly
these two rows (UIOptionsFrame.lua l.18-19), and the
reason there is no CVar to register. Default "1" for
both, the reference's own hand-written arm. -->
<Frame name="$parentRowShowCloak" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowStatusText" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowStatusText" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsFuncRow_OnLoad(self, ShowingCloak, ShowCloak, "1",
"Show Cloak", "OPTION_TOOLTIP_SHOW_CLOAK")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowShowHelm" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowShowCloak" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowShowCloak" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsFuncRow_OnLoad(self, ShowingHelm, ShowHelm, "1",
"Show Helm", "OPTION_TOOLTIP_SHOW_HELM")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowInstantQuestText" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowShowHelm" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowShowHelm" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "QUEST_FADING_DISABLE",
"Instant Quest Text", "OPTION_TOOLTIP_SHOW_QUEST_FADING")
+4 -3
View File
@@ -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};
@@ -0,0 +1,172 @@
//! The **dress census** (`WOW_DRESS_CENSUS=<secs>[,<every>]`) — 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=<geared body> 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::<f32>().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<DressCensus>,
time: ProbeClock,
names: Res<NameCache>,
body: Query<&Transform, With<SelfPlayer>>,
entities: Query<DressQuery>,
) {
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::<f32>().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}");
}
}
@@ -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;
+8
View File
@@ -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=<secs>[,<every>]` 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=<secs>` 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() {
+2
View File
@@ -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.
@@ -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
@@ -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<Entity>; 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<Entity>; 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
@@ -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::<ClientCommand>();
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::<Equipment>(player).expect("equipment resolved");
let helm_attached = w
.get::<HeldItems>(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);
}
}
+238
View File
@@ -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<Image>,
/// 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<CompositeTile>,
}
/// 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<Image>,
camera: Entity,
quad: Handle<Mesh>,
/// 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<AssetId<Image>, Handle<UiQuadMaterial>>,
pool: Vec<Entity>,
}
/// 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<Assets<Image>>,
mut meshes: ResMut<Assets<Mesh>>,
) {
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<MinimapComposite>,
rig: Option<ResMut<CompositeRig>>,
mut commands: Commands,
mut materials: ResMut<Assets<UiQuadMaterial>>,
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();
}
+163 -64
View File
@@ -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<WmoModel>,
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<AssetServer>,
death_net: Res<crate::death::DeathNet>,
blip_inputs: blips::BlipInputs,
mut composite: ResMut<composite::MinimapComposite>,
rig: Option<Res<composite::CompositeRig>>,
mut quads: ResMut<UiQuads>,
) {
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<bevy::ecs::system::NonSendMut<benilla_ui::script::UiScript>>,
world: benilla_world::world_point::WorldPoint,
player: Res<Player>,
instances: Query<&WmoPortalInstance>,
wmos: Res<Assets<WmoModel>>,
asset_server: Res<AssetServer>,
mut was_inside: Local<crate::ui_script::VmMemo<Option<bool>>>,
mut pushed_at: Local<crate::ui_script::VmMemo<u64>>,
) {
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::<MinimapZoom>()
.init_resource::<MinimapTileCache>()
.init_resource::<blips::MinimapBlipHover>()
.init_resource::<composite::MinimapComposite>()
.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),
+7
View File
@@ -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
+2
View File
@@ -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),
+8 -1
View File
@@ -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!(
@@ -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<uniform> 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<uniform> 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<f32> {
let m = textureSampleLevel(mask_texture, mask_sampler, clamp(muv, vec2<f32>(0.0), vec2<f32>(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<f32>(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.
+65
View File
@@ -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"
);
}
}
+50
View File
@@ -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<f32>,
/// CPU-clip stand-in for a real scissor rect (see the module doc). `None` = unclipped.
pub clip: Option<Rect>,
/// 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<Handle<Image>>,
}
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<Image>, 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<f32>,
mask: Option<UiQuadMask>,
positions: Vec<[f32; 3]>,
uvs: Vec<[f32; 2]>,
@@ -574,6 +616,7 @@ impl Run {
circular: bool,
desaturated: bool,
premultiplied: bool,
alpha_test: Option<f32>,
mask: Option<UiQuadMask>,
) -> 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<AssetId<Image>>,
[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,
})
@@ -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,
@@ -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::<bool>(&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::<bool>("return ShowingHelm() and true or false")
.unwrap(),
"the getter follows the click at once — the server has not answered yet"
);
assert!(
s.eval::<bool>("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::<bool>(
"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::<bool>(
"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::<bool>("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::<bool>("return ShowingCloak() and true or false")
.unwrap());
assert!(s
.eval::<bool>("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
+44 -1
View File
@@ -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<bool>,
/// 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<NonSendMut<UiScript>>, commands: Res<NetComm
}
}
/// Drain the `ShowHelm`/`ShowCloak` flips into `CMSG_TOGGLE_HELM`/`CMSG_TOGGLE_CLOAK` (decision
/// 1472) — the Options window's two equipment-display rows, and the only callers there are.
///
/// The VM has already decided *whether* a flip is needed (the setter compares the asked-for state
/// against the belief it holds and queues nothing when they agree), because only the VM knows what
/// the row just did optimistically. This end is the pure send, the PvP drain's shape exactly.
fn drain_worn_display_toggles(script: Option<NonSendMut<UiScript>>, commands: Res<NetCommands>) {
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).
//
@@ -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
@@ -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
@@ -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;
@@ -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, &[])
}
}
@@ -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, &[])
}
}
+3
View File
@@ -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)?;
+16
View File
@@ -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<super::worn_display::WornDisplay>,
/// 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(),
@@ -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<WornDisplay> {
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::<f64>().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::<Model>().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::<Model>().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::<bool>("return ShowingHelm() == 1").unwrap(),
"shown by default"
);
s.run(r#"ShowHelm("0")"#).unwrap();
assert!(s.eval::<bool>("return ShowingHelm() == nil").unwrap());
assert_eq!(s.take_worn_display_toggles(), vec![WornDisplay::Helm]);
s.run(r#"ShowHelm("1")"#).unwrap();
assert!(s.eval::<bool>("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::<bool>("return ShowingCloak() == nil").unwrap());
s.run("ShowCloak(1)").unwrap();
assert!(s.eval::<bool>("return ShowingCloak() == 1").unwrap());
s.run("ShowCloak(nil)").unwrap();
assert!(s.eval::<bool>("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"
);
}
}