the world shrinks without the UI, and a party member keeps their face

- `renderScale`: the world renders into its off-screen target at a fraction — or a multiple — of the window while text, icons and frame art stay native; the logical viewport comes out arithmetically unchanged, so picking cannot see the scale at all, and every hand-written sampler takes the `log2(scale)` mip bias below 100 % and none above it, where the sharper mip is what makes supersampling work — measured here at 26x the world pixels for +0.02 ms, and reachable as a Render Scale row on the Graphics page (50–200 %), staged to Apply so a dragged slider rebuilds the world target once instead of thirty times a sweep
- a party member who walks out of range keeps their numbers and their face (B334): the client asks — `CMSG_REQUEST_PARTY_MEMBER_STATS` goes out on the despawn edge and for a roster member gained unseen, the same two edges the reference sends from — the record is snapshotted whole from the live descriptor at the moment the object deactivates, a never-seen member seats at the reference's 1/1 placeholder rather than an empty bar, and the raw-to-display divide happens at the read, so an out-of-range warrior stops reading ten times the rage; the portrait binds a guid-keyed bake cache built as a handover — the booth's own target image moves into the cache as its member streams out, so the face freezes with no copy and no flicker — and the self-review adds that a changed occupant is a re-bake and the record's dead/ghost bits are read
- a slider row's number sits on its label's line (B232): the readout hung off the control frame and inherited a +3 art nudge that exists to seat the groove texture — four pixels off on an eleven-pixel cap height at the reporter's geometry — so the nudge moves onto the two frames that draw the art, across eight rows on three pages, and the bar itself does not move a pixel; `$WOW_DPI` renders on a player's pixel grid, so a 1x reporter's screenshot and a capture from this 2x machine compare pixel-for-pixel

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-27 15:15:06 +07:00
co-authored by Claude Fable 5
parent 6b2a75e8a0
commit 72ca69ef5d
24 changed files with 1525 additions and 142 deletions
+63 -8
View File
@@ -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. -->
<Frame name="OptionsSliderRowTemplate" virtual="true" enableMouse="true">
<Size><AbsDimension y="26"/></Size>
<Layers>
@@ -1495,7 +1514,7 @@
</Frame>
<Frame name="$parentControl">
<Size><AbsDimension x="250" y="40"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="CENTER"><Offset><AbsDimension x="-80" y="3"/></Offset></Anchor></Anchors>
<Anchors><Anchor point="LEFT" relativePoint="CENTER"><Offset><AbsDimension x="-80" y="0"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="OVERLAY">
<FontString name="$parentValue" inherits="GameFontNormal">
@@ -1510,8 +1529,8 @@
<Frame name="$parentGroove">
<Size><AbsDimension y="17"/></Size>
<Anchors>
<Anchor point="LEFT"><Offset><AbsDimension x="19" y="0"/></Offset></Anchor>
<Anchor point="RIGHT"><Offset><AbsDimension x="-19" y="0"/></Offset></Anchor>
<Anchor point="LEFT"><Offset><AbsDimension x="19" y="3"/></Offset></Anchor>
<Anchor point="RIGHT"><Offset><AbsDimension x="-19" y="3"/></Offset></Anchor>
</Anchors>
<Backdrop bgFile="Interface\Buttons\UI-SliderBar-Background" edgeFile="Interface\Buttons\UI-SliderBar-Border" tile="true">
<EdgeSize><AbsValue val="8"/></EdgeSize>
@@ -1521,8 +1540,8 @@
</Frame>
<Slider name="$parentSlider" orientation="HORIZONTAL" minValue="0" maxValue="1" valueStep="0.05">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="19" y="0"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-19" y="0"/></Offset></Anchor>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="19" y="3"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-19" y="3"/></Offset></Anchor>
</Anchors>
<ThumbTexture name="$parentThumb" file="Interface\Buttons\UI-SliderBar-Button-Horizontal">
<Size><AbsDimension x="32" y="32"/></Size>
@@ -2312,11 +2331,47 @@
"OPTION_TOOLTIP_VERTICAL_SYNC")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowMultisample" inherits="OptionsDropdownRowTemplate">
<Frame name="$parentRowRenderScale" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowVerticalSync" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowVerticalSync" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- 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
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowMultisample" inherits="OptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowRenderScale" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowRenderScale" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- Multisampling: 1.12's own Video Options dropdown
-- (OptionsFrameMultiSampleDropDown, id 4), over
+26 -7
View File
@@ -885,6 +885,16 @@ fn hold_clock(mut clock: ResMut<Time<Virtual>>) {
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<benilla_world::model_render::ModelPart>>,
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<Time<bevy::time::Real>>,
mut windows: Query<&mut Window, With<bevy::window::PrimaryWindow>>,
particles: Query<&benilla_world::particles::ParticleEmitter>,
parts: Query<&ViewVisibility, With<benilla_world::model_render::ModelPart>>,
entities: Query<()>,
census: ProbeCensus,
fx_req: Option<Res<FxViewRequest>>,
wfx_req: Option<Res<waterfx::WaterFxView>>,
mut fx_state: Option<ResMut<FxViewState>>,
@@ -913,6 +921,9 @@ fn drive_capture(
mut clock: ResMut<Time<Virtual>>,
// Switched to `Automatic` at the probe's release point — see `Phase::Aging`.
mut time_strategy: ResMut<TimeUpdateStrategy>,
// `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<Res<crate::world_backdrop::WorldBackdrop>>,
) {
// 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::<f32>() / 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),
+48 -1
View File
@@ -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<CvarPersist>, 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::<ZoomLimit>()
.init_resource::<FollowConfig>()
.init_resource::<VideoConfig>()
// Literal, not Default: RenderScale::default() reads $WOW_RENDER_SCALE.
.insert_resource(RenderScale(1.0))
.init_resource::<PaneRate>()
.init_resource::<crate::ui_guild::GuildMemberNotify>()
.add_plugins(CvarPlugin);
+51 -49
View File
@@ -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
+9
View File
@@ -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 {
+28 -11
View File
@@ -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 {
+285 -2
View File
@@ -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<GroupLootInfo>,
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<u64> = 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<ClientCommand>) -> Vec<u64> {
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<GroupMemberEntry>| {
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"
);
}
}
+3
View File
@@ -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,
@@ -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,
@@ -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,
+163 -5
View File
@@ -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<u64, Handle<Image>>,
/// Insertion order, oldest first — the eviction queue for [`Self::CAP`].
order: Vec<u64>,
}
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<Handle<Image>> {
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<Image>) {
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<Image>,
baked: Option<LookKey>,
/// **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<u64>,
/// **Body panes only** — what [`sync_body_booth`] last snapshotted, in place of `baked`.
/// A body pane is a `<PlayerModel>` 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<Image>>,
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::<PortraitImages>()
.init_resource::<PortraitBakes>()
.init_resource::<PaneRate>()
.init_resource::<PaperDollBooth>()
.init_resource::<InspectBooth>()
@@ -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<String> = None;
// The guid this slot names, when the slot is one that retains a bake (decision 1640).
let mut occupant: Option<u64> = None;
let unit: Option<Entity> = 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::<usize>().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::<Image>::default();
// Distinct real handles — a 1² target is the cheapest thing `Assets` will mint.
let face = |images: &mut Assets<Image>| images.add(new_target_image(1));
let faces: Vec<Handle<Image>> =
(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(),
+179 -13
View File
@@ -29,6 +29,12 @@ pub(super) struct FedParty {
loot: Option<GroupLootInfo>,
invite: Option<String>,
units: [Option<UnitState>; 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<PartyState>,
@@ -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<String>,
) -> 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]
@@ -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::<String>("return OptionsFrameContainerBodyGraphicsRowRenderScaleLabel:GetText()")
.unwrap(),
"Render Scale"
);
assert_eq!(
s.eval::<String>(
"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::<bool>(
"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::<bool>("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::<String>(
"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::<bool>("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::<bool>("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
+32 -1
View File
@@ -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<UVec2> {
Some(UVec2::new(w.parse().ok()?, h.parse().ok()?))
}
/// `$WOW_DPI=<f32>` — **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<f32> {
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)]
+338 -27
View File
@@ -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<f32> = 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::<f32>().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<Image>,
@@ -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<Assets<Image>>,
windows: Query<&Window, With<PrimaryWindow>>,
scale: Res<RenderScale>,
) {
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<WorldBackdrop>,
mut images: ResMut<Assets<Image>>,
windows: Query<&Window, With<PrimaryWindow>>,
scale: Res<RenderScale>,
) {
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<WorldBackdrop>,
windows: Query<&Window, With<PrimaryWindow>>,
added: Query<(), Added<WorldCamera>>,
mut cameras: Query<&mut RenderTarget, With<WorldCamera>>,
mut cameras: Query<(Entity, &mut RenderTarget), With<WorldCamera>>,
) {
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::<RenderScale>()
.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::<RenderScale>()
.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::<Image>();
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::<WorldBackdrop>();
assert_eq!(backdrop.size, UVec2::new(px.x / 2, px.y / 2));
match app.world().entity(cam).get::<RenderTarget>() {
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::<MipBias>().expect("a bias");
assert!(
(bias.0 - -1.0).abs() < 1e-6,
"half scale owes one mip level: {}",
bias.0
);
}
}
@@ -287,7 +287,14 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
// 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
@@ -245,10 +245,15 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4<f32> {
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
@@ -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;
@@ -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<Vec<u16>>,
}
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`
@@ -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;
@@ -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
@@ -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};
@@ -243,9 +243,14 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
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) {
@@ -133,7 +133,8 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
}
// 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