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