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:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user