diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index 4b41b14e..d9497d0c 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -609,6 +609,19 @@
-- CENTER-80, child rows +15 indent in the small font). Era rows with no 1.12 CVar +
-- benilla knob behind them (Enable Sound=Sound_EnableAllSound, Dialog, output device,
-- channels…) do not ship — the 0950 honest-tree rule applied to settings.
+ -- The one options string that is NOT the reference's (decision 1639). Every other
+ -- tooltip on this window resolves an OPTION_TOOLTIP_* out of 1.12's GlobalStrings, and a
+ -- benilla row with no counterpart there has so far simply gone mute (the nameplate pair).
+ -- Render Scale cannot afford to: the director's own reaction to the dial was "not really
+ -- sure what this does". So it gets a description, under a BENILLA_ prefix rather than an
+ -- invented OPTION_TOOLTIP_ -- the reference's namespace stays the reference's, and an
+ -- addon reading GlobalStrings still finds exactly what 1.12 defined.
+ BENILLA_TOOLTIP_RENDER_SCALE =
+ "Draws the 3D world at a fraction of the window's resolution, or larger than it. "
+ .. "The interface is unaffected at any setting.\n\n"
+ .. "Below 100% trades sharpness for frame rate; above 100% is supersampling, which "
+ .. "smooths edges at the cost of frame rate. Applies when you click Apply."
+
OPTIONS_PAGE_ROWS = {
Controls = {
"RowStickyTarget", "RowAutoLoot", "RowInvertMouse", "RowMouseSpeed",
@@ -620,7 +633,7 @@
},
Graphics = {
"RowUiScale", "RowFarclip", "RowWorldDetail",
- "RowWindowedMode", "RowVerticalSync", "RowMultisample",
+ "RowWindowedMode", "RowVerticalSync", "RowRenderScale", "RowMultisample",
},
Nameplates = {
"RowEnemyPlates", "RowFriendlyPlates",
@@ -1460,7 +1473,13 @@
worked but the visible knob is a small target, so 0989's engine track-press law
seats the thumb under a press anywhere on the bar and drags on from there. The
readout is era's RIGHT label seat — 6 right of the control (MinimalSlider.xml
- l.73-77), rounded-percent formatted. -->
+ l.73-77), rounded-percent formatted.
+ THE +3 LIVES ON THE ART, NOT ON THE CONTROL (B232, decision 1641). It seats the
+ 17-tall groove and the 32 px thumb inside the 26-tall row, so it belongs to the two
+ frames that draw them — $parentGroove and $parentSlider. It used to sit on
+ $parentControl itself, where the readout inherited it: every slider row drew its number
+ 3 units above its own label, one line asked to read as two. The art's seat is unchanged
+ to the pixel; the text is what moved. -->
@@ -1495,7 +1514,7 @@
-
+
@@ -1510,8 +1529,8 @@
-
-
+
+
@@ -1521,8 +1540,8 @@
-
-
+
+
@@ -2312,11 +2331,47 @@
"OPTION_TOOLTIP_VERTICAL_SYNC")
-
+
+
+ -- Render Scale (decision 1639) -- benilla's own
+ -- row, no 1.12 counterpart: the reference renders
+ -- straight to the backbuffer and has no second
+ -- image to size. Seated directly above
+ -- Multisampling because the two are the page's
+ -- antialiasing pair and a player weighing one
+ -- against the other should see them together --
+ -- and because above 100% this row IS antialiasing,
+ -- on a client whose gxMultisample ships off (1629).
+ --
+ -- 50-200%, narrower than the CVar's own clamp
+ -- (RENDER_SCALE_RANGE, 0.25-4.0). That clamp stops
+ -- an absurd value and leaves room for the
+ -- supersampling instrument; this is what a player
+ -- should be offered, and it is where every engine
+ -- puts the same slider.
+ --
+ -- DEFERRED, and for the reason the flag exists.
+ -- Committing live would rebuild the world's render
+ -- target on every drag tick -- tens of megabytes,
+ -- thirty times across one sweep of the handle.
+ -- Staging to Apply makes that exactly one rebuild,
+ -- which is the same answer uiScale's row already
+ -- reaches for a different reason.
+ OptionsRow_OnLoad(self, "renderScale", "Render Scale",
+ "BENILLA_TOOLTIP_RENDER_SCALE")
+ OptionsSliderRow_Setup(self, 0.5, 2.0, 0.05)
+ self.deferred = 1
+
+
+
+
+
+
+
-- Multisampling: 1.12's own Video Options dropdown
-- (OptionsFrameMultiSampleDropDown, id 4), over
diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs
index e64fe2d7..91bc918e 100644
--- a/crates/benilla-app/src/capture/mod.rs
+++ b/crates/benilla-app/src/capture/mod.rs
@@ -885,6 +885,16 @@ fn hold_clock(mut clock: ResMut>) {
clock.pause();
}
+/// The three scene-population queries the `FPS_PROBE` line prints — bundled because they are one
+/// concern (how much world is resident, and how much of it survived the cull) and because
+/// `drive_capture` sits against Bevy's 16-parameter ceiling, which `cvars::KnobParams` hit first.
+#[derive(bevy::ecs::system::SystemParam)]
+pub(crate) struct ProbeCensus<'w, 's> {
+ particles: Query<'w, 's, &'static benilla_world::particles::ParticleEmitter>,
+ parts: Query<'w, 's, &'static ViewVisibility, With>,
+ entities: Query<'w, 's, ()>,
+}
+
/// Drive the capture lifecycle: wait for streaming, settle, screenshot, exit.
#[allow(clippy::too_many_arguments)]
fn drive_capture(
@@ -898,9 +908,7 @@ fn drive_capture(
// `ResMut` for one reason: re-anchoring the clock at the probe's release — see `Phase::Aging`.
mut time: ResMut>,
mut windows: Query<&mut Window, With>,
- particles: Query<&benilla_world::particles::ParticleEmitter>,
- parts: Query<&ViewVisibility, With>,
- entities: Query<()>,
+ census: ProbeCensus,
fx_req: Option>,
wfx_req: Option>,
mut fx_state: Option>,
@@ -913,6 +921,9 @@ fn drive_capture(
mut clock: ResMut>,
// Switched to `Automatic` at the probe's release point — see `Phase::Aging`.
mut time_strategy: ResMut,
+ // `Option`: the glue-screen capture path builds no composite lane, so there is no backdrop to
+ // ask how big the world was — and those scenarios have no world to price anyway.
+ backdrop: Option>,
) {
// The wall-clock ceiling, checked before anything else and in every phase — `Building` is
// where the drawable starvation was caught, but `FxAging` waits on a fixture that may never
@@ -1113,7 +1124,8 @@ fn drive_capture(
v.sort_by(f32::total_cmp);
let at = |q: f32| v[(((v.len() - 1) as f32) * q).round() as usize];
let mean = v.iter().sum::() / v.len() as f32;
- let (emitters, active, live) = particles
+ let (emitters, active, live) = census
+ .particles
.iter()
.fold((0usize, 0usize, 0usize), |(e, a, l), p| {
(e + 1, a + usize::from(p.live() > 0), l + p.live())
@@ -1122,13 +1134,20 @@ fn drive_capture(
.single()
.map(|w| (w.physical_width(), w.physical_height()))
.unwrap_or((0, 0));
+ // The pixels the GPU was actually asked for. `px` is the WINDOW, and since 1639
+ // the world need not match it: a probe line that reported only the window would
+ // silently price a 4x supersample as if it were a native frame.
+ let world_px = backdrop.map_or(String::new(), |b| {
+ let s = b.render_size();
+ format!(" world_px={}x{}", s.x, s.y)
+ });
// Scene population: model submeshes (the per-frame visibility walk's N), how many
// survived the cull to render, and the whole-world entity count — the scale terms
// behind every O(N) per-frame cost (the Stormwind fps hunt's instrument).
- let (submeshes, drawn) = parts.iter().fold((0usize, 0usize), |(n, d), v| {
+ let (submeshes, drawn) = census.parts.iter().fold((0usize, 0usize), |(n, d), v| {
(n + 1, d + usize::from(v.get()))
});
- let entity_count = entities.iter().len();
+ let entity_count = census.entities.iter().len();
// CPU cost per frame across every thread — the load-robust half of the measurement
// (`perf::process_cpu_secs`), same fields as the live probe's line.
let cpu = match (ctx.probe_cpu_start, crate::perf::process_cpu_secs()) {
@@ -1150,7 +1169,7 @@ fn drive_capture(
// Machine-greppable one-liner + a human block. stdout, not the log, so a script can
// capture it without log-filter noise.
println!(
- "FPS_PROBE scenario={} frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={emitters} active={active} particles={live} submeshes={submeshes} drawn={drawn} entities={entity_count} px={}x{}{cpu}{present}",
+ "FPS_PROBE scenario={} frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={emitters} active={active} particles={live} submeshes={submeshes} drawn={drawn} entities={entity_count} px={}x{}{world_px}{cpu}{present}",
ctx.name,
v.len(),
at(0.50),
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index 1d13abf6..789c0528 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -41,6 +41,7 @@ use crate::ui_loot::LootConfig;
use crate::ui_script::UiScaleCvar;
use crate::video::VideoConfig;
use crate::vplates::VPlateMode;
+use crate::world_backdrop::{RenderScale, RENDER_SCALE_RANGE};
use benilla_ui::script::UiScript;
use benilla_ui::widget::MINIMAP_ZOOM_LEVELS;
use benilla_world::clutter::ClutterConfig;
@@ -288,6 +289,19 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
// enumeration.
("gxColorBits", "32"),
("gxDepthBits", "32"),
+ // **Render scale** (decision 1639) — benilla's own CVar, no 1.12 counterpart, in the
+ // `boothHalfRate` / `SoundOutputLimiter` mould: the reference has no such dial because it has
+ // no second buffer to hang one on. The world renders into the composite lane's off-screen image
+ // at `window × this` while the UI stays at native resolution; the knob is
+ // [`crate::world_backdrop::RenderScale`], clamped to its `RENDER_SCALE_RANGE`.
+ //
+ // The era's nearest equivalent is `gxResolution`, which drops the interface along with the
+ // world and, in fullscreen, mode-sets the display — the thing 1627 deliberately stopped doing.
+ //
+ // Default "1" is off, and that is load-bearing rather than cautious: at 1.0 the lane reproduces
+ // its pre-1639 numbers bit-for-bit, so no visual golden in the tree moves. `$WOW_RENDER_SCALE`
+ // overrides it session-only, below.
+ ("renderScale", "1"),
(crate::char_select::CVAR_LAST_CHARACTER, "0"),
];
@@ -418,6 +432,7 @@ pub(crate) struct KnobParams<'w> {
zoom: ResMut<'w, ZoomLimit>,
follow: ResMut<'w, FollowConfig>,
video: ResMut<'w, VideoConfig>,
+ render_scale: ResMut<'w, RenderScale>,
pane_rate: ResMut<'w, PaneRate>,
guild_notify: ResMut<'w, crate::ui_guild::GuildMemberNotify>,
}
@@ -447,6 +462,7 @@ impl KnobParams<'_> {
zoom: &mut self.zoom,
follow: &mut self.follow,
video: &mut self.video,
+ render_scale: &mut self.render_scale,
pane_rate: &mut self.pane_rate,
guild_notify: &mut self.guild_notify,
}
@@ -471,6 +487,7 @@ struct Knobs<'a> {
zoom: &'a mut ZoomLimit,
follow: &'a mut FollowConfig,
video: &'a mut VideoConfig,
+ render_scale: &'a mut RenderScale,
pane_rate: &'a mut PaneRate,
guild_notify: &'a mut crate::ui_guild::GuildMemberNotify,
}
@@ -570,6 +587,12 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
"gxwindow" => knobs.video.display = crate::video::display_from_flag(v),
// The body panes' half-rate render (1444) — a flag like every other checkbox here.
"boothhalfrate" => knobs.pane_rate.half = v != 0.0,
+ // Render scale (1639). Clamped at the knob's edge like every other numeric row; the
+ // backdrop re-sizes on the next frame and the world camera's target factor follows it
+ // in the same pass, which is what keeps the pick rays where they were.
+ "renderscale" => {
+ knobs.render_scale.0 = v.clamp(*RENDER_SCALE_RANGE.start(), *RENDER_SCALE_RANGE.end());
+ }
// Multisampling (1629) — the reference's own `atoi`-then-clamp `[1, 16]` at `0x63b250`.
// Writing the knob live is faithful, not a bug: the CVar holds the PENDING value (latched),
// and nothing reads this resource after the world camera's spawn.
@@ -621,6 +644,12 @@ fn load_config(mut persist: ResMut, mut params: KnobParams) {
if std::env::var_os("WOW_MSAA").is_some() {
persist.env_overridden.insert("gxmultisample".into());
}
+ // `$WOW_RENDER_SCALE` is the render-scale A/B lever (1639), and doubly session-only: it is
+ // also the supersampling instrument this machine prices pixels with, and an instrument run
+ // that pinned 4× into the file would come back at 4× the next time the client opened.
+ if std::env::var_os("WOW_RENDER_SCALE").is_some() {
+ persist.env_overridden.insert("renderscale".into());
+ }
let cvars = match stored_config() {
StoredConfig::Absent => return, // no file, hermetic capture, or no install
StoredConfig::Bad(msg) => {
@@ -739,6 +768,7 @@ fn sync_cvars(
zoom,
follow,
video,
+ render_scale,
pane_rate,
guild_notify,
msaa,
@@ -775,7 +805,7 @@ fn sync_cvars(
.collect(),
);
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
- let session: [(&str, String); 36] = [
+ let session: [(&str, String); 37] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -825,6 +855,7 @@ fn sync_cvars(
format!("{}x{}", video.windowed.x, video.windowed.y),
),
("boothHalfRate", flag(pane_rate.half)),
+ ("renderScale", render_scale.0.to_string()),
("gxMultisample", msaa.samples.to_string()),
];
for (name, value) in session {
@@ -1086,6 +1117,10 @@ mod tests {
assert_eq!(d["gxVSync"] != 0.0, VideoConfig::default().vsync);
// The pane half-rate (1444) welds to the portrait knob's shipped default.
assert_eq!(d["boothHalfRate"] != 0.0, PaneRate::default().half);
+ // Render scale (1639) welds to OFF. Not a taste default: the whole tree of visual
+ // goldens is denominated in a 1:1 backdrop, so a registered value other than 1 would
+ // silently re-render every one of them through a resample.
+ assert_eq!(d["renderScale"], 1.0);
}
#[test]
@@ -1114,6 +1149,8 @@ mod tests {
let mut guild_notify = crate::ui_guild::GuildMemberNotify::default();
// Literal, not Default: MsaaSetting::default() reads $WOW_MSAA.
let mut msaa = MsaaSetting { samples: 1 };
+ // Literal for the same reason: RenderScale::default() reads $WOW_RENDER_SCALE.
+ let mut render_scale = RenderScale(1.0);
let mut knobs = Knobs {
sound: &mut sound,
scale: &mut scale,
@@ -1129,6 +1166,7 @@ mod tests {
zoom: &mut zoom,
follow: &mut follow,
video: &mut video,
+ render_scale: &mut render_scale,
pane_rate: &mut pane_rate,
guild_notify: &mut guild_notify,
msaa: &mut msaa,
@@ -1148,6 +1186,13 @@ mod tests {
assert_eq!(knobs.msaa.samples, *MSAA_RANGE.end());
assert!(apply_to_knobs("gxmultisample", "0", &mut knobs));
assert_eq!(knobs.msaa.samples, *MSAA_RANGE.start());
+ // Render scale takes a fraction and clamps to its own range at both ends (1639).
+ assert!(apply_to_knobs("renderScale", "0.75", &mut knobs));
+ assert_eq!(knobs.render_scale.0, 0.75);
+ assert!(apply_to_knobs("renderscale", "9", &mut knobs));
+ assert_eq!(knobs.render_scale.0, *RENDER_SCALE_RANGE.end());
+ assert!(apply_to_knobs("renderscale", "0", &mut knobs));
+ assert_eq!(knobs.render_scale.0, *RENDER_SCALE_RANGE.start());
// Enable flags: any nonzero is on, zero is off (the client's int-parse + != 0).
assert!(apply_to_knobs("EnableMusic", "0", &mut knobs));
assert!(!knobs.sound.music_enabled);
@@ -1336,6 +1381,8 @@ mod tests {
.init_resource::()
.init_resource::()
.init_resource::()
+ // Literal, not Default: RenderScale::default() reads $WOW_RENDER_SCALE.
+ .insert_resource(RenderScale(1.0))
.init_resource::()
.init_resource::()
.add_plugins(CvarPlugin);
diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs
index f8a02488..2a6dbc20 100644
--- a/crates/benilla-app/src/lib.rs
+++ b/crates/benilla-app/src/lib.rs
@@ -373,57 +373,59 @@ pub fn run(build: BuildId) -> AppExit {
// the 1:1 gx window: at 1024×768 one gx unit = 1280 px, so the plate must land at the
// border texture's native 128×32 — directly diffable against the decoded BLP. Sized
// per-capture off WOW_CAPTURE.
- resolution: if capturing && std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1") {
- // `$WOW_WIN` overrides here too — the resolution-A/B instrument for UI scenarios (a
- // scale-dependent text bug looks fine at the scenario's default size and truncates at
- // fullscreen heights).
- if let Some(win) = video::requested_window_size() {
- win
- } else {
- match std::env::var("WOW_CAPTURE").as_deref() {
- Ok("ui-actionbar") => UVec2::new(1300, 260),
- Ok("vplates") => UVec2::new(1024, 768),
- // The director's small-window shape: short enough that the action bar strip
- // overlaps the chat edit box rows — the overlap is the subject.
- Ok("ui-chatedit") => UVec2::new(566, 377),
- // The fullscreen map's chrome is a centered 1024×768 block; a hair of margin
- // shows the blackout doing its job.
- Ok("ui-worldmap") => UVec2::new(1100, 800),
- // The 920×724 era options window wants air on every side so the straddling
- // right-edge tile and the hung close X stay in frame.
- Ok("ui-options") | Ok("ui-options-audio") | Ok("ui-options-graphics") => {
- UVec2::new(1200, 900)
- }
- _ => UVec2::new(640, 700),
- }
- }
- .into()
- } else {
- // `$WOW_WIN=WxH` (logical px): override the world capture/window size — the
- // resolution-A/B instrument. The FFXGlow blur geometry is byte-pinned in TEXELS, so its
- // angular footprint shrinks as resolution grows and thin bright features (fence rails)
- // self-amplify at 4K where the 1024-era reference diluted them; matching the era's
- // pixel density (e.g. `WOW_WIN=512x288` on a 2× display → 1024×576 physical) isolates
- // that term. Also the knob for any future era-resolution comparison.
- video::requested_window_size()
- // A run that reads no pixels gets a SMALL window. It is held `AlwaysOnTop` for its
- // whole life so it can never be occluded into the ~1 fps throttle
- // (`capture::ProbeFocusPlugin`, decision 0906) — at the full default that meant
- // every agent probe planted a screen-filling window over the director's work. Small
- // + cornered (`ProbeFocusPlugin` parks it) is un-occludable AND out of the way;
- // anything photographing pixels keeps the full size, and `WOW_WIN` overrides either
- // way (decision 1148).
- .unwrap_or(if benilla_world::bgwin::no_pixel_run() {
- UVec2::new(640, 360)
+ resolution: video::at_requested_dpi(
+ if capturing && std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1") {
+ // `$WOW_WIN` overrides here too — the resolution-A/B instrument for UI scenarios (a
+ // scale-dependent text bug looks fine at the scenario's default size and truncates at
+ // fullscreen heights).
+ if let Some(win) = video::requested_window_size() {
+ win
} else {
- // The player's `gxResolution` — what "windowed" means for them, and what
- // leaving fullscreen restores (1627). Its default is the 1600×900 that was
- // hard-coded here before, and while `mode` above is fullscreen `bevy_winit`
- // ignores this entirely (it applies an inner size only on the `Windowed` arm).
- video::boot_windowed_size()
- })
+ match std::env::var("WOW_CAPTURE").as_deref() {
+ Ok("ui-actionbar") => UVec2::new(1300, 260),
+ Ok("vplates") => UVec2::new(1024, 768),
+ // The director's small-window shape: short enough that the action bar strip
+ // overlaps the chat edit box rows — the overlap is the subject.
+ Ok("ui-chatedit") => UVec2::new(566, 377),
+ // The fullscreen map's chrome is a centered 1024×768 block; a hair of margin
+ // shows the blackout doing its job.
+ Ok("ui-worldmap") => UVec2::new(1100, 800),
+ // The 920×724 era options window wants air on every side so the straddling
+ // right-edge tile and the hung close X stay in frame.
+ Ok("ui-options") | Ok("ui-options-audio") | Ok("ui-options-graphics") => {
+ UVec2::new(1200, 900)
+ }
+ _ => UVec2::new(640, 700),
+ }
+ }
.into()
- },
+ } else {
+ // `$WOW_WIN=WxH` (logical px): override the world capture/window size — the
+ // resolution-A/B instrument. The FFXGlow blur geometry is byte-pinned in TEXELS, so its
+ // angular footprint shrinks as resolution grows and thin bright features (fence rails)
+ // self-amplify at 4K where the 1024-era reference diluted them; matching the era's
+ // pixel density (e.g. `WOW_WIN=512x288` on a 2× display → 1024×576 physical) isolates
+ // that term. Also the knob for any future era-resolution comparison.
+ video::requested_window_size()
+ // A run that reads no pixels gets a SMALL window. It is held `AlwaysOnTop` for its
+ // whole life so it can never be occluded into the ~1 fps throttle
+ // (`capture::ProbeFocusPlugin`, decision 0906) — at the full default that meant
+ // every agent probe planted a screen-filling window over the director's work. Small
+ // + cornered (`ProbeFocusPlugin` parks it) is un-occludable AND out of the way;
+ // anything photographing pixels keeps the full size, and `WOW_WIN` overrides either
+ // way (decision 1148).
+ .unwrap_or(if benilla_world::bgwin::no_pixel_run() {
+ UVec2::new(640, 360)
+ } else {
+ // The player's `gxResolution` — what "windowed" means for them, and what
+ // leaving fullscreen restores (1627). Its default is the 1600×900 that was
+ // hard-coded here before, and while `mode` above is fullscreen `bevy_winit`
+ // ignores this entirely (it applies an inner size only on the `Windowed` arm).
+ video::boot_windowed_size()
+ })
+ .into()
+ },
+ ),
// The boot present mode. `$WOW_NOVSYNC=1` uncaps presentation so a headless FPS-journal
// run measures true frame cost, not the vsync ceiling — the same uncap the capture probe
// flips mid-run, available from boot for non-capture probes (perf triage at the glue
diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs
index 54415861..4e4a23a8 100644
--- a/crates/benilla-app/src/net.rs
+++ b/crates/benilla-app/src/net.rs
@@ -1452,6 +1452,15 @@ pub(crate) enum ClientCommand {
/// Convert the party into a raid (`CMSG_GROUP_RAID_CONVERT`, leader only; echoes back as a
/// `SMSG_GROUP_LIST` with `group_type` raid).
GroupRaidConvert,
+ /// Ask the server for one party/raid member's whole stat block
+ /// (`CMSG_REQUEST_PARTY_MEMBER_STATS`; answered by `SMSG_PARTY_MEMBER_STATS_FULL`).
+ ///
+ /// **Sent from exactly two edges, both the reference's** (decision 1640, wow-re
+ /// `ui/scratch/party-oor-stats-and-portrait-law.md` §2): the moment a member's object leaves
+ /// the object manager (`net::apply::group::member_deactivated`), and the GROUP_LIST seat of a
+ /// member new to the roster whose object we do not hold (`seat_new_records`). There is no
+ /// timer and no Lua binding — a periodic poll would be ours, not the client's.
+ RequestPartyMemberStats { guid: u64 },
/// Set the loot rules (`CMSG_LOOT_METHOD`): `method` 0..4, `master` guid (master loot only),
/// `threshold` quality 2..4. Leader only; echoes back as a fresh `SMSG_GROUP_LIST`.
LootMethod {
diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs
index 4a71ef64..b99ec849 100644
--- a/crates/benilla-app/src/net/apply.rs
+++ b/crates/benilla-app/src/net/apply.rs
@@ -621,9 +621,20 @@ pub(super) fn apply_net_updates(
}
SessionEvent::ObjectDestroyed(guid) => {
death::forget_corpse(guid, &mut death_net);
+ // The party hook runs FIRST and on the same edge the reference takes it: the
+ // deactivate virtual reads the descriptor that is about to go (decision 1640).
+ let store = index.0.get(&guid).and_then(|e| stores.get(*e).ok());
+ group::member_deactivated(guid, &mut group, store, &net_commands);
objects::object_destroyed(guid, &mut commands, &mut index, &mut items)
}
SessionEvent::ObjectsRemoved(guids) => {
+ // OUT_OF_RANGE and DESTROY take the same virtual in the reference — so the
+ // snapshot + `CMSG_REQUEST_PARTY_MEMBER_STATS` fire here too, which is the edge
+ // report B334 is actually about: a member walking over the hill.
+ for guid in &guids {
+ let store = index.0.get(guid).and_then(|e| stores.get(*e).ok());
+ group::member_deactivated(*guid, &mut group, store, &net_commands);
+ }
objects::objects_removed(guids, &mut commands, &mut index)
}
SessionEvent::MonsterMove {
@@ -666,17 +677,22 @@ pub(super) fn apply_net_updates(
position,
orientation,
needs_ack,
- } => session::worldport(
- map_id,
- position,
- orientation,
- needs_ack,
- &mut commands,
- &mut index,
- &mut aura.6,
- &aura.7,
- &mut worldports,
- ),
+ } => {
+ // Every streamed roster member's object is about to be purged — the same
+ // deactivation the reference runs one object at a time (decision 1640).
+ group::roster_deactivated(&mut group, &index, &stores, &net_commands);
+ session::worldport(
+ map_id,
+ position,
+ orientation,
+ needs_ack,
+ &mut commands,
+ &mut index,
+ &mut aura.6,
+ &aura.7,
+ &mut worldports,
+ )
+ }
SessionEvent::TransferPending {
map_id,
transport_entry,
@@ -949,6 +965,7 @@ pub(super) fn apply_net_updates(
leader,
loot,
&mut names,
+ &index,
&net_commands,
),
SessionEvent::PartyCommandResult {
diff --git a/crates/benilla-app/src/net/apply/group.rs b/crates/benilla-app/src/net/apply/group.rs
index b74c1143..190d82a8 100644
--- a/crates/benilla-app/src/net/apply/group.rs
+++ b/crates/benilla-app/src/net/apply/group.rs
@@ -5,14 +5,17 @@
//! decision 0440's §5 fold-back). Each `pub(super)` fn here is exactly one arm's body; the match at
//! the call site stays the dispatcher, one call per arm.
-use benilla_protocol::messages::{GroupLootInfo, GroupMemberEntry};
+use benilla_protocol::messages::{
+ member_status, GroupLootInfo, GroupMemberEntry, PartyMemberStatsInfo,
+};
+use bevy::prelude::*;
use crate::names::NameCache;
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
use crate::ui_party::GroupState;
use crate::ui_quest::QuestGiver;
-use super::super::{NetCommands, SelfGuid};
+use super::super::{ClientCommand, GuidIndex, NetCommands, ObjectStore, SelfGuid};
/// Push the lines a `GroupState::apply_*` composed as CHAT_MSG_SYSTEM — the same seam the other
/// client-composed feeds use (the reference's engine formats these via its errorId→GlobalStrings
@@ -81,16 +84,121 @@ pub(super) fn list(
leader: u64,
loot: Option,
names: &mut NameCache,
+ index: &GuidIndex,
net_commands: &NetCommands,
) {
for m in &members {
let _ = names.resolve(m.guid, net_commands);
}
+ // The roster BEFORE the apply, so the "new to this roster" test below is the slot writer's
+ // own `srcRec == 0` (see `seat_new_records`), not a re-read of what we just stored.
+ let seats: Vec<(u64, bool)> = members
+ .iter()
+ .map(|m| (m.guid, m.status & member_status::ONLINE != 0))
+ .collect();
let lines = group.apply_list(group_type, own_flags, members, leader, loot);
push_group_lines(chat_log, lines);
+ seat_new_records(group, &seats, index, net_commands);
quest.bump_reask();
}
+/// **The GROUP_LIST slot writer's record leg** — `0x4e82d0` (VERIFIED wow-re
+/// `ui/scratch/party-oor-stats-and-portrait-law.md` §2.2), the second of the two places the
+/// reference asks for a member's stats.
+///
+/// Per member the writer has two legs, chosen on whether the member already had a record
+/// (`srcRec`): a **known** member's record is copied forward and *nothing is sent* — a resync of a
+/// group you are already in must not fire four queries — while a member **new to the roster** gets
+/// a zeroed record with the `1/1` placeholder ([`PartyMemberStatsInfo::placeholder`]), and then, if
+/// their player object is not in the object manager (`4e8398 74 4a je`), the request
+/// (`4e83e4 39 7d f8 cmp [ebp-8],edi; 75 5f jne` — the second gate, on `srcRec` again).
+///
+/// The consequence worth stating: after this, **every roster member owns a record**, which is what
+/// makes the merged view's out-of-range leg total. A member you have never seen shows a full `1/1`
+/// bar rather than an empty `0/0` one until their stats land — the reference's own picture.
+fn seat_new_records(
+ group: &mut GroupState,
+ seats: &[(u64, bool)],
+ index: &GuidIndex,
+ net_commands: &NetCommands,
+) {
+ for (guid, online) in seats {
+ // `apply_list` has already dropped the records of everyone who left, and kept the rest —
+ // so "no record now" is exactly the writer's `srcRec == 0`.
+ if group.stats.contains_key(guid) {
+ continue;
+ }
+ group
+ .stats
+ .insert(*guid, PartyMemberStatsInfo::placeholder(*online));
+ if !index.0.contains_key(guid) {
+ let _ = net_commands
+ .0
+ .send(ClientCommand::RequestPartyMemberStats { guid: *guid });
+ }
+ }
+}
+
+/// **The despawn edge** — CGPlayer_C vtable slot 1 `0x5e9aa0`, which `0x464920` invokes on both
+/// `SMSG_DESTROY_OBJECT` and the `SMSG_UPDATE_OBJECT` OUT_OF_RANGE block (VERIFIED wow-re
+/// `object-layer/scratch/party-record-live-snapshot.md` §3, `ui/scratch/party-oor-stats-and-
+/// portrait-law.md` §2.1). A party or raid member's object is leaving the object manager, so:
+/// **snapshot its live descriptor into the roster record** (`0x5f0880`), then **ask the server for
+/// the member's stats** (`CMSG_REQUEST_PARTY_MEMBER_STATS`, `0x4e8646`), in that order and on that
+/// same instruction sequence.
+///
+/// The pair is why the reference's party frame does not blank when somebody walks over the hill
+/// (report B334): the snapshot means the bars keep the numbers they were showing at the edge, and
+/// the request means the server answers `_FULL` rather than leaving us on whatever delta its
+/// accumulated mask happens to carry next. Neither existed here before decision 1640.
+///
+/// **Two gates, and both are the reference's.** This runs on every despawn, so a guid that is not
+/// on the roster falls straight through; and a guid we hold **no object for** falls through too —
+/// the hook is a *virtual on the object*, so no object means it never ran, and a server that
+/// re-announces a stream-out we have already applied must not cost a packet.
+pub(super) fn member_deactivated(
+ guid: u64,
+ group: &mut GroupState,
+ store: Option<&ObjectStore>,
+ net_commands: &NetCommands,
+) {
+ let Some(store) = store else {
+ return;
+ };
+ if !group.members.iter().any(|m| m.guid == guid) {
+ return;
+ }
+ group
+ .stats
+ .entry(guid)
+ .or_default()
+ .snapshot_descriptor(&store.0);
+ let _ = net_commands
+ .0
+ .send(ClientCommand::RequestPartyMemberStats { guid });
+}
+
+/// [`member_deactivated`] for **every** streamed roster member at once — the bulk teardown a
+/// cross-map transfer performs, where the reference destroys the same objects one at a time and
+/// runs the same hook on each.
+pub(super) fn roster_deactivated(
+ group: &mut GroupState,
+ index: &GuidIndex,
+ stores: &Query<&mut ObjectStore>,
+ net_commands: &NetCommands,
+) {
+ let streamed: Vec = group
+ .members
+ .iter()
+ .map(|m| m.guid)
+ .filter(|g| index.0.contains_key(g))
+ .collect();
+ for guid in streamed {
+ let store = index.0.get(&guid).and_then(|e| stores.get(*e).ok());
+ member_deactivated(guid, group, store, net_commands);
+ }
+}
+
/// `SMSG_PARTY_COMMAND_RESULT` — the verdict on an invite/kick/leave we asked for.
pub(super) fn command_result(
group: &mut GroupState,
@@ -151,6 +259,7 @@ mod tests {
leader,
None,
&mut names,
+ &GuidIndex::default(),
&net,
);
@@ -179,6 +288,7 @@ mod tests {
leader,
None,
&mut names,
+ &GuidIndex::default(),
&net,
);
assert!(
@@ -188,4 +298,177 @@ mod tests {
);
assert_eq!(names.player_traits(leader), Some((1, 4, 1)));
}
+
+ // ── The out-of-range record (decision 1640, report B334) ────────────────────────────────
+
+ /// Descriptor field indices, spelled locally the way the other app-side descriptor tests do
+ /// (`ui_unit`'s vitals block) — the private `FIELD_UNIT_*` values, build 5875.
+ const HEALTH: u16 = 22;
+ const MAXHEALTH: u16 = 28;
+ /// `UNIT_FIELD_POWER2` / `MAXPOWER2` — the RAGE slot (`POWER1 + POWER_RAGE`).
+ const POWER2: u16 = 24;
+ const MAXPOWER2: u16 = 30;
+ const LEVEL: u16 = 34;
+ const BYTES_0: u16 = 36;
+
+ fn asked(rx: &crossbeam_channel::Receiver) -> Vec {
+ rx.try_iter()
+ .filter_map(|c| match c {
+ ClientCommand::RequestPartyMemberStats { guid } => Some(guid),
+ _ => None,
+ })
+ .collect()
+ }
+
+ fn grouped(members: &[GroupMemberEntry]) -> GroupState {
+ let mut group = GroupState::default();
+ group.apply_list(0, 0, members.to_vec(), members[0].guid, None);
+ group
+ }
+
+ /// **The despawn edge is where the numbers are kept** (wow-re `0b2a594a` §2.1/§2.3 — the
+ /// deactivate virtual `0x5e9aa0` snapshots `0x5f0880` and sends `0x27f`, in that order).
+ ///
+ /// Falsifier for the whole report: delete the snapshot and this member's record stays whatever
+ /// the wire last said — which, for a member who has never had a stats packet, is nothing, and
+ /// the frame reads `0/0`. Delete the send and the server is never asked for a `_FULL`, so the
+ /// record stays frozen at the snapshot until the member's health happens to change.
+ #[test]
+ fn a_members_despawn_snapshots_their_descriptor_and_asks_for_their_stats() {
+ let (tx, rx) = crossbeam_channel::unbounded();
+ let net = NetCommands(tx);
+ let guid = 0x1234;
+ let mut group = grouped(&[member(guid, "Thalyn"), member(0x99, "Frostshake")]);
+ let _ = asked(&rx); // the roster's own seat-time asks (they are unstreamed here)
+
+ // A warrior: rage rides the wire ×10, and the record stores it raw exactly as the
+ // descriptor does.
+ let store = ObjectStore(benilla_protocol::messages::ObjectFields::from_pairs(&[
+ (HEALTH, 2400),
+ (MAXHEALTH, 3000),
+ (POWER2, 570),
+ (MAXPOWER2, 1000),
+ (LEVEL, 41),
+ (BYTES_0, 1 << 24), // POWER_RAGE in BYTES_0 byte 3
+ ]));
+ member_deactivated(guid, &mut group, Some(&store), &net);
+
+ let rec = group.stats.get(&guid).expect("the member has a record");
+ assert_eq!(
+ (rec.cur_hp, rec.max_hp, rec.level),
+ (Some(2400), Some(3000), Some(41)),
+ "the bars keep the numbers they were showing at the edge"
+ );
+ assert_eq!(
+ (rec.power_type, rec.cur_power, rec.max_power),
+ (Some(1), Some(570), Some(1000)),
+ "RAW power, like the reference's record — the ÷10 happens at the read"
+ );
+ assert_eq!((rec.shown_power(), rec.shown_max_power()), (57, 100));
+ assert_eq!(
+ rec.status,
+ Some(member_status::ONLINE),
+ "an object you can see belongs to an online, living, unflagged player"
+ );
+ assert_eq!(asked(&rx), vec![guid], "and the server is asked, once");
+ }
+
+ /// The hook runs on **every** despawn — a mob, a totem, a stranger — and must be silent for
+ /// everyone who is not on the roster. Without the guard every stream-out in a busy city would
+ /// put a `CMSG_REQUEST_PARTY_MEMBER_STATS` on the wire.
+ #[test]
+ fn a_despawn_that_is_not_a_party_member_asks_nothing() {
+ let (tx, rx) = crossbeam_channel::unbounded();
+ let net = NetCommands(tx);
+ let mut group = grouped(&[member(0x1234, "Thalyn")]);
+ let _ = asked(&rx);
+ // A live object, so only the roster gate can stop it.
+ let store = ObjectStore(benilla_protocol::messages::ObjectFields::from_pairs(&[(
+ HEALTH, 40,
+ )]));
+ member_deactivated(0xdead, &mut group, Some(&store), &net);
+ assert!(asked(&rx).is_empty());
+ assert!(!group.stats.contains_key(&0xdead));
+
+ // …and the object gate on its own: a roster member we hold no object for never
+ // deactivated, so re-announcing their stream-out costs nothing.
+ member_deactivated(0x1234, &mut group, None, &net);
+ assert!(asked(&rx).is_empty());
+ }
+
+ /// **The roster seat** (`0x4e82d0`, §2.2): a member new to the roster gets the `1/1`
+ /// placeholder, and is asked for only when we hold no object for them. A resync asks nothing —
+ /// the gate the reference puts on `srcRec`, and the reason a busy group does not fire four
+ /// queries per GROUP_LIST.
+ #[test]
+ fn a_roster_new_member_is_seated_at_one_one_and_asked_for_only_when_unseen() {
+ let (tx, rx) = crossbeam_channel::unbounded();
+ let net = NetCommands(tx);
+ let (mut group, mut chat_log, mut quest) = (
+ GroupState::default(),
+ ChatLog::default(),
+ QuestGiver::default(),
+ );
+ let mut names = NameCache::default();
+ let (near, far) = (0x11u64, 0x22u64);
+
+ // `near` is streamed, `far` is not.
+ let mut index = GuidIndex::default();
+ let mut world = bevy::ecs::world::World::new();
+ index.0.insert(near, world.spawn_empty().id());
+
+ let roster = vec![member(near, "Frostshake"), member(far, "Thalyn")];
+ let mut send = |group: &mut GroupState, members: Vec| {
+ list(
+ group,
+ &mut chat_log,
+ &mut quest,
+ 0,
+ 0,
+ members,
+ near,
+ None,
+ &mut names,
+ &index,
+ &net,
+ );
+ };
+
+ send(&mut group, roster.clone());
+ assert_eq!(
+ asked(&rx),
+ vec![far],
+ "only the member whose object we do not hold"
+ );
+ for guid in [near, far] {
+ let rec = group.stats.get(&guid).expect("every member owns a record");
+ assert_eq!(
+ (rec.cur_hp, rec.max_hp, rec.cur_power, rec.max_power),
+ (Some(1), Some(1), Some(1), Some(1)),
+ "a full bar, not an empty one, until the stats land"
+ );
+ }
+
+ // The wire answers for `far`, and then the roster is re-sent (somebody changed the loot
+ // method). The cached record is restored and nothing is asked again.
+ group.apply_stats(
+ far,
+ true,
+ PartyMemberStatsInfo {
+ cur_hp: Some(900),
+ max_hp: Some(1100),
+ ..PartyMemberStatsInfo::default()
+ },
+ );
+ send(&mut group, roster);
+ assert!(
+ asked(&rx).is_empty(),
+ "a resync of a known roster asks nothing"
+ );
+ assert_eq!(
+ group.stats.get(&far).map(|r| (r.cur_hp, r.max_hp)),
+ Some((Some(900), Some(1100))),
+ "and the record it already had survives the resync"
+ );
+ }
}
diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs
index a7549c03..b707bc41 100644
--- a/crates/benilla-app/src/net/io.rs
+++ b/crates/benilla-app/src/net/io.rs
@@ -967,6 +967,9 @@ fn writer_loop(
ClientCommand::GroupSetLeader { guid } => w.group_set_leader(guid),
ClientCommand::GroupLeave => w.group_disband(),
ClientCommand::GroupRaidConvert => w.group_raid_convert(),
+ ClientCommand::RequestPartyMemberStats { guid } => {
+ w.request_party_member_stats(guid)
+ }
ClientCommand::LootMethod {
method,
master,
diff --git a/crates/benilla-app/src/portrait/dressup.rs b/crates/benilla-app/src/portrait/dressup.rs
index 2b46dc33..efd6ad62 100644
--- a/crates/benilla-app/src/portrait/dressup.rs
+++ b/crates/benilla-app/src/portrait/dressup.rs
@@ -155,6 +155,7 @@ pub(super) fn spawn_dressup_booth(
root,
target: image,
baked: None,
+ baked_guid: None,
snap: None,
shown: false,
show_rev: 0,
diff --git a/crates/benilla-app/src/portrait/glue_booth.rs b/crates/benilla-app/src/portrait/glue_booth.rs
index 5141c2ce..127d8ec5 100644
--- a/crates/benilla-app/src/portrait/glue_booth.rs
+++ b/crates/benilla-app/src/portrait/glue_booth.rs
@@ -534,6 +534,7 @@ pub(super) fn spawn_glue_booth(
root,
target: image,
baked: None,
+ baked_guid: None,
snap: None,
shown: false,
show_rev: 0,
diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs
index 91975310..9c306c9e 100644
--- a/crates/benilla-app/src/portrait/mod.rs
+++ b/crates/benilla-app/src/portrait/mod.rs
@@ -321,6 +321,63 @@ pub(crate) enum PortraitSource {
File(String),
}
+/// **The GUID-keyed bake cache** — `0xc0ce7c` (decision 1640; wow-re
+/// `ui/scratch/party-oor-stats-and-portrait-law.md` §4/§5, report B334).
+///
+/// `SetPortraitTexture` on a player GUID whose object the client does not hold does **not** go
+/// straight to the 2D stand-in: it probes this cache by guid and, on a hit with a live handle,
+/// **binds the cached bake** (`0x525c36 call 0x770300`) — the face the member had when you last
+/// saw them. Only a miss loads `TemporaryPortrait-{Male|Female}-{Race}`. So a party member who
+/// walks out of visibility range keeps their portrait, and a member you have never seen falls to
+/// the stand-in: exactly the two halves of the reported behaviour.
+///
+/// **How a bake gets in here.** A booth renders into its own target image and then sleeps, so that
+/// image already *is* a still of the last bake — the retention is a **handover**: at the moment a
+/// party booth loses its member, its target moves in here under that member's guid and the booth
+/// is given a fresh one ([`sync_portraits`]'s empty arm). The UI keeps sampling the same handle it
+/// was sampling a frame earlier, so the frozen face costs no flicker and no copy — one 256²
+/// texture per retained member, allocated only at a handover.
+///
+/// **Two stated deviations from `0xc0ce7c`** (which caches every portrait draw for the whole
+/// session and is emptied only by `ClientDestroyGame 0x401ee0`):
+/// - only the four **party** slots hand over. They are where the observable lives: a target out of
+/// the object manager has already been deselected, and the pet slot takes the reference's own
+/// creature leg. A general cache would pay a texture for every unit ever portraited.
+/// - it is **capped** at [`Self::CAP`] entries, oldest-first, instead of growing all session. The
+/// cap is memory, not policy — and it is not cleared on disconnect either, because a guid's face
+/// does not go stale with the socket; the reference's clear is its graphics graph coming down,
+/// not the cache expiring.
+#[derive(Resource, Default)]
+pub(crate) struct PortraitBakes {
+ faces: HashMap>,
+ /// Insertion order, oldest first — the eviction queue for [`Self::CAP`].
+ order: Vec,
+}
+
+impl PortraitBakes {
+ /// How many members' faces are kept. Four is a full party; the slack covers a roster that
+ /// churns (somebody leaves out of range and is re-invited) without unbounding the memory.
+ const CAP: usize = 8;
+
+ /// The bake standing for `guid` — the cache probe `0x525ba0` makes before it reaches for the
+ /// stand-in.
+ fn get(&self, guid: u64) -> Option> {
+ self.faces.get(&guid).cloned()
+ }
+
+ /// Take a booth's target as `guid`'s face. A re-bake replaces the entry (the reference
+ /// re-stores the handle at `+0x24` too), and only a genuinely new guid can evict.
+ fn store(&mut self, guid: u64, face: Handle) {
+ if self.faces.insert(guid, face).is_none() {
+ self.order.push(guid);
+ if self.order.len() > Self::CAP {
+ let oldest = self.order.remove(0);
+ self.faces.remove(&oldest);
+ }
+ }
+ }
+}
+
/// The bridge between the booth and the UI: unit token (`"player"`/`"target"`) → what its portrait
/// region shows. The UI extract pass ([`crate::ui_script`]) reads it for a `SetPortraitTexture`-bound
/// region; the booth writes it (Live ↔ File transitions included).
@@ -435,6 +492,10 @@ struct Booth {
root: Entity,
target: Handle,
baked: Option,
+ /// **Whose face is standing in this booth** — the guid the current [`Self::baked`] belongs to,
+ /// for the handover into [`PortraitBakes`] when the booth loses them (decision 1640). Set by
+ /// the party slots alone; `None` on every other booth, which never hands over.
+ baked_guid: Option,
/// **Body panes only** — what [`sync_body_booth`] last snapshotted, in place of `baked`.
/// A body pane is a `` widget and re-takes its model on the reference's own
/// four triggers, never on the world moving something ([`SnapKey`]).
@@ -843,7 +904,7 @@ pub(crate) struct BoothBridge<'w> {
/// since become that system's overflow bag outright — [`BoothPanes`] is not group-facing at all,
/// it rides here because there is no room left in the signature.
#[derive(bevy::ecs::system::SystemParam)]
-pub(crate) struct PartyBooths<'w> {
+pub(crate) struct PartyBooths<'w, 's> {
roster: Res<'w, crate::ui_party::GroupState>,
index: Res<'w, crate::net::GuidIndex>,
palettes: ResMut<'w, benilla_world::rig_palette::RigPalettes>,
@@ -851,6 +912,17 @@ pub(crate) struct PartyBooths<'w> {
names: Res<'w, crate::names::NameCache>,
/// What the UI drew last frame ([`BoothPanes`]) — read by the `"targettarget"` slot alone.
panes: Res<'w, BoothPanes>,
+ /// The guid-keyed bake cache + what the handover needs to run (decision 1640): a fresh render
+ /// target for the booth that just gave its own away, and that booth camera's
+ /// [`RenderTarget`] to re-point at it.
+ ///
+ /// A query of its own rather than a wider `cams` tuple in [`sync_portraits`]: `aim`'s query
+ /// shape is shared by five booth systems across four files, and this is one system's concern.
+ /// It takes `RenderTarget` mutably where `cams` takes `Transform`/`Projection`, so the two
+ /// never overlap.
+ bakes: ResMut<'w, PortraitBakes>,
+ images: ResMut<'w, Assets>,
+ targets: Query<'w, 's, (&'static BoothCam, &'static mut RenderTarget)>,
}
/// Tags a booth camera with its slot token, so the model-sync pass can re-frame it per model.
@@ -958,6 +1030,7 @@ pub(crate) struct PortraitPlugin;
impl Plugin for PortraitPlugin {
fn build(&self, app: &mut App) {
app.init_resource::()
+ .init_resource::()
.init_resource::()
.init_resource::()
.init_resource::()
@@ -1203,6 +1276,7 @@ fn setup_booths(
root,
target: image,
baked: None,
+ baked_guid: None,
snap: None,
shown: false,
show_rev: 0,
@@ -1282,6 +1356,7 @@ fn setup_booths(
root,
target: image,
baked: None,
+ baked_guid: None,
snap: None,
shown: false,
show_rev: 0,
@@ -1645,6 +1720,8 @@ fn sync_portraits(
// ready" draws the blank. `unseen` carries that art for exactly those tokens; a token that
// names nobody at all leaves it `None` and the booth empties as before (report B315).
let mut unseen: Option = None;
+ // The guid this slot names, when the slot is one that retains a bake (decision 1640).
+ let mut occupant: Option = None;
let unit: Option = match token {
"player" => self_q.single().ok(),
"target" => selection.target,
@@ -1697,6 +1774,9 @@ fn sync_portraits(
.strip_prefix("party")
.and_then(|n| n.parse::().ok())
.and_then(|n| party.roster.party_slots().nth(n - 1));
+ // Whose face this slot is showing — the key the bake handover files it under,
+ // and the key the absent-unit arm probes the cache with (decision 1640).
+ occupant = member.map(|m| m.guid);
let entity = member.and_then(|m| party.index.0.get(&m.guid)).copied();
if entity.is_none() {
unseen = member.map(|m| {
@@ -1714,6 +1794,37 @@ fn sync_portraits(
continue;
};
let Some(unit) = unit else {
+ // ── The bake handover (decision 1640, report B334) ─────────────────────────────
+ //
+ // The booth is about to be emptied, and its target is a *still of the face that was
+ // standing in it* — so before the clear renders over it, that image becomes this
+ // member's entry in the guid cache and the booth is handed a fresh one. The UI is
+ // left sampling the very handle it sampled last frame, which now holds the frozen
+ // bake: the reference's "bind the cached bake" (`0x525c36`), reached without a copy
+ // and without a flicker.
+ //
+ // Keyed on whose bake is STANDING, not on who the slot names now: a slot whose
+ // occupant changed still owes the departed member their face. Only a **settled**
+ // bake is worth keeping — an unfinished one (camera still awake, a texture still in
+ // flight, pipelines still compiling) would freeze a half-rendered face forever, and
+ // the stand-in is the honest answer for it.
+ if let Some(guid) = booth.baked_guid.filter(|_| {
+ booth.baked.is_some()
+ && booth.wake == 0
+ && booth.pending.is_empty()
+ && !booth.pipes_settling
+ }) {
+ let fresh = party.images.add(new_target_image(PORTRAIT_SIZE));
+ for (cam, mut target) in &mut party.targets {
+ if cam.0 == *token {
+ *target = RenderTarget::Image(fresh.clone().into());
+ }
+ }
+ party
+ .bakes
+ .store(guid, std::mem::replace(&mut booth.target, fresh));
+ }
+ booth.baked_guid = None;
// No unit: empty the booth (the frame itself is hidden on UnitExists false; the dark
// disc behind it never shows).
if booth.baked.is_some() {
@@ -1730,10 +1841,14 @@ fn sync_portraits(
booth.rigged = false;
booth.parked = false;
}
- // A unit we simply hold no object for takes the ref's 2D stand-in; only a token that
- // names nobody falls to the emptied render target.
+ // A unit we hold no object for takes the **cached bake if we have ever baked them**
+ // and the ref's 2D stand-in otherwise (`0x525ba0`: hit → bind, miss → the
+ // TemporaryPortrait file); only a token that names nobody falls to the emptied
+ // render target.
let src = match unseen {
- Some(file) => PortraitSource::File(file),
+ Some(file) => occupant
+ .and_then(|g| party.bakes.get(g))
+ .map_or(PortraitSource::File(file), PortraitSource::Live),
None => PortraitSource::Live(booth.target.clone()),
};
if portraits.0.get(token) != Some(&src) {
@@ -1758,7 +1873,13 @@ fn sync_portraits(
continue;
}
let key = LookKey::build(&parts, &riders, &billboards, &effects);
- if booth.baked.as_ref() != Some(&key) {
+ // **A changed occupant is a re-bake even at an identical look** (decision 1640). The key
+ // is built from mesh/material handles, so two party members in the same race, sex and
+ // gear share it — and without this term the booth would keep standing A's bake while
+ // `baked_guid` was quietly re-filed to B, so the handover would put that face in the
+ // cache under the wrong guid and B would never get one of their own. The bake itself is
+ // per-unit; the key alone cannot say so.
+ if booth.baked.as_ref() != Some(&key) || booth.baked_guid != occupant {
let display_id = ent_q.get(unit).ok().and_then(|n| n.display_id);
// The look changed — re-bake: studio-lit twins of the exact dressed materials, posed
// at Stand on the booth's own throwaway skeleton (the ref bake — riders ride their
@@ -1880,6 +2001,7 @@ fn sync_portraits(
.chain(booth_billboards.iter().map(|b| &b.material)),
);
booth.baked = Some(key);
+ booth.baked_guid = occupant;
}
let live = PortraitSource::Live(booth.target.clone());
if portraits.0.get(token) != Some(&live) {
@@ -2601,6 +2723,42 @@ mod tests {
use super::*;
use crate::entities::ItemModelKind;
+ /// **The cache keeps faces, not slots** (decision 1640, report B334): a re-bake of somebody
+ /// already in it replaces their entry rather than queueing a second one, and only genuinely
+ /// new members can push the oldest out. Get this wrong — count a replace as an insert — and a
+ /// party member whose gear changes a few times evicts the other three.
+ #[test]
+ fn the_bake_cache_replaces_in_place_and_evicts_oldest_first() {
+ let mut bakes = PortraitBakes::default();
+ let mut images = Assets::::default();
+ // Distinct real handles — a 1² target is the cheapest thing `Assets` will mint.
+ let face = |images: &mut Assets| images.add(new_target_image(1));
+
+ let faces: Vec> =
+ (0..PortraitBakes::CAP).map(|_| face(&mut images)).collect();
+ for (i, f) in faces.iter().enumerate() {
+ bakes.store(i as u64 + 1, f.clone());
+ }
+ // A re-bake of the oldest entry: replaced, and it does NOT move up the eviction queue —
+ // the reference re-stores the handle at `+0x24` and touches no ordering either.
+ let rebaked = face(&mut images);
+ bakes.store(1, rebaked.clone());
+ assert_eq!(bakes.get(1), Some(rebaked));
+ assert_eq!(
+ bakes.order.len(),
+ PortraitBakes::CAP,
+ "a replace is not an insert"
+ );
+
+ // One new member past the cap evicts exactly the oldest.
+ let newcomer = face(&mut images);
+ bakes.store(99, newcomer.clone());
+ assert_eq!(bakes.get(1), None, "the oldest face went");
+ assert_eq!(bakes.get(2), Some(faces[1].clone()), "and only the oldest");
+ assert_eq!(bakes.get(99), Some(newcomer));
+ assert_eq!(bakes.faces.len(), PortraitBakes::CAP);
+ }
+
fn body_part() -> PortraitPart {
PortraitPart {
static_mesh: Handle::default(),
diff --git a/crates/benilla-app/src/ui_party/feed.rs b/crates/benilla-app/src/ui_party/feed.rs
index 91324e11..ebf8fd82 100644
--- a/crates/benilla-app/src/ui_party/feed.rs
+++ b/crates/benilla-app/src/ui_party/feed.rs
@@ -29,6 +29,12 @@ pub(super) struct FedParty {
loot: Option,
invite: Option,
units: [Option; 4],
+ /// Each `party1..party4` slot's `(member guid, is their object streamed)` — the edge
+ /// `PARTY_MEMBER_ENABLE`/`DISABLE` fire on (decision 1640). The **guid rides along** because
+ /// the events are an *object's* activation, not a slot's: a slot whose occupant changed is a
+ /// roster edge, and firing ENABLE there would announce an arrival nobody made. Guid `0` is an
+ /// empty slot.
+ presence: [(u64, bool); 4],
/// The whole roster-level snapshot last pushed — `set_party`'s own diff (1439; it used to
/// re-push, allocations and all, every frame).
pushed_party: Option,
@@ -260,13 +266,13 @@ pub(super) fn feed_party(
// party1..party4 unit snapshots + their per-field UNIT_* transitions — pushed on diff (1439;
// an identical snapshot re-pushed is invisible to the VM, so only a change pays the clone).
for (i, token) in PARTY_TOKENS.iter().enumerate() {
- let snap = slots.get(i).map(|m| {
+ let member = slots.get(i);
+ let snap = member.map(|m| {
member_unit_state(
m,
group.stats.get(&m.guid),
+ index.0.get(&m.guid).and_then(|e| stores.get(*e).ok()),
&group,
- &index,
- &stores,
own_group.clone(),
)
});
@@ -278,6 +284,36 @@ pub(super) fn feed_party(
}
fed.units[i] = snap;
}
+ // ── PARTY_MEMBER_ENABLE / PARTY_MEMBER_DISABLE (decision 1640) ──────────────────────
+ //
+ // The pair the reference fires from the very hooks decision 1640 built the rest of this
+ // arc on: `PARTY_MEMBER_DISABLE` (`0xdd`) at the end of the deactivate virtual
+ // `0x5e9aa0`, `PARTY_MEMBER_ENABLE` (`0xdc`) from the activate leg `0x4e85d0(mode 1)` —
+ // i.e. exactly this slot's object entering or leaving the object manager, with the
+ // **1-based slot index** as the (string) argument.
+ //
+ // Fired here rather than at the net edge because this is where the VM is, and because the
+ // slot number is a party-slot fact, not an object one. **Nothing in 1.12.1's FrameXML
+ // reads them** — `PartyMemberFrame_OnEvent`'s two arms are commented out in the shipped
+ // Lua — so this is addon-facing fidelity, and it is deliberately not the wire the frame's
+ // own repaint rides (that is `UNIT_*` + `PARTY_MEMBERS_CHANGED`, above).
+ let presence = member.map_or((0, false), |m| (m.guid, index.0.contains_key(&m.guid)));
+ if fed.presence[i] != presence {
+ gate.audit("feed_party", "a party-slot presence edge");
+ // Only the **same** member's object crossing the boundary is an activation. Not on
+ // the first observation after a VM reset either: a fresh VM starting at `false`
+ // against a member who has been standing there all along would announce an arrival
+ // that did not happen (the `READY_CHECK` rule, one system down).
+ if !vm_reset && presence.0 != 0 && fed.presence[i].0 == presence.0 {
+ let event = if presence.1 {
+ "PARTY_MEMBER_ENABLE"
+ } else {
+ "PARTY_MEMBER_DISABLE"
+ };
+ script.fire_event(event, vec![ScriptValue::Str((i + 1).to_string())]);
+ }
+ fed.presence[i] = presence;
+ }
}
// ── raid1..raid40 (decision 1549) ──────────────────────────────────────────────────────
@@ -312,9 +348,8 @@ pub(super) fn feed_party(
Some(member_unit_state(
m,
group.stats.get(guid),
+ index.0.get(guid).and_then(|e| stores.get(*e).ok()),
&group,
- &index,
- &stores,
own_group.clone(),
))
}
@@ -661,25 +696,55 @@ fn raid_roster(
fn member_unit_state(
m: &GroupMemberEntry,
stats: Option<&PartyMemberStatsInfo>,
+ // The member's live descriptor, if their object is in the manager — the reference's
+ // `0x468460`, resolved by the caller. Taking the *answer* rather than the index+query pair
+ // is what makes the out-of-range leg (the whole of report B334) testable at all.
+ store: Option<&ObjectStore>,
group: &GroupState,
- index: &GuidIndex,
- stores: &Query<&ObjectStore>,
own_group: Option,
) -> UnitState {
- let mut s = match index.0.get(&m.guid).and_then(|e| stores.get(*e).ok()) {
+ let mut s = match store {
// In visibility range: the live descriptor is the truth (the server keeps it current).
Some(store) => crate::ui_unit::snapshot(store, Some(m.name.clone()), 0),
- // Out of range: the PARTY_MEMBER_STATS snapshot (vmangos only sends these to members
- // who can't see the subject — the two sources are complementary by construction).
+ // Out of range: **the roster record** — which is not only the `PARTY_MEMBER_STATS` wire
+ // any more (decision 1640). It is seeded from the member's own live descriptor at the
+ // instant their object leaves the manager (`0x5f0880`, `net::apply::group::
+ // member_deactivated`), seated with the `1/1` placeholder when they join the roster
+ // unseen (`0x4e82d0`), and patched by the wire afterwards — so this leg never reads the
+ // `0/0` report B334 is about, and there is always a record to read.
+ //
+ // The reference's own getter chain, in order (`ui/scratch/party-oor-stats-and-portrait-
+ // law.md` §3): live descriptor → party record → pet record → 0. The pet leg is not
+ // reachable here (a `partyN` token is a player guid; the `partypetN` tokens resolve
+ // nowhere in benilla yet), so this is the whole of it.
None => UnitState {
exists: true,
name: Some(m.name.clone()),
health: stats.and_then(|s| s.cur_hp).map_or(0, u32::from),
max_health: stats.and_then(|s| s.max_hp).map_or(0, u32::from),
level: stats.and_then(|s| s.level).map_or(0, u32::from),
- power_type: stats.and_then(|s| s.power_type).unwrap_or(0),
- power: stats.and_then(|s| s.cur_power).map_or(0, u32::from),
- max_power: stats.and_then(|s| s.max_power).map_or(0, u32::from),
+ power_type: stats.map_or(0, PartyMemberStatsInfo::shown_power_type),
+ // **Divided, like the live leg** — `UnitMana` applies the raw→display scale on its
+ // record path too (`0x517744`-`0x51775e`), which this arm did not: a warrior out of
+ // range read ten times the rage of one in range.
+ power: stats.map_or(0, PartyMemberStatsInfo::shown_power),
+ max_power: stats.map_or(0, PartyMemberStatsInfo::shown_max_power),
+ // **The record's status bits, for the two predicates the RE actually pins to it**:
+ // `UnitIsDead 0x517b5d` reads `+0x08 & 4` and `UnitIsGhost 0x517c32` reads `& 8` on
+ // the no-object leg. They matter because they are *fresher than the roster byte*: the
+ // roster only moves on a `SMSG_GROUP_LIST`, while this byte is rewritten by the
+ // descriptor snapshot at the despawn edge and by every stats delta after it — so a
+ // member who was dead when they walked over the hill reads dead, where the last
+ // roster echo still had them alive.
+ //
+ // Connected / AFK / DND / PvP / FFA deliberately stay the roster's below: wow-re's §3
+ // table carves the no-object path for the health, power, level and dead/ghost/
+ // connected getters, and says nothing about `UnitIsAFK` and kin. Taking the record
+ // for those would be a guess, and vmangos only flags the status byte on the AFK/DND/
+ // PvP/FFA toggles anyway — never on death, which is exactly why the two above are the
+ // pair worth reading.
+ dead: stats.is_some_and(|s| s.status.unwrap_or(0) & member_status::DEAD != 0),
+ ghost: stats.is_some_and(|s| s.status.unwrap_or(0) & member_status::GHOST != 0),
..Default::default()
},
};
@@ -1364,6 +1429,107 @@ pub(crate) fn synthetic_raid(
mod tests {
use super::*;
+ /// **The out-of-range party frame reads the roster record** (decision 1640, report B334) —
+ /// the reference's `UnitHealth`/`UnitMana`/`UnitLevel` no-object leg, `0x496400` into
+ /// `0xbc70b0 + slot·0x148`.
+ ///
+ /// Two things this pins, and both were wrong before it. The bars come off the record at all
+ /// (they used to read the wire-only snapshot and map an absent field to `0`, which is the
+ /// blanked frame Goudy reported); and the power pair is **divided** by the raw→display scale
+ /// on this leg exactly as on the live one — a warrior's rage rides the wire ×10, so without
+ /// the divide an out-of-range warrior reads ten times an in-range one.
+ #[test]
+ fn an_out_of_range_member_reads_the_record_and_divides_its_rage() {
+ let m = GroupMemberEntry {
+ name: "Thalyn".into(),
+ guid: 0x1234,
+ status: member_status::ONLINE,
+ flags: 0,
+ };
+ let record = PartyMemberStatsInfo {
+ cur_hp: Some(2400),
+ max_hp: Some(3000),
+ level: Some(41),
+ power_type: Some(1), // POWER_RAGE
+ cur_power: Some(570),
+ max_power: Some(1000),
+ ..PartyMemberStatsInfo::default()
+ };
+ let s = member_unit_state(&m, Some(&record), None, &GroupState::default(), None);
+ assert_eq!(
+ (s.health, s.max_health),
+ (2400, 3000),
+ "the bars keep their numbers"
+ );
+ assert_eq!(s.level, 41);
+ assert_eq!(
+ (s.power_type, s.power, s.max_power),
+ (1, 57, 100),
+ "rage reads 57/100, not 570/1000 — `UnitMana`'s record leg divides too (0x517744)"
+ );
+ assert!(s.exists && s.is_player && s.is_connected);
+
+ // And a member with no record at all still reads as an existing, connected player — the
+ // seat law means this cannot happen for a real roster, but the mapping must not invent
+ // numbers when it does.
+ let bare = member_unit_state(&m, None, None, &GroupState::default(), None);
+ assert_eq!((bare.health, bare.max_health, bare.power), (0, 0, 0));
+ assert!(bare.exists);
+ }
+
+ /// **The record's dead/ghost bits are read out of range** — `UnitIsDead 0x517b5d` (`+0x08 &
+ /// 4`) and `UnitIsGhost 0x517c32` (`& 8`), the two predicates wow-re's §3 table pins to the
+ /// no-object leg.
+ ///
+ /// The falsifier is the roster byte's staleness: it only moves on a `SMSG_GROUP_LIST`, and
+ /// vmangos never flags the party status byte on death (`Player.cpp`'s five setters are the
+ /// AFK/DND/PvP/FFA toggles). So a member who was dead at the moment they walked out of range
+ /// is dead in the record — written there by the despawn snapshot — and alive in the roster
+ /// echo that predates it. Read only the roster and their frame stays lit.
+ #[test]
+ fn an_out_of_range_members_dead_and_ghost_come_off_the_record() {
+ let m = GroupMemberEntry {
+ name: "Thalyn".into(),
+ guid: 0x1234,
+ // The stale roster echo: online, alive.
+ status: member_status::ONLINE,
+ flags: 0,
+ };
+ let dead = PartyMemberStatsInfo {
+ status: Some(member_status::ONLINE | member_status::DEAD),
+ ..PartyMemberStatsInfo::default()
+ };
+ let s = member_unit_state(&m, Some(&dead), None, &GroupState::default(), None);
+ assert!(
+ s.dead,
+ "the record says dead even though the roster echo does not"
+ );
+ assert!(!s.ghost);
+
+ let ghost = PartyMemberStatsInfo {
+ status: Some(member_status::ONLINE | member_status::GHOST),
+ ..PartyMemberStatsInfo::default()
+ };
+ let s = member_unit_state(&m, Some(&ghost), None, &GroupState::default(), None);
+ assert!(s.ghost);
+ assert!(!s.dead, "a released ghost is not `dead` — the 0308 §1 trio");
+
+ // The roster byte still wins when IT is the one carrying the bit (an offline member whose
+ // record was never filled): the overlay ORs, it does not replace.
+ let stale = GroupMemberEntry {
+ status: member_status::ONLINE | member_status::DEAD,
+ ..m.clone()
+ };
+ let s = member_unit_state(
+ &stale,
+ Some(&PartyMemberStatsInfo::placeholder(true)),
+ None,
+ &GroupState::default(),
+ None,
+ );
+ assert!(s.dead);
+ }
+
/// A repeat answer is an edge even when it repeats *nothing* — the rule the Raid Info button
/// hangs off, and the one that was missing (1561).
#[test]
diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs
index f70b4d08..952c086d 100644
--- a/crates/benilla-app/src/ui_script/options_tests.rs
+++ b/crates/benilla-app/src/ui_script/options_tests.rs
@@ -604,6 +604,60 @@ fn a_track_press_seats_the_thumb_and_keeps_dragging() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+/// **A slider row's readout sits on its label's line.** The row shows one setting as two
+/// `GameFontNormal` strings — the name at the left, the value at the right — and a reader takes
+/// them as a single line; ours were **3 units apart** at every UI scale (≈4 px on a maximised
+/// 1440p window, which is where B232 photographed them).
+///
+/// The cause is a nudge that leaked from the art onto the text: the readout hangs off
+/// `$parentControl`, and the control was seated at the row's `CENTER(-80, +3)` — the +3 lifting
+/// the 17-tall groove and its 32 px thumb inside the 26-tall row. Anything anchored to the
+/// control inherited a seat that exists for a texture. The nudge now lives on the two art frames
+/// it was for (`$parentGroove`, `$parentSlider`), so the bar does not move by a pixel and the
+/// number falls onto the label's line — asserted here on both halves, over every slider row the
+/// window has: a label/value line, and the groove still riding 3 units high of the row.
+#[test]
+fn a_slider_rows_readout_sits_on_its_labels_line() {
+ let mut s = harness_on(audio_harness());
+ s.run("ShowUIPanel(OptionsFrame)").unwrap();
+ for (page, rows) in [
+ ("Controls", &["RowMouseSpeed", "RowMaxCameraDistance"][..]),
+ (
+ "Audio",
+ &["RowMaster", "RowSound", "RowMusic", "RowAmbience"][..],
+ ),
+ ("Graphics", &["RowUiScale", "RowFarclip"][..]),
+ ] {
+ s.run(&format!("OptionsFrameCategoryListRow{page}:Click()"))
+ .unwrap();
+ s.resolve(); // seat the rects before reading them
+ let mid = |frame: &str| -> f32 {
+ let top: f32 = s.eval(&format!("return {frame}:GetTop()")).unwrap();
+ let bottom: f32 = s.eval(&format!("return {frame}:GetBottom()")).unwrap();
+ (top + bottom) * 0.5
+ };
+ for row in rows {
+ let base = format!("OptionsFrameContainerBody{page}{row}");
+ // The two strings are the assertion: same font, same row, one line. (Both are
+ // regions, so both rects arrive in the same space — the comparison needs no scale.)
+ let (label, value) = (
+ mid(&format!("{base}Label")),
+ mid(&format!("{base}ControlValue")),
+ );
+ assert!(
+ (label - value).abs() < 0.01,
+ "{base}: the readout sits {:.2} off its label's line",
+ value - label
+ );
+ // …and the art it used to ride is exactly where it was: 3 units high of the row.
+ assert!(
+ (mid(&format!("{base}ControlGroove")) - mid(&base) - 3.0).abs() < 0.01,
+ "{base}: the groove left its seat"
+ );
+ }
+ }
+}
+
/// The Audio harness: the real registered CVar set on the table before the XML loads, exactly
/// the app's boot order (register → seed → load → select).
fn audio_harness() -> UiScript {
@@ -1337,6 +1391,89 @@ fn the_ui_scale_slider_defers_to_the_apply_button() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+/// **Render Scale** (decision 1639) — benilla's own Graphics row, seated directly above
+/// Multisampling because above 100 % it *is* antialiasing, on a client whose `gxMultisample`
+/// ships off (1629).
+///
+/// Three things this pins, each of which was a real choice:
+///
+/// - **It reads the CVar and shows a percentage.** 1.0 must render as "100%", because the whole
+/// point of the row is that the number in front of the player means something without a manual.
+/// - **It is DEFERRED.** Committing live would rebuild the world's render target on every drag
+/// tick — tens of megabytes, thirty times across one sweep of the handle. Staging to Apply makes
+/// that exactly one rebuild. (`uiScale` above it defers for a different reason; the flag is the
+/// same.)
+/// - **It is not mute.** Every other tooltip on this window resolves an `OPTION_TOOLTIP_*` out of
+/// 1.12's GlobalStrings, and a benilla row with no counterpart there has so far gone silent (the
+/// nameplate pair). This one carries a description under a `BENILLA_` prefix — the reference's
+/// namespace stays the reference's — because the dial's first reviewer said outright that they
+/// could not tell what it did.
+#[test]
+fn the_render_scale_row_shows_a_percentage_and_defers_to_apply() {
+ let mut s = audio_harness();
+ s.set_cvar_host("renderScale", "1");
+ let mut s = harness_on(s);
+ s.run("ShowUIPanel(OptionsFrame)").unwrap();
+ s.run("OptionsFrameCategoryListRowGraphics:Click()")
+ .unwrap();
+
+ assert_eq!(
+ s.eval::("return OptionsFrameContainerBodyGraphicsRowRenderScaleLabel:GetText()")
+ .unwrap(),
+ "Render Scale"
+ );
+ assert_eq!(
+ s.eval::(
+ "return OptionsFrameContainerBodyGraphicsRowRenderScaleControlValue:GetText()"
+ )
+ .unwrap(),
+ "100%",
+ "off has to read as 100%, not as 1"
+ );
+ // The player's range is 50–200 %, narrower than the CVar's own clamp (0.25–4.0, which leaves
+ // room for the supersampling instrument). A row that offered the whole clamp would put 400 %
+ // in front of someone who only wanted their frame rate back.
+ assert!(s
+ .eval::(
+ "local lo, hi = OptionsFrameContainerBodyGraphicsRowRenderScaleControlSlider:GetMinMaxValues() return math.abs(lo - 0.5) < 0.0001 and math.abs(hi - 2.0) < 0.0001"
+ )
+ .unwrap());
+ // Not mute: the row resolves a description, unlike the other benilla-own rows.
+ assert!(s
+ .eval::("return BENILLA_TOOLTIP_RENDER_SCALE ~= nil")
+ .unwrap());
+ let _ = s.take_cvar_changes();
+
+ // The move stages and shows Apply; it does NOT rebuild the render target.
+ s.run("OptionsFrameContainerBodyGraphicsRowRenderScaleControlSlider:SetValue(0.75)")
+ .unwrap();
+ assert_eq!(
+ s.eval::(
+ "return OptionsFrameContainerBodyGraphicsRowRenderScaleControlValue:GetText()"
+ )
+ .unwrap(),
+ "75%"
+ );
+ assert!(
+ s.take_cvar_changes().is_empty(),
+ "a deferred row must not write the CVar on the move"
+ );
+ assert!(s
+ .eval::("return OptionsFrameApplyButton:IsVisible()")
+ .unwrap());
+
+ // Apply commits once.
+ s.run("OptionsFrameApplyButton:Click()").unwrap();
+ assert_eq!(
+ s.take_cvar_changes(),
+ vec![("renderScale".to_string(), "0.75".to_string())]
+ );
+ assert!(!s
+ .eval::("return OptionsFrameApplyButton:IsVisible()")
+ .unwrap());
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
+
/// Pending edits are PANEL-wide like era's modified table: they survive a category switch
/// (the row redisplays the pending value on return, era's GetValue-returns-pending) and die
/// only when the window hides — the reopened window reads the committed truth.
@@ -1996,6 +2133,23 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
untipped.push(row.to_string());
continue;
}
+ // The one deliberate exception (decision 1639). Render Scale has no 1.12 counterpart
+ // and so no `OPTION_TOOLTIP_*` to resolve, but it is the row that most needs a
+ // description — its first reviewer said outright they could not tell what it did. It
+ // carries one under a `BENILLA_` prefix so the reference's namespace stays the
+ // reference's, which is exactly what this guard is here to protect. Everything the
+ // guard was built to catch — an invented or typo'd `OPTION_TOOLTIP_` key that silently
+ // resolves to nothing — is untouched by the carve-out.
+ if key == "BENILLA_TOOLTIP_RENDER_SCALE" {
+ assert_eq!(
+ row, "GraphicsRowRenderScale",
+ "{row}: not this row's string"
+ );
+ let text: String = s.eval(&format!("return {key}")).unwrap();
+ assert!(!text.is_empty(), "{row}: {key} resolves to nothing");
+ checked += 1;
+ continue;
+ }
assert!(
key.starts_with("OPTION_TOOLTIP_"),
"{row}: {key} is not a 1.12 option-tooltip key"
@@ -2024,7 +2178,9 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// bar as the other two. Camera Following Style is counted on the key it wears at rest (Smart's
// OPTION_TOOLTIP_CAMERA1) and Show When on its own (Always's OPTION_TOOLTIP_TARGETOFTARGET5);
// their other entries ride the same census as the selection moves.
- assert_eq!(checked, 55, "every tipped row carries a live 1.12 key");
+ // 55 of the 56 are 1.12's own; the 56th is Render Scale, whose description is benilla's
+ // (1639) and whose carve-out is above.
+ assert_eq!(checked, 56, "every tipped row carries a live key");
assert_eq!(
untipped,
vec![
@@ -2129,7 +2285,9 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
// (the lock 1136, Always Show
// ActionBars 1500), the Chat page's 1 (Remove Chat Hover Delay, 1589) and 6 API rows (Show
// Cloak / Show Helm, 1472; the four multibar switches, 1500).
- assert_eq!(raised, 55, "every row but Auto Loot has a 1.12 description");
+ // …plus the Graphics page's Render Scale (1639), the one row whose description is
+ // benilla's own rather than a 1.12 GlobalString — see the tooltip-key guard above.
+ assert_eq!(raised, 56, "every row but Auto Loot raises a description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs
index 47418c97..741429fb 100644
--- a/crates/benilla-app/src/video.rs
+++ b/crates/benilla-app/src/video.rs
@@ -58,7 +58,7 @@
//! takes ~1 s `nextDrawable` stalls — measured, and pinned at [`crate::capture::probe_uncap_mode`].
use bevy::prelude::*;
-use bevy::window::{MonitorSelection, PresentMode, PrimaryWindow, WindowMode};
+use bevy::window::{MonitorSelection, PresentMode, PrimaryWindow, WindowMode, WindowResolution};
/// `$WOW_NOVSYNC=1` — the session-only measurement override. It wins over the config for the run
/// and never reaches `config.toml` (registered in [`crate::cvars`]'s `env_overridden` set), so a
@@ -94,6 +94,37 @@ pub(crate) fn requested_window_size() -> Option {
Some(UVec2::new(w.parse().ok()?, h.parse().ok()?))
}
+/// `$WOW_DPI=` — **render at a player's pixel grid, not this machine's.**
+///
+/// Every session here runs on a 2× panel; nearly every text-under-scale report the channel files
+/// comes from a 1080p/1440p one at 1×. That gap is not cosmetic. The two places our text meets the
+/// grid — the raster size (`TextEngine::ppem`, `round(logical × dpi)`) and the per-block vertical
+/// snap (`ui_text::layout::snap_block_top`) — are both *quantizers*, and a quantizer's error is
+/// denominated in device pixels: at 1× the same layout rounds twice as coarsely as it does here.
+/// A defect that is half a pixel at 2× is a whole one at 1×, which is the difference between
+/// invisible and reported (B209, B231, B232 — all from 1×, all reproduced here only by forcing
+/// this).
+///
+/// The override goes on the *window*, so `Window::scale_factor()` — the one number the text engine,
+/// the vplates raster and the world backdrop all read — answers with it, and `WOW_WIN` then means
+/// physical pixels one-to-one. The image a capture writes is byte-for-byte the framebuffer that
+/// player's GPU would scan out; on this display it is simply drawn at half the size.
+///
+/// Absent, nothing changes: the window keeps whatever the display reports.
+pub(crate) fn requested_dpi() -> Option {
+ let v: f32 = std::env::var("WOW_DPI").ok()?.parse().ok()?;
+ (v.is_finite() && v > 0.0).then_some(v)
+}
+
+/// Apply [`requested_dpi`] to a window resolution — the one place the knob is spent, so the size
+/// that is asked for and the grid it is asked for on cannot drift apart.
+pub(crate) fn at_requested_dpi(res: WindowResolution) -> WindowResolution {
+ match requested_dpi() {
+ Some(dpi) => res.with_scale_factor_override(dpi),
+ None => res,
+ }
+}
+
/// The display modes benilla ships. **Two, and neither is the reference's mode-setting
/// fullscreen** — the module doc says why.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
diff --git a/crates/benilla-app/src/world_backdrop.rs b/crates/benilla-app/src/world_backdrop.rs
index dd92b67b..a1897f76 100644
--- a/crates/benilla-app/src/world_backdrop.rs
+++ b/crates/benilla-app/src/world_backdrop.rs
@@ -50,20 +50,146 @@
//! Nothing about the world lane changes: FFXGlow keeps its decode, the frame still holds exactly
//! one, and an opaque world pixel must come out byte-identical to before. That identity is the
//! regression test, the same one 0161 used.
+//!
+//! ## …and that seam turned out to be a dial: **render scale** (decision 1639)
+//!
+//! Once the world is a picture the UI paints on, the picture does not have to be the window's size.
+//! [`RenderScale`] sizes it — the world renders at `window × scale`, the quad still covers the
+//! window, and **the UI is untouched at native resolution**, because [`emit_backdrop_quad`]
+//! measures the quad in the window's LOGICAL size and nothing here can move that. That is the whole
+//! reason this is worth having rather than "run the game in a smaller window": text, icons and
+//! frame art stay exactly as sharp as they were, and only the 3D pays.
+//!
+//! **Below 1 it buys frames; above 1 it is supersampling** — and above 1 is the half this machine
+//! can measure, since `gxMultisample` defaults to off (1629) and the client therefore ships with no
+//! antialiasing of any kind. At exactly 2.0 the plain bilinear resolve *is* a 2×2 box average (the
+//! destination pixel centre lands on the corner four texels share, weighting each 0.25), so SSAA×2
+//! needs no filter of its own.
+//!
+//! **The one number that must not move is the camera's LOGICAL viewport**, and
+//! [`render_target_for`] is built around holding it fixed — [`retarget_world_camera`] says what
+//! reads it (every pick ray in the client) and why getting it wrong is B169 over again.
use bevy::asset::RenderAssetUsages;
use bevy::camera::RenderTarget;
use bevy::prelude::*;
+use bevy::render::camera::MipBias;
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat, TextureUsages};
use bevy::window::PrimaryWindow;
use crate::ui_pass::{UiQuad, UiQuads};
use benilla_world::view::WorldCamera;
+/// The **render scale** — benilla's own CVar `renderScale`, no 1.12 counterpart.
+///
+/// The era's answer to "my machine is too slow" was `gxResolution`: drop the whole backbuffer, UI
+/// and all, and in fullscreen mode-set the display to match. We ship no exclusive mode at all
+/// (decision 1627) and our UI is a separate pass over an off-screen world, so we can offer the
+/// strictly better version of that trade — **shrink the 3D, keep the interface**. It is the same
+/// knob every engine since has grown (Godot's `scaling_3d_scale`, Unity URP's `renderScale`,
+/// Unreal's `r.ScreenPercentage`), and it is the standard lever for the one machine class we have a
+/// real measurement from: the Steam Deck sitting at 94 % GPU busy (B329).
+///
+/// **Default 1.0 — off, and it has to be**, so every visual golden in the tree keeps meaning what
+/// it meant: at 1.0 [`render_target_for`] returns the window's own physical size and the window's
+/// own scale factor, unrounded and unmultiplied, which is bit-for-bit the pre-1639 lane.
+#[derive(Resource, Clone, Copy, PartialEq, Debug)]
+pub(crate) struct RenderScale(pub(crate) f32);
+
+/// The settable range of [`RenderScale`], shared by the CVar apply and the `$WOW_RENDER_SCALE` env
+/// knob so the two cannot drift.
+///
+/// Wider than a settings row would offer (a slider belongs at 50–200 %, where every engine puts
+/// it): this is the clamp that stops an absurd *value*, not the one that shapes the UI. The upper
+/// end is deliberately past 2 because supersampling is also the instrument — the only way to price
+/// a pixel on a machine whose present is railed at the display's grant (0362, and `crate::video`'s
+/// note that macOS honours neither `AutoNoVsync` nor `Immediate`).
+pub(crate) const RENDER_SCALE_RANGE: std::ops::RangeInclusive = 0.25..=4.0;
+
+/// The per-axis ceiling on the backdrop, in physical px — `wgpu::Limits::default()`'s
+/// `max_texture_dimension_2d`, which is what Bevy asks the adapter for unless `WgpuSettings` says
+/// otherwise.
+///
+/// Not a render-scale concern in origin: a window wider than this has always been a texture wgpu
+/// refuses to create, and before 1639 nothing here looked. The ceiling is applied to the *ratio*
+/// rather than to each axis (see [`render_target_for`]) so hitting it shrinks the picture instead of
+/// reshaping it.
+const MAX_RENDER_AXIS: u32 = 8192;
+
+impl Default for RenderScale {
+ /// `$WOW_RENDER_SCALE` overrides the default, **session-only** — the A/B lever, the same posture
+ /// as `$WOW_MSAA` and `$WOW_FARCLIP`: a value pinned into `config.toml` would make a
+ /// measurement sticky across relaunches.
+ fn default() -> Self {
+ let scale = std::env::var("WOW_RENDER_SCALE")
+ .ok()
+ .and_then(|v| v.parse::().ok())
+ .filter(|v| v.is_finite())
+ .map_or(1.0, |v| {
+ v.clamp(*RENDER_SCALE_RANGE.start(), *RENDER_SCALE_RANGE.end())
+ });
+ Self(scale)
+ }
+}
+
+/// The backdrop's size and the world camera's target scale factor, for a window of `px` physical
+/// pixels at `window_factor`, rendered at `scale`.
+///
+/// **Two invariants, and the second is the load-bearing one.**
+///
+/// 1. The image keeps the window's aspect, so the ratio — never an individual axis — is what gets
+/// clamped to [`MAX_RENDER_AXIS`]. Clamping the axes independently would letterbox the world
+/// inside a quad that is still the window's shape.
+/// 2. `size.x / factor` — the camera's LOGICAL viewport width — comes out the window's logical
+/// width, whatever `scale` is. That is why the factor is derived from the size actually built
+/// rather than from `scale`: the `round()` moves the true ratio by up to half a pixel, and it is
+/// the logical number every pick ray is denominated in (see [`retarget_world_camera`]).
+///
+/// At `scale == 1.0` both lines are exact identities in IEEE — `px × 1.0` is `px`, `size.x / px.x`
+/// is `1.0`, `window_factor × 1.0` is `window_factor` — so the pre-1639 numbers come back
+/// bit-for-bit and no golden can move. A test welds that.
+fn render_target_for(px: UVec2, window_factor: f32, scale: f32) -> (UVec2, f32) {
+ let px = px.max(UVec2::ONE);
+ let ceiling = |axis: u32| MAX_RENDER_AXIS as f32 / axis as f32;
+ let scale = scale
+ .clamp(*RENDER_SCALE_RANGE.start(), *RENDER_SCALE_RANGE.end())
+ .min(ceiling(px.x))
+ .min(ceiling(px.y));
+ let size = (px.as_vec2() * scale)
+ .round()
+ .as_uvec2()
+ .clamp(UVec2::ONE, UVec2::splat(MAX_RENDER_AXIS));
+ (size, window_factor * size.x as f32 / px.x as f32)
+}
+
+/// The texture LOD bias a render at `effective` scale owes its mipmapped world textures.
+///
+/// Rendering smaller doubles every screen-space derivative, so every sampler picks a **coarser**
+/// mip — and that blur then gets stretched back up by the resolve, on top of the resolution loss.
+/// Every engine that ships a render scale compensates with `log2(scale)`, and every one of them has
+/// to do it in the shader, because WebGPU dropped the sampler's LOD bias: `wgpu::SamplerDescriptor`
+/// (27.0.1) carries `lod_min_clamp`, `lod_max_clamp` and `anisotropy_clamp`, and nothing else.
+///
+/// **Clamped at 0 — downscaling is compensated, upscaling is left alone.** Above 1.0 the smaller
+/// derivatives already pick a *sharper* mip, and that is not an artefact to correct: it is exactly
+/// what makes supersampling work, since the resolve then averages detail that a native-resolution
+/// frame could never have resolved. Feeding it `+log2(scale)` would hand back the blurrier mip and
+/// throw the win away.
+///
+/// Bevy carries the plumbing already: [`MipBias`] on the camera is extracted into the view uniform
+/// (`View::mip_bias`, defaulting 0.0 when absent), and `pbr_input_from_standard_material` applies it
+/// to every sample — so the whole M2/WMO/creature/doodad lane gets this for free and only our own
+/// hand-written samplers had to be taught (`terrain.wgsl`, `static_gx.wgsl`, `liquid.wgsl`, and the
+/// coverage re-sample in `wow_model.wgsl`).
+fn mip_bias(effective: f32) -> f32 {
+ effective.log2().min(0.0)
+}
+
/// The off-screen image the world camera renders into, and which the UI pass draws first.
///
-/// Sized in **physical** pixels to match the swapchain 1:1 — the backdrop quad covers the window
-/// exactly, so the sample is an identity resample and opaque world pixels survive untouched.
+/// Sized in physical pixels **× [`RenderScale`]**; at the default 1.0 that matches the swapchain 1:1
+/// and the UI pass's sample is an identity resample, which is the property the composite lane was
+/// built on and the reason the scale ships off.
#[derive(Resource)]
pub(crate) struct WorldBackdrop {
pub(crate) image: Handle,
@@ -75,6 +201,15 @@ pub(crate) struct WorldBackdrop {
scale_factor: f32,
}
+impl WorldBackdrop {
+ /// The size the world is actually being rendered at, in physical px — the window's size times
+ /// [`RenderScale`], after the rounding and the axis ceiling. The number an FPS probe has to
+ /// print beside its frame time, since nothing else in the line can imply it.
+ pub(crate) fn render_size(&self) -> UVec2 {
+ self.size
+ }
+}
+
/// A fresh backdrop image at `size` physical px. See the module doc for the format's two halves.
fn new_backdrop_image(size: UVec2) -> Image {
let mut image = Image::new_fill(
@@ -104,11 +239,15 @@ fn setup_backdrop(
mut commands: Commands,
mut images: ResMut>,
windows: Query<&Window, With>,
+ scale: Res,
) {
- let size = windows
- .single()
- .map(window_physical_size)
- .unwrap_or(UVec2::new(1280, 720));
+ let (size, _) = windows.single().map_or((UVec2::new(1280, 720), 1.0), |w| {
+ render_target_for(
+ window_physical_size(w),
+ w.resolution.scale_factor(),
+ scale.0,
+ )
+ });
let image = images.add(new_backdrop_image(size));
commands.insert_resource(WorldBackdrop {
image,
@@ -119,18 +258,27 @@ fn setup_backdrop(
});
}
-/// Keep the backdrop the window's size. A stale-sized backdrop would still *work* (the quad covers
-/// the window either way) but the sample would stop being 1:1 and opaque world pixels would start
-/// moving under a resample — the one property this whole lane is built to preserve.
-fn track_window_size(
+/// Keep the backdrop at `window × `[`RenderScale`]. A stale-sized backdrop would still *work* (the
+/// quad covers the window either way) — what it would break is the pairing: the size and the
+/// factor [`retarget_world_camera`] stamps are two halves of one number, and a size that moved
+/// without the factor following it is exactly the B169 defect with a different constant.
+///
+/// Runs before the stamp (the plugin's `.chain()`), so the factor is always computed against the
+/// image that now exists.
+fn track_render_size(
mut backdrop: ResMut,
mut images: ResMut>,
windows: Query<&Window, With>,
+ scale: Res,
) {
let Ok(window) = windows.single() else {
return;
};
- let size = window_physical_size(window);
+ let (size, _) = render_target_for(
+ window_physical_size(window),
+ window.resolution.scale_factor(),
+ scale.0,
+ );
if size == backdrop.size {
return;
}
@@ -138,6 +286,17 @@ fn track_window_size(
if let Some(image) = images.get_mut(&handle) {
*image = new_backdrop_image(size);
backdrop.size = size;
+ // Said out loud on every change, because a measurement taken at the wrong scale looks
+ // exactly like a measurement: the pixels the GPU is actually being asked for are the one
+ // term no probe line could infer from the window.
+ info!(
+ "render scale {:.3}: world renders at {}x{} into a {}x{} window",
+ scale.0,
+ size.x,
+ size.y,
+ window.physical_width(),
+ window.physical_height()
+ );
}
}
@@ -168,28 +327,47 @@ fn track_window_size(
/// conversion at each of the eight call sites.
///
/// Re-stamped when the factor changes, not only on `Added`: dragging the window between a Retina
-/// and a non-Retina display changes it mid-session.
+/// and a non-Retina display changes it mid-session — and, since 1639, so does moving
+/// [`RenderScale`], which multiplies into exactly the same number.
+///
+/// **Render scale rides here rather than anywhere else precisely because of the paragraph above.**
+/// The camera's logical viewport is `image_size / scale_factor`; scaling both by the same ratio
+/// leaves it — and therefore the projection (`camera_system` feeds `logical_viewport_size` into
+/// `Projection::update`) and every pick ray (`viewport_to_ndc` and `world_to_viewport_core` read
+/// `logical_viewport_rect()` and nothing else) — **arithmetically unchanged**. Picking is not
+/// "still correct after render scale"; it cannot see render scale at all.
+///
+/// The camera also carries the [`MipBias`] that a scaled render owes its textures — see
+/// [`mip_bias`]. Absent, Bevy's view uniform reads 0.0, so the component is inserted every stamp
+/// rather than only when non-zero: a camera left holding last scale's bias is the same class of
+/// stale-pairing bug as a stale factor.
fn retarget_world_camera(
+ mut commands: Commands,
mut backdrop: ResMut,
windows: Query<&Window, With>,
added: Query<(), Added>,
- mut cameras: Query<&mut RenderTarget, With>,
+ mut cameras: Query<(Entity, &mut RenderTarget), With>,
) {
- let scale_factor = windows
- .single()
- .map_or(1.0, |w| w.resolution.scale_factor());
+ let (px, window_factor) = windows.single().map_or((UVec2::ONE, 1.0), |w| {
+ (window_physical_size(w), w.resolution.scale_factor())
+ });
+ // The image's real size, not the requested one: `track_render_size` ran first and may have
+ // clamped, and the factor must describe the texture that exists.
+ let scale_factor = window_factor * backdrop.size.x as f32 / px.x.max(1) as f32;
// `!=` on the stored NAN seed is true, so the first run always stamps.
#[allow(clippy::float_cmp)]
- let moved_display = scale_factor != backdrop.scale_factor;
- if !moved_display && added.is_empty() {
+ let moved = scale_factor != backdrop.scale_factor;
+ if !moved && added.is_empty() {
return;
}
backdrop.scale_factor = scale_factor;
- for mut current in &mut cameras {
+ let bias = MipBias(mip_bias(scale_factor / window_factor));
+ for (entity, mut current) in &mut cameras {
*current = RenderTarget::Image(bevy::camera::ImageRenderTarget {
handle: backdrop.image.clone(),
scale_factor,
});
+ commands.entity(entity).insert(bias.clone());
}
}
@@ -230,12 +408,14 @@ pub(crate) struct WorldBackdropPlugin;
impl Plugin for WorldBackdropPlugin {
fn build(&self, app: &mut App) {
- app.add_systems(Startup, setup_backdrop).add_systems(
- Update,
- (track_window_size, retarget_world_camera, emit_backdrop_quad)
- .chain()
- .before(crate::ui_pass::UiQuadAppend),
- );
+ app.init_resource::()
+ .add_systems(Startup, setup_backdrop)
+ .add_systems(
+ Update,
+ (track_render_size, retarget_world_camera, emit_backdrop_quad)
+ .chain()
+ .before(crate::ui_pass::UiQuadAppend),
+ );
}
}
@@ -296,8 +476,12 @@ mod tests {
w
};
app.world_mut().spawn((window(2.0), PrimaryWindow));
- app.add_systems(Startup, setup_backdrop)
- .add_systems(Update, retarget_world_camera);
+ app.init_resource::()
+ .add_systems(Startup, setup_backdrop)
+ // Chained exactly as the plugin chains them: since 1639 the stamp reads the size the
+ // resize pass settled on, so a test that ran the stamp alone would be testing a pairing
+ // that never happens.
+ .add_systems(Update, (track_render_size, retarget_world_camera).chain());
let cam = app
.world_mut()
.spawn((WorldCamera, RenderTarget::default()))
@@ -341,4 +525,131 @@ mod tests {
TextureFormat::Rgba16Float.pixel_size().ok()
);
}
+
+ /// **Scale 1.0 is bit-for-bit the pre-1639 lane** — the property every visual golden in the
+ /// tree rests on. Not "within a pixel": the same `UVec2` and the same `f32`, because
+ /// `px × 1.0`, `size.x / px.x` and `f × 1.0` are all exact in IEEE and a rounding introduced
+ /// here would move the whole world by a sub-pixel resample.
+ #[test]
+ fn scale_one_reproduces_the_windows_own_numbers_exactly() {
+ for px in [
+ UVec2::new(1280, 720),
+ UVec2::new(3200, 1800),
+ UVec2::new(1601, 901), // odd on both axes — the case a `/ 2` would round
+ ] {
+ for sf in [1.0, 1.5, 2.0] {
+ assert_eq!(render_target_for(px, sf, 1.0), (px, sf), "{px} at {sf}×");
+ }
+ }
+ }
+
+ /// **The camera's logical viewport does not move** — the whole design, and the invariant that
+ /// makes render scale invisible to picking. `logical = image_size / scale_factor`
+ /// (`bevy_camera`'s `to_logical`), and that is what `viewport_to_ndc` and
+ /// `world_to_viewport_core` read; if it drifts, B169 comes back at the drift's magnitude.
+ ///
+ /// Sub-pixel is the tolerance because the image is integer: the ratio is derived back out of
+ /// the size that was actually built, so x is exact and y carries at most one `round()`.
+ #[test]
+ fn every_scale_keeps_the_logical_viewport_the_windows_own() {
+ for px in [UVec2::new(1280, 720), UVec2::new(3200, 1800)] {
+ for sf in [1.0, 2.0] {
+ let want = px.as_vec2() / sf;
+ for scale in [0.25, 0.5, 0.6667, 0.75, 1.0, 1.25, 2.0, 4.0] {
+ let (size, factor) = render_target_for(px, sf, scale);
+ let logical = size.as_vec2() / factor;
+ assert!(
+ (logical.x - want.x).abs() < 0.001,
+ "{px} at {sf}× scaled {scale}: logical width {logical:?}, want {want:?}"
+ );
+ assert!(
+ (logical.y - want.y).abs() < 1.0,
+ "{px} at {sf}× scaled {scale}: logical height {logical:?}, want {want:?}"
+ );
+ }
+ }
+ }
+ }
+
+ /// The axis ceiling clamps the **ratio**, so the picture keeps the window's aspect instead of
+ /// being letterboxed inside a quad that is still the window's shape. It binds on an oversized
+ /// window at scale 1.0 too — a >8192 px window was a texture wgpu refuses to create long before
+ /// this dial existed, and nothing here used to look.
+ #[test]
+ fn the_axis_ceiling_shrinks_the_picture_it_does_not_reshape_it() {
+ let px = UVec2::new(3840, 2160);
+ let (size, _) = render_target_for(px, 1.0, 4.0);
+ assert_eq!(size.x, MAX_RENDER_AXIS);
+ let aspect = |v: UVec2| v.x as f32 / v.y as f32;
+ assert!(
+ (aspect(size) - aspect(px)).abs() < 0.001,
+ "aspect kept: {size}"
+ );
+ // …and the clamp is not render scale's alone.
+ let huge = UVec2::new(10240, 4320);
+ let (size, _) = render_target_for(huge, 1.0, 1.0);
+ assert!(
+ size.x <= MAX_RENDER_AXIS && size.y <= MAX_RENDER_AXIS,
+ "{size}"
+ );
+ assert!(
+ (aspect(size) - aspect(huge)).abs() < 0.001,
+ "aspect kept: {size}"
+ );
+ }
+
+ /// **The bias compensates downscaling and leaves supersampling alone.** A negative bias where a
+ /// scale is > 1 would hand back the coarser mip and throw away the only thing SSAA buys; a
+ /// missing one where it is < 1 is the double blur every engine's render scale corrects.
+ #[test]
+ fn the_mip_bias_is_log2_below_one_and_nothing_above_it() {
+ assert!((mip_bias(0.5) - -1.0).abs() < 1e-6);
+ assert!((mip_bias(0.25) - -2.0).abs() < 1e-6);
+ assert_eq!(
+ mip_bias(1.0),
+ 0.0,
+ "off must be exactly off — 0.0, not -0.0's cousin"
+ );
+ assert_eq!(mip_bias(2.0), 0.0);
+ assert_eq!(mip_bias(4.0), 0.0);
+ }
+
+ /// End to end on a live `App`: a half-scale world on a 2× display renders into a half-size
+ /// image, stamps a factor of 1.0 — `image / factor` is still the window's logical size — and
+ /// carries `MipBias(-1)`.
+ #[test]
+ fn a_half_scale_world_halves_the_image_the_factor_and_the_mip() {
+ let mut app = App::new();
+ app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default()))
+ .init_asset::();
+ let mut w = Window::default();
+ w.resolution.set_scale_factor(2.0);
+ let px = window_physical_size(&w);
+ app.world_mut().spawn((w, PrimaryWindow));
+ app.insert_resource(RenderScale(0.5))
+ .add_systems(Startup, setup_backdrop)
+ .add_systems(Update, (track_render_size, retarget_world_camera).chain());
+ let cam = app
+ .world_mut()
+ .spawn((WorldCamera, RenderTarget::default()))
+ .id();
+ app.update();
+
+ let backdrop = app.world().resource::();
+ assert_eq!(backdrop.size, UVec2::new(px.x / 2, px.y / 2));
+ match app.world().entity(cam).get::() {
+ Some(RenderTarget::Image(t)) => assert!(
+ (t.scale_factor - 1.0).abs() < 1e-6,
+ "half the pixels at half the factor is the same logical viewport: {}",
+ t.scale_factor
+ ),
+ _ => panic!("the world camera must target the backdrop image"),
+ }
+ let bias = app.world().entity(cam).get::().expect("a bias");
+ assert!(
+ (bias.0 - -1.0).abs() < 1e-6,
+ "half scale owes one mip level: {}",
+ bias.0
+ );
+ }
}
diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl
index 08f49941..b9583394 100644
--- a/crates/benilla-assets/src/shaders/liquid.wgsl
+++ b/crates/benilla-assets/src/shaders/liquid.wgsl
@@ -287,7 +287,14 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 {
// Animated frame. For water/ocean this is the DETAIL ripple (RGB ≈ near-black, ALPHA = ripple);
// for magma/slime it is the OPAQUE BODY texture.
- let detail = textureSample(frames, frames_samp, apply_scroll(in.uv), frame_layer());
+ // `view.mip_bias`: the render-scale LOD compensation (1639), 0.0 at native and above.
+ let detail = textureSampleBias(
+ frames,
+ frames_samp,
+ apply_scroll(in.uv),
+ frame_layer(),
+ view.mip_bias,
+ );
// Magma / slime (kind.x > 0.5): the animated texture IS the opaque body colour — no depth swatch
// (the ADT liquid vertex format carries no colour element at all, and the WMO one is a hard
diff --git a/crates/benilla-assets/src/shaders/terrain.wgsl b/crates/benilla-assets/src/shaders/terrain.wgsl
index bc7c9bfc..e5ce32c2 100644
--- a/crates/benilla-assets/src/shaders/terrain.wgsl
+++ b/crates/benilla-assets/src/shaders/terrain.wgsl
@@ -245,10 +245,15 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 {
let tiled = in.uv * t.params.x;
// Full RGBA: `.rgb` = diffuse, `.a` = the `_s` texture's per-texel SHEEN MASK (see assets.rs /
// terrain.rs — the base `.blp` has no alpha, so we load the `_s` variant; matte where no mask).
- let s0 = textureSample(layer_array, splat_samp, tiled, li.x);
- let s1 = textureSample(layer_array, splat_samp, tiled, li.y);
- let s2 = textureSample(layer_array, splat_samp, tiled, li.z);
- let s3 = textureSample(layer_array, splat_samp, tiled, li.w);
+ // `view.mip_bias` (decision 1639): a render scale below 1 doubles every derivative and so
+ // picks a coarser mip, and that blur is then stretched back up by the resolve. The bias undoes
+ // exactly that shift and is 0.0 at native and above, so this is an identity at scale 1.
+ // Only the LAYER array takes it: `alpha_array`/`shadow_array` are one 64x64 grid per chunk,
+ // built with a single mip level (terrain.rs `array_image`), so no bias could move them.
+ let s0 = textureSampleBias(layer_array, splat_samp, tiled, li.x, view.mip_bias);
+ let s1 = textureSampleBias(layer_array, splat_samp, tiled, li.y, view.mip_bias);
+ let s2 = textureSampleBias(layer_array, splat_samp, tiled, li.z, view.mip_bias);
+ let s3 = textureSampleBias(layer_array, splat_samp, tiled, li.w, view.mip_bias);
// Alpha + shadow maps are one 64² grid per chunk in 0..1 (NOT tiled), yet they share the layer
// textures' Repeat sampler. The old "repeat == clamp here" assumption is FALSE under LINEAR
// filtering: at a chunk edge (uv→0/1) the bilinear footprint WRAPS to the chunk map's opposite
diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl
index d845e804..e44deaf6 100644
--- a/crates/benilla-assets/src/shaders/wow_model.wgsl
+++ b/crates/benilla-assets/src/shaders/wow_model.wgsl
@@ -665,10 +665,14 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
#ifdef VERTEX_COLORS
if (m.model_flags.x > 0.5) {
#ifdef VERTEX_UVS_A
- base_color.a = textureSample(
+ base_color.a = textureSampleBias(
pbr_bindings::base_color_texture,
pbr_bindings::base_color_sampler,
vo.uv,
+ // The same LOD `pbr_input_from_standard_material` just sampled the colour at — it
+ // applies `view.mip_bias` itself (1639), and coverage read from a different mip than
+ // the colour is a cutout that erodes out of step with the art it masks.
+ view.mip_bias,
).a;
#else
base_color.a = 1.0;
diff --git a/crates/benilla-protocol/src/messages/group.rs b/crates/benilla-protocol/src/messages/group.rs
index 977f0f81..da659663 100644
--- a/crates/benilla-protocol/src/messages/group.rs
+++ b/crates/benilla-protocol/src/messages/group.rs
@@ -10,6 +10,7 @@
use std::io::{self, Read};
+use crate::messages::update_object::power_display_scale;
use crate::wire::{
read_cstring, read_f32_le, read_packed_guid, read_u16_le, read_u32_le, read_u64_le, read_u8,
};
@@ -210,6 +211,99 @@ pub struct PartyMemberStatsInfo {
pub pet_auras_negative: Option>,
}
+impl PartyMemberStatsInfo {
+ /// **The `1/1` placeholder** — the record a member *new to the roster* starts life with
+ /// (VERIFIED wow-re `ui/scratch/party-oor-stats-and-portrait-law.md` §2.2: the GROUP_LIST slot
+ /// writer `0x4e82d0` zero-fills the record and then stores `1` into `+0x0a`/`+0x0c`/`+0x0e`/
+ /// `+0x10` at `0x4e833d`-`0x4e8357`). It is what an out-of-range member you have never seen
+ /// shows until their first stats packet lands: a *full* bar, not an empty one. `status` carries
+ /// the roster's own online bit, which is the one field the writer takes from its caller.
+ pub fn placeholder(online: bool) -> Self {
+ Self {
+ status: Some(u8::from(online)),
+ cur_hp: Some(1),
+ max_hp: Some(1),
+ cur_power: Some(1),
+ max_power: Some(1),
+ ..Self::default()
+ }
+ }
+
+ /// **The live-descriptor snapshot** — `0x5f0880`, the record's third writer beside the wire and
+ /// the slot writer (VERIFIED wow-re `object-layer/scratch/party-record-live-snapshot.md` §1).
+ /// The reference runs it at the instant a party/raid member's object leaves the object manager
+ /// (`SMSG_DESTROY_OBJECT` and the `OUT_OF_RANGE` block take the same virtual), immediately
+ /// before asking the server for the member's stats — which is why an out-of-range party frame
+ /// never reads `0/0` at the despawn edge.
+ ///
+ /// Fields, in the binary's own order: `status` (bit0 online — an object you can see belongs to
+ /// an online player, `0x5f088e c6 46 08 01`; bit3 ghost; bit1 PVP; bit4 FFA-PVP; bit2 dead),
+ /// `+0x0a` cur HP, `+0x0c` max HP, `+0x09` power type, `+0x0e`/`+0x10` the power pair,
+ /// `+0x12` level. **Raw** descriptor values, not the `Unit*` getters' display ones: the
+ /// reference copies the field words (`5f08ea 66 8b 51 40`), and the raw→display divide happens
+ /// at the *read*, in [`Self::shown_power`] — the same place the live path does it.
+ ///
+ /// **Three stated deviations** (decision 1640), each a field with no reader in benilla today:
+ /// - `zone` and `position` are **left as they were** rather than overwritten. The reference
+ /// writes the *viewer's* zone (`[0xb4e314]`) and the object's world position; ours keeps the
+ /// last wire-reported pair, which the `_FULL` answer to the request corrects within a round
+ /// trip either way. Nothing draws party dots on the minimap yet — that is the consumer whose
+ /// arrival makes the position worth plumbing.
+ /// - the 48-slot **aura** block and the **pet** block are not snapshotted: benilla feeds no
+ /// party-token aura list and resolves no `partypetN` token, so both would be write-only.
+ /// - AFK/DND (`0x40`/`0x80`) are dropped from `status`, exactly as the reference's byte is
+ /// rewritten whole — those two bits live on the roster entry, which is what the party frame
+ /// actually overlays.
+ pub fn snapshot_descriptor(&mut self, fields: &crate::messages::update_object::ObjectFields) {
+ let mut status = member_status::ONLINE;
+ if fields.player_is_ghost() {
+ status |= member_status::GHOST;
+ }
+ // `UNIT_FIELD_FLAGS` PvP bit — the same word/bit the tooltip's PvP line reads.
+ if fields.unit_flags() & 0x1000 != 0 {
+ status |= member_status::PVP;
+ }
+ // `PLAYER_FLAGS` bit 7 (`0x5f08be c1 ea 07`).
+ if fields.player_flags() & 0x80 != 0 {
+ status |= member_status::PVP_FFA;
+ }
+ // `[desc+0x40] <= 0` — the raw health, not `unit_reads_dead`: the snapshot has no
+ // dyn-flag leg, so a feigning member's record says alive (`5f08d3 85 c9; 7f 04`).
+ if fields.unit_health().unwrap_or(0) == 0 {
+ status |= member_status::DEAD;
+ }
+ self.status = Some(status);
+ // The record's fields are `u16` (`+0x0a`..`+0x12`), and so is the wire's — the reference
+ // takes the low word of each dword (`66 8b 51 40`), which is a truncation no 1.12
+ // character can reach.
+ let power_type = fields.unit_power_type();
+ self.cur_hp = Some(fields.unit_health().unwrap_or(0) as u16);
+ self.max_hp = Some(fields.unit_max_health().unwrap_or(0) as u16);
+ self.power_type = Some(power_type);
+ self.cur_power = Some(fields.unit_power(power_type).unwrap_or(0) as u16);
+ self.max_power = Some(fields.unit_max_power(power_type).unwrap_or(0) as u16);
+ self.level = Some(fields.unit_level().unwrap_or(0) as u16);
+ }
+
+ /// `UnitPowerType` on the record path — `+0x09`, `0` when the record has never carried one
+ /// (the binding's own miss value).
+ pub fn shown_power_type(&self) -> u8 {
+ self.power_type.unwrap_or(0)
+ }
+
+ /// `UnitMana` on the record path (`0x517744`-`0x51775e`) — the stored power divided by
+ /// [`power_display_scale`], exactly as the live-object leg divides. Without it an out-of-range
+ /// warrior's rage bar reads ten times an in-range one's. Miss ⇒ `0`, the binding's own.
+ pub fn shown_power(&self) -> u32 {
+ u32::from(self.cur_power.unwrap_or(0)) / power_display_scale(self.shown_power_type())
+ }
+
+ /// `UnitManaMax` on the record path (`0x5178af`), the same divide.
+ pub fn shown_max_power(&self) -> u32 {
+ u32::from(self.max_power.unwrap_or(0)) / power_display_scale(self.shown_power_type())
+ }
+}
+
/// Read the aura tail shared by [`party_member_mask::AURAS`]/`PET_AURAS` (a `u32` bit mask) and
/// `AURAS_NEGATIVE`/`PET_AURAS_NEGATIVE` (a `u16` bit mask, passed widened): one `u16` spell id per
/// set bit, ascending bit order (VERIFIED `GroupHandler.cpp:624-630` positive, `634-644`
diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs
index 7df8e016..084327b4 100644
--- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs
+++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs
@@ -819,7 +819,7 @@ impl ObjectFields {
mod player;
mod unit;
-pub use unit::OwnerFallback;
+pub use unit::{power_display_scale, OwnerFallback};
#[cfg(test)]
mod tests;
diff --git a/crates/benilla-protocol/src/messages/update_object/fields/unit.rs b/crates/benilla-protocol/src/messages/update_object/fields/unit.rs
index 1a3feee7..55ba796c 100644
--- a/crates/benilla-protocol/src/messages/update_object/fields/unit.rs
+++ b/crates/benilla-protocol/src/messages/update_object/fields/unit.rs
@@ -28,7 +28,13 @@ const STAND_STATE_DEAD: u8 = 7;
///
/// Applied by `UnitMana`/`UnitManaMax` only — the raw accessors stay raw, and the pet happiness
/// bucket thresholds are on the RAW scale, which is why both forms have to exist (decision 1034).
-fn power_display_scale(ty: u8) -> u32 {
+///
+/// **Two callers, because the getters have two sources** (decision 1640): the live descriptor
+/// legs below, and the party roster record's ([`crate::messages::PartyMemberStatsInfo::shown_power`])
+/// — `UnitMana 0x517670` divides by this on *both* its object leg (`0x51770f`) and its
+/// record leg (`0x517744`-`0x51775e`), so an out-of-range warrior's rage is not ten times
+/// an in-range one's.
+pub fn power_display_scale(ty: u8) -> u32 {
match ty {
1 => 10, // rage
4 => 1000, // happiness
diff --git a/crates/benilla-protocol/src/messages/update_object/mod.rs b/crates/benilla-protocol/src/messages/update_object/mod.rs
index bb672c71..cd2cc21c 100644
--- a/crates/benilla-protocol/src/messages/update_object/mod.rs
+++ b/crates/benilla-protocol/src/messages/update_object/mod.rs
@@ -13,10 +13,10 @@ mod fields;
mod movement;
pub use fields::{
- quest_slot_state, ObjectFields, OwnerFallback, PlayerSkillSlot, QuestLogSlot, UnitAuraSlot,
- AURA_FLAG_CANCELABLE, AURA_FLAG_EFF_INDEX_MASK, FIELD_PLAYER_SKILL_INFO_1_1,
- PLAYER_EXPLORED_ZONES_SLOTS, PLAYER_QUEST_LOG_SLOTS, PLAYER_SKILL_SLOTS,
- UNIT_AURA_POSITIVE_SLOTS, UNIT_AURA_SLOTS,
+ power_display_scale, quest_slot_state, ObjectFields, OwnerFallback, PlayerSkillSlot,
+ QuestLogSlot, UnitAuraSlot, AURA_FLAG_CANCELABLE, AURA_FLAG_EFF_INDEX_MASK,
+ FIELD_PLAYER_SKILL_INFO_1_1, PLAYER_EXPLORED_ZONES_SLOTS, PLAYER_QUEST_LOG_SLOTS,
+ PLAYER_SKILL_SLOTS, UNIT_AURA_POSITIVE_SLOTS, UNIT_AURA_SLOTS,
};
pub use movement::{CreateSpline, MovementBlock, ObjectType};
diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl
index 04a343d7..3ee7101b 100644
--- a/crates/benilla-world/src/shaders/static_gx.wgsl
+++ b/crates/benilla-world/src/shaders/static_gx.wgsl
@@ -243,9 +243,14 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 {
if (!wrap_y) {
uv_mixed.y = clamp(uv_mixed.y, inset.y, 1.0 - inset.y);
}
- let c_repeat = textureSample(tex_array, samp_repeat, in.uv, layer);
- let c_clamp = textureSample(tex_array, samp_clamp, in.uv, layer);
- let c_mixed = textureSample(tex_array, samp_repeat, uv_mixed, layer);
+ // `view.mip_bias` (decision 1639) — the render-scale LOD compensation, 0.0 at native.
+ // Carried here as well as in wow_model.wgsl because this lane draws the same statics
+ // through its own sampler rather than through `pbr_input_from_standard_material`, which
+ // applies the bias for free; a lane that skipped it would blur out of step with the
+ // entity path drawing the identical art.
+ let c_repeat = textureSampleBias(tex_array, samp_repeat, in.uv, layer, view.mip_bias);
+ let c_clamp = textureSampleBias(tex_array, samp_clamp, in.uv, layer, view.mip_bias);
+ let c_mixed = textureSampleBias(tex_array, samp_repeat, uv_mixed, layer, view.mip_bias);
if (wrap_x && wrap_y) {
base = c_repeat;
} else if (!wrap_x && !wrap_y) {
diff --git a/crates/benilla-world/src/shaders/wow_effect.wgsl b/crates/benilla-world/src/shaders/wow_effect.wgsl
index ae4de44e..781243e1 100644
--- a/crates/benilla-world/src/shaders/wow_effect.wgsl
+++ b/crates/benilla-world/src/shaders/wow_effect.wgsl
@@ -133,7 +133,8 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 {
}
// The gamma-space product — texture bytes × raw authored vertex colour, exactly what the
// reference multiplies.
- let c = textureSample(effect_texture, effect_sampler, in.uv) * in.color;
+ // `view.mip_bias`: the render-scale LOD compensation (1639), 0.0 at native and above.
+ let c = textureSampleBias(effect_texture, effect_sampler, in.uv, view.mip_bias) * in.color;
#ifdef BLEND_ALPHAKEY
// THE FIXED-FUNCTION ALPHA TEST (EGxRs id 0x08 — `glAlphaFunc(GL_GEQUAL, ref/255)` +
// `glEnable(GL_ALPHA_TEST)`). `0x70c256` computes the ref from the RAW blend mode, so an