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