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