diff --git a/crates/benilla-app/src/bindings.rs b/crates/benilla-app/src/bindings.rs index e5630d90f..c76a862fa 100644 --- a/crates/benilla-app/src/bindings.rs +++ b/crates/benilla-app/src/bindings.rs @@ -574,12 +574,17 @@ pub(crate) fn sync_dispatch( }) } -/// A wheel delta in lines (notches), whatever unit the OS reported: a trackpad's `Pixel` deltas -/// go through Bevy's own conversion factor. Every wheel reader shares it. -pub(crate) fn wheel_lines(unit: MouseScrollUnit, dy: f32) -> f32 { - match unit { - MouseScrollUnit::Line => dy, - MouseScrollUnit::Pixel => dy / MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR, +/// Whether `Pixel` scroll is Wayland's axis units, Linux's one pixel path: winit reports lines on +/// X11 and on Windows. +pub(crate) const WAYLAND_PIXEL_SCROLL: bool = cfg!(target_os = "linux"); + +/// The physical pixels of a trackpad's travel that make one notch. On Wayland it is 10 axis units, +/// 10 logical pixels, as SDL, GLFW and Xwayland take one step; elsewhere Bevy's conversion factor. +pub(crate) fn pixels_per_notch(scale_factor: f32, wayland: bool) -> f32 { + if wayland { + 10.0 * scale_factor + } else { + MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR } } @@ -634,10 +639,11 @@ pub(crate) fn world_takes_wheel(dispatch: Option<&BindingDispatch>, mods: Mods, /// Whole notches out of a stream of line deltas, carrying the fraction between frames. /// -/// Deviation: a trackpad's `Pixel` deltas are carried until they make a line, and step at most -/// once per event ([`crate::ui_script::Record::Wheel`]). The reference steps on every -/// `WM_MOUSEWHEEL`, whatever its delta (`0x76c1a9`); the carry is kept because a carried trackpad -/// does not zoom or scroll at its event rate. +/// Deviation: a trackpad's `Pixel` deltas are carried until they make a line, and an event steps +/// once per whole line on Wayland and at most once elsewhere ([`Self::feed_pixels`]). The reference +/// steps on every `WM_MOUSEWHEEL`, whatever its delta (`0x76c1a9`); the carry is kept because a +/// carried trackpad does not zoom or scroll at its event rate, and Wayland takes every whole line +/// because a 10-pixel notch capped per event would lose a fast swipe's travel. /// /// For consumers that act per notch off the sign alone: the UI's `OnMouseWheel` (stock /// `ScrollFrameTemplate_OnMouseWheel` moves half a pane per call, `UIPanelTemplates.lua:150`) and @@ -662,6 +668,17 @@ impl WheelNotches { self.carry -= whole; whole as i32 } + + /// The steps a `Pixel` event of `travel` physical pixels makes. `scale_factor` is the window's + /// base scale, the one winit made the pixels physical with; `WOW_DPI`'s override is not. + pub(crate) fn feed_pixels(&mut self, travel: f32, scale_factor: f32, wayland: bool) -> i32 { + let whole = self.feed(travel / pixels_per_notch(scale_factor, wayland)); + if wayland { + whole + } else { + whole.signum() + } + } } /// The dispatch pass's head (see the module doc), ahead of the frame's records: the frame's @@ -2513,6 +2530,33 @@ mod tests { ); } + /// Wayland's notch is 10 logical pixels and an event steps for each whole one; elsewhere it is + /// Bevy's factor, whatever the scale, and an event steps at most once. + #[test] + fn pixel_travel_steps_by_the_platforms_notch() { + let mut wayland = WheelNotches::default(); + assert_eq!(wayland.feed_pixels(13.0, 1.33, true), 0, "under 10 logical"); + assert_eq!(wayland.feed_pixels(0.5, 1.33, true), 1, "the carry adds up"); + assert_eq!( + wayland.feed_pixels(-70.0, 1.33, true), + -5, + "each whole notch" + ); + + let mut elsewhere = WheelNotches::default(); + assert_eq!(elsewhere.feed_pixels(250.0, 2.0, false), 1, "at most one"); + assert_eq!( + elsewhere.feed_pixels(50.0, 2.0, false), + 1, + "the fraction carries" + ); + assert_eq!( + elsewhere.feed_pixels(99.0, 2.0, false), + 0, + "under the factor" + ); + } + /// The window deactivate (`0x514490`) clears the direction bits a Lua Start set and keeps the /// mouse pair. #[test] diff --git a/crates/benilla-app/src/portrait/glue_booth.rs b/crates/benilla-app/src/portrait/glue_booth.rs index c177ffa29..37922b074 100644 --- a/crates/benilla-app/src/portrait/glue_booth.rs +++ b/crates/benilla-app/src/portrait/glue_booth.rs @@ -307,18 +307,12 @@ pub(crate) struct CreateScene { /// The character's stage spot, scene attachment 0 in Bevy model space (`ZERO` with no scene); /// the body seats on attachment 0 at select too (`0x473039`). char_spot: Vec3, - /// The stage's authored frame, attachment 0's bone rotation and uniform scale - /// ([`stage_frame`]); identity and 1.0 with no scene. - char_facing: Quat, - char_scale: f32, /// The pet's spot, scene attachment 1 (`0x47306b` seats the secondary model there). Every /// `UI_*` scene authors exactly attachments 0 and 1, both on parentless root bones. pet_root: Entity, pet_spot: Vec3, - /// Attachment 1's bone frame, read like [`Self::char_facing`]. UI_Human's holds the same - /// −16.5° yaw and 0.97 scale as attachment 0; UI_Tauren's holds 0.7324 on the pet's bone alone. - pet_facing: Quat, - pet_stage_scale: f32, + /// The family ramp scale of the pet standing at the seat. + pet_scale: f32, /// The revision of the pet bake standing at [`Self::pet_spot`], so a re-bake happens once. pet_baked: Option, /// The fog the spawn wrote; the scene rebuilds when it changes. @@ -364,11 +358,7 @@ struct ParkedScene { file: GlueSceneFile, handle: Handle, char_spot: Vec3, - char_facing: Quat, - char_scale: f32, pet_spot: Vec3, - pet_facing: Quat, - pet_stage_scale: f32, stage: u64, clock_ms: u64, } @@ -382,12 +372,9 @@ impl CreateScene { handle: None, spawned: false, char_spot: Vec3::ZERO, - char_facing: Quat::IDENTITY, - char_scale: 1.0, pet_root, pet_spot: Vec3::ZERO, - pet_facing: Quat::IDENTITY, - pet_stage_scale: 1.0, + pet_scale: 1.0, pet_baked: None, fog: None, stage: 0, @@ -431,11 +418,7 @@ impl CreateScene { file, handle, char_spot: self.char_spot, - char_facing: self.char_facing, - char_scale: self.char_scale, pet_spot: self.pet_spot, - pet_facing: self.pet_facing, - pet_stage_scale: self.pet_stage_scale, stage: self.stage, clock_ms: self.clock_ms, }); @@ -465,11 +448,7 @@ impl CreateScene { self.file = Some(p.file); self.handle = Some(p.handle); self.char_spot = p.char_spot; - self.char_facing = p.char_facing; - self.char_scale = p.char_scale; self.pet_spot = p.pet_spot; - self.pet_facing = p.pet_facing; - self.pet_stage_scale = p.pet_stage_scale; self.stage = p.stage; self.clock_ms = p.clock_ms; self.spawned = true; @@ -493,8 +472,6 @@ impl CreateScene { self.handle = None; self.spawned = false; self.char_spot = Vec3::ZERO; - self.char_facing = Quat::IDENTITY; - self.char_scale = 1.0; clear_pet(commands, self); true } @@ -1072,14 +1049,7 @@ pub(super) fn sync_glue_scene( "glue scene: {name} resumed at {} ms on its own clock", scene.clock_ms ); - apply_yaw( - &mut commands, - booth.root, - scene.char_spot, - scene.char_facing, - scene.char_scale, - preview.yaw, - ); + apply_yaw(&mut commands, booth.root, scene.char_spot, preview.yaw); } if !scene.spawned { let Some(model) = scene.handle.as_ref().and_then(|h| m2s.get(h)) else { @@ -1090,12 +1060,6 @@ pub(super) fn sync_glue_scene( } // The scene's light, in its own buffer so the portraits' studio light is untouched. let stage = attachment_point(&model.skeleton, &model.attachments, 0).unwrap_or(Vec3::ZERO); - // The stage's frame, not just its point ([`stage_frame`]). - let (stage_facing, stage_scale) = model - .attachments - .iter() - .find(|a| a.id == 0) - .map_or((Quat::IDENTITY, 1.0), |a| stage_frame(model, a.bone)); let rig = if ghost { ghost_rig() } else { @@ -1239,39 +1203,20 @@ pub(super) fn sync_glue_scene( scene_rig.finish(&mut commands); scene.spawned = true; scene.char_spot = stage; - scene.char_facing = stage_facing; - scene.char_scale = stage_scale; - // The pet's seat: attachment 1, frame and all. + // The pet's seat: attachment 1 ([`seat_on_stage_bones`] follows its bone from here). scene.pet_spot = attachment_point(&model.skeleton, &model.attachments, 1).unwrap_or(Vec3::ZERO); - let (pet_facing, pet_stage_scale) = model - .attachments - .iter() - .find(|a| a.id == 1) - .map_or((Quat::IDENTITY, 1.0), |a| stage_frame(model, a.bone)); - scene.pet_facing = pet_facing; - scene.pet_stage_scale = pet_stage_scale; // A rebuilt scene moved the seat, so the standing pet re-seats. scene.pet_baked = None; scene.rev += 1; // the character re-lights onto the scene rig (sync_glue_booth's key) info!( - "create scene: {name} up — {} submeshes, {} camera(s), stage {:?} facing {:.2}° \ - scale {:.3}", + "create scene: {name} up — {} submeshes, {} camera(s), stage {:?}", model.submeshes.len(), model.cameras.len(), scene.char_spot, - scene.char_facing.to_euler(EulerRot::YXZ).0.to_degrees(), - scene.char_scale, ); // Seat the character at the current facing (the yaw path below runs only on a change). - apply_yaw( - &mut commands, - booth.root, - scene.char_spot, - scene.char_facing, - scene.char_scale, - preview.yaw, - ); + apply_yaw(&mut commands, booth.root, scene.char_spot, preview.yaw); } // The installed camera owns the booth camera while the scene shows, every frame: the pane's // camera of the file's table, or with none the orthographic leg (`0x76ceeb` installs no @@ -1595,16 +1540,9 @@ pub(super) fn sync_glue_booth( let Some(booth) = booths.0.get_mut(GLUE_SLOT) else { return; }; - let (spot, stage, stage_scale, scene_rev) = - scene - .as_deref() - .map_or((Vec3::ZERO, Quat::IDENTITY, 1.0, 0), |s| { - if s.spawned { - (s.char_spot, s.char_facing, s.char_scale, s.rev) - } else { - (Vec3::ZERO, Quat::IDENTITY, 1.0, s.rev) - } - }); + let (spot, scene_rev) = scene.as_deref().map_or((Vec3::ZERO, 0), |s| { + (if s.spawned { s.char_spot } else { Vec3::ZERO }, s.rev) + }); let (last_rev, last_yaw, last_scene) = state.last.unwrap_or((u64::MAX, f32::NAN, u64::MAX)); // `sync_glue_scene` runs first and commits a stage only beside its character, so a standing // stage matching the request means this bake may show ([`PendingSwap`]). @@ -1627,14 +1565,7 @@ pub(super) fn sync_glue_booth( booth.parked = false; state.last = Some((bake.revision, preview.yaw, scene_rev)); state.body = None; - apply_yaw( - &mut commands, - booth.root, - spot, - stage, - stage_scale, - preview.yaw, - ); + apply_yaw(&mut commands, booth.root, spot, preview.yaw); return; } // The rig comes from the display cache the builder gated on; if not ready, retry. @@ -1962,14 +1893,7 @@ pub(super) fn sync_glue_booth( ); } } - apply_yaw( - &mut commands, - booth.root, - spot, - stage, - stage_scale, - preview.yaw, - ); + apply_yaw(&mut commands, booth.root, spot, preview.yaw); // A yaw-only pass must not record revisions it did not commit, or a held pair never retries. state.last = Some(( if rebake { bake.revision } else { last_rev }, @@ -2204,11 +2128,11 @@ pub(super) fn sync_glue_pet( ); // `anchor` writes the pose buffer, `finish` commits it. pet_rig.finish(&mut commands); - // The seat: attachment 1's whole frame, times the family ramp scale. + // The seat at bind pose, times the family ramp scale; the bone's frame comes next + // ([`seat_on_stage_bones`]). + scene.pet_scale = pet.scale.max(0.01); commands.entity(scene.pet_root).insert( - Transform::from_translation(scene.pet_spot) - .with_rotation(scene.pet_facing) - .with_scale(Vec3::splat(pet.scale.max(0.01) * scene.pet_stage_scale)), + Transform::from_translation(scene.pet_spot).with_scale(Vec3::splat(scene.pet_scale)), ); scene.pet_baked = Some(pet.revision); info!( @@ -2234,55 +2158,61 @@ fn clear_pet(commands: &mut Commands, scene: &mut CreateScene) { scene.pet_baked = None; } -/// Seat the model root on the stage `spot` in the stage's frame, facing `yaw` (the reference's -/// `Model:SetRotation`, about the root's own origin). -fn apply_yaw( - commands: &mut Commands, - root: Entity, - spot: Vec3, - stage: Quat, - stage_scale: f32, - yaw: f32, -) { - commands.entity(root).insert( - Transform::from_translation(spot) - .with_rotation(stage * Quat::from_rotation_y(yaw)) - .with_scale(Vec3::splat(stage_scale)), - ); +/// Seat the model root on the stage `spot` at bind pose, facing `yaw` (the reference's +/// `Model:SetRotation`, about the root's own origin); [`seat_on_stage_bones`] then puts it in the +/// bone's frame. +fn apply_yaw(commands: &mut Commands, root: Entity, spot: Vec3, yaw: f32) { + commands + .entity(root) + .insert(Transform::from_translation(spot).with_rotation(Quat::from_rotation_y(yaw))); } -/// The stage's authored frame: the constant rotation and uniform scale parked on attachment -/// `bone`'s chain. The reference composes an attachment's whole frame, not only its point -/// (`0x71439b` in `0x714260`). `UI_Human` keys its stage bones with a constant −16.5° yaw to face -/// its off-axis camera; the other race scenes leave them unrotated. The scale is uniform on every -/// shipped scene, so `x` stands for it. -fn stage_frame(model: &M2Model, bone: u16) -> (Quat, f32) { - let Some(anims) = model.animations.as_ref() else { - return (Quat::IDENTITY, 1.0); +/// A child's seat under a scene bone: the bone's whole animated matrix, moved to the attachment's +/// position, the composed result of `0x7140d8`-`0x714149`. The child's own turn and scale go in +/// its frame, under the bone's. +fn stage_seat(bone: bevy::math::Affine3A, offset: Vec3, own: Transform) -> Transform { + let mut seat = bone; + seat.translation = bone.transform_point3(offset).into(); + Transform::from_matrix(Mat4::from(seat) * own.to_matrix()) +} + +/// Seats the body and the pet on attachments 0 and 1 of the standing scene every frame, from the +/// scene rig's pose: a seat's keys, a parent's and keys that move all reach them. A scene with no +/// posed rig keeps the bind-pose seats its build wrote. +pub(super) fn seat_on_stage_bones( + mut seats: Query<&mut Transform>, + scene: Option>, + booths: Res, + preview: Res, + m2s: Res>, + rigs: Query<&benilla_world::rig_anim::RigPose>, +) { + let (Some(scene), Some(booth)) = (scene, booths.0.get(GLUE_SLOT)) else { + return; }; - let mut rot = Quat::IDENTITY; - let mut scale = 1.0; - let mut idx = bone; - // Cycle-guarded by the joint count, as the pivot walk is. - for _ in 0..=model.skeleton.joints.len() { - if let Some(g) = anims.global_bones.iter().find(|g| g.bone == idx) { - // `t = 0`: these are parked keys, and no stage bone animates. - if let Some(c) = g.rotation.as_ref() { - rot = c.sample(0.0) * rot; - } - if let Some(c) = g.scale.as_ref() { - scale *= c.sample(0.0).x; - } - } - let Some(joint) = model.skeleton.joints.get(usize::from(idx)) else { - break; + if !scene.spawned { + return; + } + let Some(model) = scene.handle.as_ref().and_then(|h| m2s.get(h)) else { + return; + }; + let Ok(pose) = rigs.get(scene.root) else { + return; + }; + let body = Transform::from_rotation(Quat::from_rotation_y(preview.yaw)); + let pet = Transform::from_scale(Vec3::splat(scene.pet_scale)); + for (id, root, own) in [(0, booth.root, body), (1, scene.pet_root, pet)] { + let Some(a) = model.attachments.iter().find(|a| a.id == id) else { + continue; }; - match u16::try_from(joint.parent) { - Ok(parent) => idx = parent, - Err(_) => break, // -1 = root reached + let Some(bone) = pose.model.get(usize::from(a.bone)) else { + continue; + }; + if let Ok(mut t) = seats.get_mut(root) { + // A still seat leaves the booth's transform unchanged. + t.set_if_neq(stage_seat(*bone, a.offset, own)); } } - (rot, scale) } /// The create preview instrument: @@ -2976,6 +2906,22 @@ mod tests { } } + #[test] + fn a_seat_takes_the_bones_whole_matrix_at_the_attachment() { + // UI_CharacterSelect's seat: a half turn and 0.673 keyed on the bone, which also moves. + let half_turn = Quat::from_rotation_y(std::f32::consts::PI); + let bone = bevy::math::Affine3A::from_scale_rotation_translation( + Vec3::splat(0.5), + half_turn, + Vec3::new(1.0, 2.0, 3.0), + ); + let quarter = Quat::from_rotation_y(std::f32::consts::FRAC_PI_2); + let seat = stage_seat(bone, Vec3::X, Transform::from_rotation(quarter)); + assert!(seat.translation.abs_diff_eq(Vec3::new(0.5, 2.0, 3.0), 1e-5)); + assert!(seat.scale.abs_diff_eq(Vec3::splat(0.5), 1e-5)); + assert!(seat.rotation.dot(half_turn * quarter).abs() > 0.9999); + } + fn create(sex: u8, class: u8, skin: u8, hair_style: u8) -> GlueLook { GlueLook::Create(CreateLook { race: 1, @@ -3715,11 +3661,7 @@ mod tests { file: GlueSceneFile::new("UI_MainMenu.mdx"), handle: Handle::default(), char_spot: Vec3::ZERO, - char_facing: Quat::IDENTITY, - char_scale: 1.0, pet_spot: Vec3::ZERO, - pet_facing: Quat::IDENTITY, - pet_stage_scale: 1.0, stage: 1, clock_ms: 0, }); diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index 8b1a5212c..b35db8a53 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -828,6 +828,14 @@ impl Plugin for PortraitPlugin { // the booths' transforms and cameras are none a call reads. .after(crate::vm_pass::VmPass::End), ) + // The seats ride this frame's scene pose, as the reference animates the scene before + // the children on it (`0x714000`); the seats' builds above wrote the bind pose. + .add_systems( + PostUpdate, + glue_booth::seat_on_stage_bones + .after(benilla_world::rig_anim::RigModelPass) + .before(bevy::transform::TransformSystems::Propagate), + ) // The character panes: the VM's paint list, after its paint pass armed the turns and // the extract published the panes' geometry; before the camera gate reads the marks. .add_systems( diff --git a/crates/benilla-app/src/ui_script/glue.rs b/crates/benilla-app/src/ui_script/glue.rs index 501f01c8b..9bf6120f9 100644 --- a/crates/benilla-app/src/ui_script/glue.rs +++ b/crates/benilla-app/src/ui_script/glue.rs @@ -567,7 +567,12 @@ mod tests { called.insert(name); return false; } - if INSTALL_WARNINGS.contains(&e.as_str()) { + // HTML tag spelling may be normalised by the parser; this is the same known + // install warning for H4 and h4, not another accepted warning. + if INSTALL_WARNINGS + .iter() + .any(|warning| warning.eq_ignore_ascii_case(e)) + { return false; } true diff --git a/crates/benilla-app/src/ui_script/records.rs b/crates/benilla-app/src/ui_script/records.rs index fadfa51d1..34dd9542c 100644 --- a/crates/benilla-app/src/ui_script/records.rs +++ b/crates/benilla-app/src/ui_script/records.rs @@ -32,9 +32,9 @@ pub(crate) enum Record { /// A wheel turn, one step up (`true`) or down. The reference queues each `WM_MOUSEWHEEL`'s raw /// delta (`0x42d58c`, `0x424159`) and steps once per message on its sign alone (`setge` at /// `0x76c1a9`), so a line event is one step whatever its travel; a trackpad's `Pixel` events - /// step as their carry completes a line ([`crate::bindings::WheelNotches`]), at most once - /// each. A turn with no vertical travel is no record: `WM_MOUSEHWHEEL` takes the window - /// procedure's default arm and never reaches the client. + /// step as their carry completes lines ([`crate::bindings::WheelNotches::feed_pixels`]). A + /// turn with no vertical travel is no record: `WM_MOUSEHWHEEL` takes the window procedure's + /// default arm and never reaches the client. Wheel { up: bool, }, @@ -104,6 +104,9 @@ pub(crate) fn collect_input_records( ) { let events: Vec<&WindowEvent> = events.read().collect(); let now = window.single().ok().and_then(Window::cursor_position); + let base_scale = window + .single() + .map_or(1.0, |w| w.resolution.base_scale_factor()); let moved = events .iter() .any(|e| matches!(e, WindowEvent::CursorMoved(_) | WindowEvent::CursorLeft(_))); @@ -144,12 +147,13 @@ pub(crate) fn collect_input_records( let travel = crate::bindings::notch_travel(w.x, w.y, mods.shift); let step = match w.unit { MouseScrollUnit::Line => i32::from(travel > 0.0) - i32::from(travel < 0.0), - MouseScrollUnit::Pixel => records - .notches - .feed(crate::bindings::wheel_lines(w.unit, travel)) - .signum(), + MouseScrollUnit::Pixel => records.notches.feed_pixels( + travel, + base_scale, + crate::bindings::WAYLAND_PIXEL_SCROLL, + ), }; - if step != 0 { + for _ in 0..step.unsigned_abs() { out.push(InputRecord { record: Record::Wheel { up: step > 0 }, mods, @@ -427,7 +431,9 @@ mod tests { ], ); assert_eq!(shape(&got), ["WheelUp@Some(10.0)", "WheelUp@Some(10.0)"]); - let quarter = MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR / 4.0; + let wayland = crate::bindings::WAYLAND_PIXEL_SCROLL; + let notch = crate::bindings::pixels_per_notch(1.0, wayland); + let quarter = notch / 4.0; let got = collect(&mut w, vec![wheel(MouseScrollUnit::Pixel, quarter); 3]); assert!( got.is_empty(), @@ -440,10 +446,10 @@ mod tests { ["WheelUp@Some(10.0)"], "the fourth completes it" ); - // A pixel event carrying more than a line is still one step. - let lines = MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR * 2.5; - let got = collect(&mut w, vec![wheel(MouseScrollUnit::Pixel, -lines)]); - assert_eq!(shape(&got), ["WheelDown@Some(10.0)"]); + // A pixel event carrying more than a line: a record per step, two on Wayland, one elsewhere. + let got = collect(&mut w, vec![wheel(MouseScrollUnit::Pixel, -notch * 2.5)]); + let steps = if wayland { 2 } else { 1 }; + assert_eq!(shape(&got), vec!["WheelDown@Some(10.0)"; steps]); // The horizontal wheel is no notch: `WM_MOUSEHWHEEL` never reaches the client's wheel // walk. let sideways = WindowEvent::MouseWheel(MouseWheel { @@ -454,4 +460,17 @@ mod tests { }); assert!(collect(&mut w, vec![sideways]).is_empty()); } + + /// `Pixel` travel is physical at the OS's scale, which `WOW_DPI`'s override leaves alone. + #[test] + fn a_pixel_notch_reads_the_base_scale() { + let mut w = world(); + let mut windows = w.0.query_filtered::<&mut Window, With>(); + let mut window = windows.single_mut(&mut w.0).unwrap(); + window.resolution.set_scale_factor(2.0); + window.resolution.set_scale_factor_override(Some(1.0)); + let notch = crate::bindings::pixels_per_notch(2.0, crate::bindings::WAYLAND_PIXEL_SCROLL); + let got = collect(&mut w, vec![wheel(MouseScrollUnit::Pixel, notch)]); + assert_eq!(got.len(), 1, "one notch at the base scale"); + } } diff --git a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs index 65f66b3e8..2f8ecfaf5 100644 --- a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs +++ b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs @@ -57,6 +57,7 @@ fn the_russian_pack_loads_the_complete_interface_and_takes_variables_loaded() { let chain = benilla_formats::Chain::open_localized(&data, locale).unwrap(); let _selected = super::reference_ui::fixture::use_chain(chain); let mut s = benilla_ui::script::UiScript::new_with_locale(locale.code()).unwrap(); + s.keep_calls(); s.set_screen_size(1024.0, 768.0); s.set_unit( "player", diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs index 2645d7b93..7b65f2738 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -495,9 +495,29 @@ impl Plugin for VideoPlugin { .after(crate::cvars::sync_cvars), ), ); + #[cfg(windows)] + app.add_message::() + .add_systems(First, drop_accessibility_adapter); } } +/// Drops the AccessKit adapter `bevy_winit` gives a new window, which restores winit's window +/// procedure. The reference's window serves no accessibility client (`WoW.exe` imports no such +/// library and names none of its calls), and benilla builds no tree for one to read. The adapter's +/// UI Automation provider is not inert where a client runs: Proton-based Wine's `tabtip.exe` queries +/// the focused window, and that traffic faults in `rpcrt4` on reused memory and damages the heap. +#[cfg_attr(not(windows), allow(dead_code))] +fn drop_accessibility_adapter( + mut created: MessageReader, + _main_thread: bevy::ecs::system::NonSendMarker, +) { + bevy::winit::accessibility::ACCESS_KIT_ADAPTERS.with_borrow_mut(|adapters| { + for created in created.read() { + adapters.remove(&created.window); + } + }); +} + /// How many `RestartGx()` calls the interface has made. A counter, not a flag: each applier keeps /// its own last value, so one bump forces one re-assertion in each, in any order. #[derive(Resource, Default, Clone, Copy, PartialEq, Eq, Debug)] diff --git a/crates/benilla-app/tests/cover_input_wall.rs b/crates/benilla-app/tests/cover_input_wall.rs index 90dd9adfd..facea2266 100644 --- a/crates/benilla-app/tests/cover_input_wall.rs +++ b/crates/benilla-app/tests/cover_input_wall.rs @@ -96,6 +96,7 @@ const VERDICTS: &[(&str, Verdict)] = &[ ("MouseScrollUnit", Plumbing), ("NativeKey", Plumbing), ("NativeKeyCode", Plumbing), + ("ACCESS_KIT_ADAPTERS", Plumbing), ("ExitCondition", Plumbing), ("Monitor", Plumbing), ("MonitorSelection", Plumbing), @@ -104,6 +105,7 @@ const VERDICTS: &[(&str, Verdict)] = &[ ("RawHandleWrapper", Plumbing), ("VideoMode", Plumbing), ("Window", Plumbing), + ("WindowCreated", Plumbing), ("WindowLevel", Plumbing), ("WindowMode", Plumbing), ("WindowPlugin", Plumbing), diff --git a/crates/benilla-app/tests/world_api_wall.rs b/crates/benilla-app/tests/world_api_wall.rs index 46c293f31..d455df106 100644 --- a/crates/benilla-app/tests/world_api_wall.rs +++ b/crates/benilla-app/tests/world_api_wall.rs @@ -152,6 +152,7 @@ const PUBLISHED: &[(&str, &str)] = &[ ("rig_anim::PosePost", "wall"), ("rig_anim::RigAnchor", "wall"), ("rig_anim::RigFrame", "wall"), + ("rig_anim::RigModelPass", "wall"), ("rig_anim::RigPose", "wall"), ("rig_anim::SceneClock", "record"), ("rig_palette::RigPalettes", "record"), diff --git a/crates/benilla-formats/src/chain.rs b/crates/benilla-formats/src/chain.rs index 04d85bbfe..ce07a1456 100644 --- a/crates/benilla-formats/src/chain.rs +++ b/crates/benilla-formats/src/chain.rs @@ -192,7 +192,7 @@ impl Chain { /// The client's chain. Deviation: `$WOW_LOCALE` or an installed `Data/ruRU` selects a /// language pack ([`crate::ClientLocale::for_install`]); without one the stock `Wow.ini` - /// locale stands. + /// locale stands, because MoonWell supports the community language pack alongside stock data. pub fn open_client(path: &Path) -> Result { match crate::ClientLocale::for_install(path)? { Some(pack) => Self::open_localized(path, pack), @@ -202,7 +202,8 @@ impl Chain { /// The client's chain read as `pack`, its locale the one a `Wow.ini` naming the pack gives /// ([`Locale::from_language_country`]). Deviation: the community ruRU pack's archives mount - /// after the base chain, from `Data/ruRU`, which 1.12.1 never reads. + /// after the base chain, from `Data/ruRU`, because that community pack keeps its localized + /// overrides there instead of in the stock chain. pub fn open_localized(path: &Path, pack: crate::ClientLocale) -> Result { let mut chain = Self::open_without(path, left_out())?; if pack == crate::ClientLocale::RuRu { diff --git a/crates/benilla-formats/src/characters/sections.rs b/crates/benilla-formats/src/characters/sections.rs index c3a20ec24..71dc3b451 100644 --- a/crates/benilla-formats/src/characters/sections.rs +++ b/crates/benilla-formats/src/characters/sections.rs @@ -326,7 +326,8 @@ impl CompositePlan { /// enlarged), blended at level 0, then the mip pyramid built by 2×2 averages. /// /// Deviation: the reference composites at 256² only; VanillaHelpers' `ResizeCharacterSkin` - /// convention scales it for high-resolution texture packs. + /// convention scales it because high-resolution texture packs would otherwise lose their + /// detail when composited onto the stock atlas. fn run_scaled(&self, mut read: impl FnMut(&str) -> Result) -> Result { let size = 256 * self.scale; let base = diff --git a/crates/benilla-formats/src/language_pack.rs b/crates/benilla-formats/src/language_pack.rs index 4767f9403..4bd88c343 100644 --- a/crates/benilla-formats/src/language_pack.rs +++ b/crates/benilla-formats/src/language_pack.rs @@ -1,6 +1,7 @@ //! The language of the client data, Lua UI and realmd challenge. //! //! Deviation: the community 1.12 ruRU pack lives in `Data/ruRU` and overlays the vanilla chain. +//! It mounts because MoonWell supports that community pack alongside the stock install. //! Its DBCs keep the 1.12 layout and put Russian strings in slot zero, like the base tables. use std::path::{Path, PathBuf}; diff --git a/crates/benilla-formats/src/locale.rs b/crates/benilla-formats/src/locale.rs index dff28419b..eec79835f 100644 --- a/crates/benilla-formats/src/locale.rs +++ b/crates/benilla-formats/src/locale.rs @@ -55,7 +55,7 @@ impl Locale { /// /// Deviation: a ruRU tag, any case, names itself. The community ruRU pack ([`crate::ClientLocale`]) /// keeps its Russian strings in column 0, the index the table's lack of ruRU leaves, but its - /// interface asks `GetLocale` for "ruRU". + /// interface asks `GetLocale` for "ruRU", because its localized UI branches on that name. pub fn name(self) -> &'static str { if self.index == 0 && self.tag.to_be_bytes().eq_ignore_ascii_case(b"ruRU") { return "ruRU"; diff --git a/crates/benilla-movie/tests/install.rs b/crates/benilla-movie/tests/install.rs index 52678c89e..49185d772 100644 --- a/crates/benilla-movie/tests/install.rs +++ b/crates/benilla-movie/tests/install.rs @@ -23,7 +23,13 @@ fn cinematic(data: &Path, name: &str) -> Option { fn open(name: &str) -> Option { let data = benilla_formats::wow_data_or_skip!(None); - let path = cinematic(&data, name).unwrap_or_else(|| panic!("{name} is not in the install")); + let Some(path) = cinematic(&data, name) else { + benilla_formats::skipped( + "no install cinematic", + &[data.join("Interface").join("Cinematics").join(name)], + ); + return None; + }; let file = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); Some(Movie::open(file).unwrap_or_else(|e| panic!("{name}: {e}"))) } diff --git a/crates/benilla-ui/src/script/char_name.rs b/crates/benilla-ui/src/script/char_name.rs index 32e1bc8ef..da87dfd4a 100644 --- a/crates/benilla-ui/src/script/char_name.rs +++ b/crates/benilla-ui/src/script/char_name.rs @@ -137,7 +137,8 @@ pub struct NameCharsets { impl NameCharsets { /// Deviation: the community ruRU pack accepts Russian names up to twelve letters, while - /// retaining the stock Latin alternative. The server remains the naming-policy authority. + /// retaining the stock Latin alternative, because its users need to enter Cyrillic character + /// names. The server remains the naming-policy authority. pub(crate) fn for_russian_pack() -> Self { Self { primary: CYRILLIC, diff --git a/crates/benilla-ui/src/script/client.rs b/crates/benilla-ui/src/script/client.rs index 90f52e966..504f72a84 100644 --- a/crates/benilla-ui/src/script/client.rs +++ b/crates/benilla-ui/src/script/client.rs @@ -376,7 +376,8 @@ impl UiScript { }; } - /// Deviation: ruRU is a community overlay over DBC column zero, outside the stock table. + /// Deviation: ruRU is a community overlay over DBC column zero, because the supported + /// community pack supplies Russian strings there rather than in a stock locale slot. pub fn set_russian_language_pack(&mut self, enabled: bool) { let mut model = self.model_mut(); model.community_ru = enabled; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 6bdde5d28..863bdc4d8 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -724,7 +724,8 @@ pub(crate) struct Model { /// The client's locale index, `[0xc0e080]` (0, enUS, until the host seats the install's): the /// name `GetLocale` answers is the table `0x8558a4` at it ([`crate::toc::LOCALES`]). pub(crate) locale: usize, - /// Deviation: the community ruRU pack reads column zero but names its own language. + /// Deviation: the community ruRU pack reads column zero but names its own language, + /// because its interface branches on `GetLocale` returning "ruRU". pub(crate) community_ru: bool, /// `Wow.ini`'s `Region` as start-up hands it to the name check (`0x402b7f` → `0x6c91b8`, /// `[0x86c9e0]`): 0 until the host seats the install's. diff --git a/crates/benilla-world/src/rig_anim.rs b/crates/benilla-world/src/rig_anim.rs index 725ad6ff1..fa9515297 100644 --- a/crates/benilla-world/src/rig_anim.rs +++ b/crates/benilla-world/src/rig_anim.rs @@ -10,7 +10,7 @@ mod global_seq; mod pose; pub(crate) use compose::seed_rig_rows; -pub use compose::{finalize_rig_worlds, PosePost}; +pub use compose::{finalize_rig_worlds, PosePost, RigModelPass}; pub use global_seq::GlobalSeqDrive; pub use pose::{RigAnchor, RigFrame, RigPose}; diff --git a/crates/benilla-world/src/rig_anim/compose.rs b/crates/benilla-world/src/rig_anim/compose.rs index 406ee0123..5a06b0905 100644 --- a/crates/benilla-world/src/rig_anim/compose.rs +++ b/crates/benilla-world/src/rig_anim/compose.rs @@ -18,6 +18,11 @@ use super::{AnimParked, RigFrame, RigPose}; #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] pub struct PosePost; +/// The model pass ([`compose_rig_models`]): a reader ordered after it and before propagation sees +/// this frame's model-space bones. +#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] +pub struct RigModelPass; + /// Fold each pose-dirty rig into model space and re-seat its anchors, leaving `pose_dirty` for /// the world pass. A parked rig is skipped; its wake unparks it before this runs. fn compose_rig_models( @@ -270,12 +275,13 @@ pub fn plugin(app: &mut App) { .after(bevy::app::AnimationSystems) .before(bevy::transform::TransformSystems::Propagate), ) - .add_systems( + .configure_sets( PostUpdate, - compose_rig_models + RigModelPass .after(PosePost) .before(bevy::transform::TransformSystems::Propagate), - ); + ) + .add_systems(PostUpdate, compose_rig_models.in_set(RigModelPass)); } #[cfg(test)] diff --git a/crates/moonwell-gfx/src/post.rs b/crates/moonwell-gfx/src/post.rs index 3bb57020f..b79e70813 100644 --- a/crates/moonwell-gfx/src/post.rs +++ b/crates/moonwell-gfx/src/post.rs @@ -12,6 +12,9 @@ //! `ao_depth.wgsl`): its far samples take the coarse levels, so a supersampled frame's samples, //! spread over many texels, stay in the cache. The secondary light reads a colour pyramid beside //! it ([`GiColour`]), so its far samples' colour stays in the cache too. +//! The scattered light, secondary light and occlusion run together at half the width and height, +//! then resolve against scene depth at full size; anti-aliasing and sharpening keep full size. +//! `WOW_CAPTURE` with `MOONWELL_GFX_REFERENCE_POST=1` retains the full-size light for A/B probes. use benilla_world::view::WorldCamera; use bevy::camera::Camera3dDepthTextureUsage; @@ -284,7 +287,11 @@ fn publish( /// level 0 per depth kind, and the step to each coarser level. #[derive(Resource)] struct PostPipelines { + /// Capture-only A/B instrument: the original full-size calculations in the final pass. + reference: bool, layouts: [BindGroupLayoutDescriptor; 2], + air_layouts: [BindGroupLayoutDescriptor; 2], + air_pipelines: [CachedRenderPipelineId; 2], /// Group 1: the sun's shadow cascades, their comparison sampler and the view's lights. shadow_layout: BindGroupLayoutDescriptor, pipelines: [CachedRenderPipelineId; 2], @@ -307,6 +314,13 @@ struct AoDepth { colour: Option, } +/// Half-size scattering with depth in alpha, and secondary light with occlusion in alpha. +#[derive(Component)] +struct AirTexture { + air: TextureView, + indirect: TextureView, +} + /// The screen's colour at half its size and each level below, one fewer than the depth pyramid's: /// level k is the screen at 1/2^(k+1), so the secondary light reads a sample's colour at its /// depth's level less one. @@ -365,6 +379,39 @@ fn prepare_ao_depth( views: Query<(Entity, &ViewTarget), With>, ) { for (entity, target) in &views { + if frame + .0 + .is_some_and(|p| p.vol.x > 0.0 || p.gi.x > 0.0 || p.ao.z > 0.5) + { + let size = target.main_texture().size(); + let (air, _) = pyramid_texture( + &mut cache, + &device, + "moonwell_scattered_light", + Extent3d { + width: size.width.div_ceil(2), + height: size.height.div_ceil(2), + depth_or_array_layers: 1, + }, + 1, + ViewTarget::TEXTURE_FORMAT_HDR, + ); + let (indirect, _) = pyramid_texture( + &mut cache, + &device, + "moonwell_indirect", + Extent3d { + width: size.width.div_ceil(2), + height: size.height.div_ceil(2), + depth_or_array_layers: 1, + }, + 1, + ViewTarget::TEXTURE_FORMAT_HDR, + ); + commands.entity(entity).insert(AirTexture { air, indirect }); + } else { + commands.entity(entity).remove::(); + } if !ao_on(&frame) { commands.entity(entity).remove::(); continue; @@ -417,33 +464,52 @@ fn init_pipelines( asset_server: Res, pipeline_cache: Res, ) { + let reference = std::env::var_os("WOW_CAPTURE").is_some() + && std::env::var_os("MOONWELL_GFX_REFERENCE_POST").is_some(); let shader: Handle = asset_server.load(SHADER); - let layout = |label: &'static str, multisampled: bool| { + let layout = |label: &'static str, multisampled: bool, air: bool| { let depth = if multisampled { texture_depth_2d_multisampled() } else { texture_depth_2d() }; - BindGroupLayoutDescriptor::new( - label, - &BindGroupLayoutEntries::sequential( - ShaderStages::FRAGMENT, - ( - texture_2d(TextureSampleType::Float { filterable: true }), - sampler(SamplerBindingType::Filtering), - depth, - uniform_buffer::(true), - uniform_buffer::(false), - storage_buffer_read_only_sized(false, None), - texture_2d(TextureSampleType::Float { filterable: false }), - texture_2d(TextureSampleType::Float { filterable: true }), - ), + let mut entries = BindGroupLayoutEntries::sequential( + ShaderStages::FRAGMENT, + ( + texture_2d(TextureSampleType::Float { filterable: true }), + sampler(SamplerBindingType::Filtering), + depth, + uniform_buffer::(true), + uniform_buffer::(false), + storage_buffer_read_only_sized(false, None), + texture_2d(TextureSampleType::Float { filterable: false }), + texture_2d(TextureSampleType::Float { filterable: true }), ), ) + .to_vec(); + if !air { + for binding in [8, 9] { + entries.push(BindGroupLayoutEntry { + binding, + visibility: ShaderStages::FRAGMENT, + ty: BindingType::Texture { + sample_type: TextureSampleType::Float { filterable: true }, + view_dimension: TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }); + } + } + BindGroupLayoutDescriptor::new(label, &entries) }; let layouts = [ - layout("moonwell_post_layout", false), - layout("moonwell_post_layout_ms", true), + layout("moonwell_post_layout", false, false), + layout("moonwell_post_layout_ms", true, false), + ]; + let air_layouts = [ + layout("moonwell_air_layout", false, true), + layout("moonwell_air_layout_ms", true, true), ]; let shadow_layout = BindGroupLayoutDescriptor::new( "moonwell_post_shadow_layout", @@ -456,7 +522,7 @@ fn init_pipelines( ), ), ); - let pipeline = |i: usize| { + let pipeline = |i: usize, air: bool| { // The light arrays' sizes `mesh_view_types` is built with. let mut defs: Vec = crate::gx_receive::shader_defs() .into_iter() @@ -465,24 +531,40 @@ fn init_pipelines( if i == 1 { defs.push(ShaderDefVal::from("MULTISAMPLED")); } + if air { + defs.push(ShaderDefVal::from("AIR_PASS")); + } else if reference { + defs.push(ShaderDefVal::from("REFERENCE_POST")); + } pipeline_cache.queue_render_pipeline(RenderPipelineDescriptor { label: Some(format!("moonwell_post {i}").into()), - layout: vec![layouts[i].clone(), shadow_layout.clone()], + layout: vec![ + if air { + air_layouts[i].clone() + } else { + layouts[i].clone() + }, + shadow_layout.clone(), + ], vertex: fullscreen_shader.to_vertex_state(), fragment: Some(FragmentState { shader: shader.clone(), shader_defs: defs, - entry_point: Some("fragment".into()), - targets: vec![Some(ColorTargetState { - format: ViewTarget::TEXTURE_FORMAT_HDR, - blend: None, - write_mask: ColorWrites::ALL, - })], + entry_point: Some(if air { "air_fragment" } else { "fragment" }.into()), + targets: vec![ + Some(ColorTargetState { + format: ViewTarget::TEXTURE_FORMAT_HDR, + blend: None, + write_mask: ColorWrites::ALL, + }); + if air { 2 } else { 1 } + ], }), ..default() }) }; - let pipelines = [pipeline(0), pipeline(1)]; + let pipelines = [pipeline(0, false), pipeline(1, false)]; + let air_pipelines = [pipeline(0, true), pipeline(1, true)]; let ao_shader: Handle = asset_server.load(AO_DEPTH_SHADER); let pyramid = |label: &'static str, layout: &BindGroupLayoutDescriptor, @@ -579,7 +661,10 @@ fn init_pipelines( ..default() }); commands.insert_resource(PostPipelines { + reference, layouts, + air_layouts, + air_pipelines, shadow_layout, pipelines, sampler, @@ -615,6 +700,7 @@ impl ViewNode for PostNode { &'static Msaa, &'static PostView, Option<&'static AoDepth>, + Option<&'static AirTexture>, Option<( &'static ViewShadowBindings, &'static ViewLightsUniformOffset, @@ -625,7 +711,7 @@ impl ViewNode for PostNode { &self, _graph: &mut RenderGraphContext, render_context: &mut RenderContext<'w>, - (target, depth, view_offset, msaa, _, ao_depth, shadow_view): QueryItem< + (target, depth, view_offset, msaa, _, ao_depth, air_texture, shadow_view): QueryItem< 'w, '_, Self::ViewQuery, @@ -649,6 +735,21 @@ impl ViewNode for PostNode { let Some(pipeline) = pipeline_cache.get_render_pipeline(pipes.pipelines[i]) else { return Ok(()); }; + let air_pipeline = if !pipes.reference + && frame + .0 + .is_some_and(|p| p.vol.x > 0.0 || p.gi.x > 0.0 || p.ao.z > 0.5) + { + let Some(pipeline) = pipeline_cache.get_render_pipeline(pipes.air_pipelines[i]) else { + return Ok(()); + }; + if air_texture.is_none() { + return Ok(()); + } + Some(pipeline) + } else { + None + }; let (Some(view_binding), Some(params), Some(lights)) = ( world.resource::().uniforms.binding(), world.resource::().0.binding(), @@ -761,6 +862,49 @@ impl ViewNode for PostNode { .and_then(|p| p.colour.as_ref()) .map_or(&fallback.d2.texture_view, |c| &c.chain); let layout = pipeline_cache.get_bind_group_layout(&pipes.layouts[i]); + if let (Some(air_pipeline), Some(air)) = (air_pipeline, air_texture) { + let bind = render_context.render_device().create_bind_group( + "moonwell_scattered_light", + &pipeline_cache.get_bind_group_layout(&pipes.air_layouts[i]), + &BindGroupEntries::sequential(( + target.main_texture_view(), + &pipes.sampler, + depth.view(), + view_binding.clone(), + params.clone(), + lights.0.as_entire_binding(), + ao_view, + gi_view, + )), + ); + let diagnostics = render_context.diagnostic_recorder(); + let mut pass = render_context.begin_tracked_render_pass(RenderPassDescriptor { + label: Some("moonwell_scattered_light"), + color_attachments: &[ + Some(RenderPassColorAttachment { + view: &air.air, + depth_slice: None, + resolve_target: None, + ops: Operations::default(), + }), + Some(RenderPassColorAttachment { + view: &air.indirect, + depth_slice: None, + resolve_target: None, + ops: Operations::default(), + }), + ], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + let span = diagnostics.pass_span(&mut pass, "moonwell_scattered_light"); + pass.set_render_pipeline(air_pipeline); + pass.set_bind_group(0, &bind, &[view_offset.offset]); + pass.set_bind_group(1, &shadow_bind, &[lights_offset.offset]); + pass.draw(0..3, 0..1); + span.end(&mut pass); + } let post = target.post_process_write(); let bind = render_context.render_device().create_bind_group( "moonwell_post", @@ -774,6 +918,8 @@ impl ViewNode for PostNode { lights.0.as_entire_binding(), ao_view, gi_view, + air_texture.map_or(&fallback.d2.texture_view, |air| &air.air), + air_texture.map_or(&fallback.d2.texture_view, |air| &air.indirect), )), ); let diagnostics = render_context.diagnostic_recorder(); diff --git a/crates/moonwell-gfx/src/shaders/post.wgsl b/crates/moonwell-gfx/src/shaders/post.wgsl index 7f9bb57bd..a57b06f34 100644 --- a/crates/moonwell-gfx/src/shaders/post.wgsl +++ b/crates/moonwell-gfx/src/shaders/post.wgsl @@ -48,6 +48,12 @@ struct Post { // The screen's colour pyramid for the secondary light, level k the screen at 1/2^(k+1) // (`ao_depth.wgsl`'s `colour_down`); a 1×1 stand-in while the light is off. @group(0) @binding(7) var gi_colour: texture_2d; +#ifndef AIR_PASS +// Half-size scattered light, with linear scene depth in alpha for the edge-aware resolve. +@group(0) @binding(8) var air_buffer: texture_2d; +// Secondary irradiance in RGB, ambient visibility in alpha; depth comes from air_buffer. +@group(0) @binding(9) var indirect_buffer: texture_2d; +#endif // The sun's shadow cascades, Bevy's, with the view's lights (`crate::post`, group 1). @group(1) @binding(0) var sun_shadow_map: texture_depth_2d_array; @group(1) @binding(1) var sun_shadow_cmp: sampler_comparison; @@ -380,6 +386,94 @@ fn lit_air(p: vec2) -> vec3 { // The most the lit air adds, in luma. const AIR_CEILING: f32 = 0.35; +#ifdef AIR_PASS +struct ScatteredLight { + @location(0) air: vec4, + @location(1) indirect: vec4, +} +@fragment +fn air_fragment(in: FullscreenVertexOutput) -> ScatteredLight { + let last = vec2(textureDimensions(depth_map)) - vec2(1); + let p = clamp(vec2(in.uv * vec2(textureDimensions(depth_map))), vec2(0), last); + let d = depth_at(p); + var lin = 0.0; + if (d > 0.0) { + lin = min(-view_pos(p, d).z, 65000.0); + } + var ao = 1.0; + if (post.ao.z > 0.5) { + ao = ambient_occlusion(p); + } + return ScatteredLight(vec4(lit_air(p), lin), vec4(secondary_light(p, vec3(1.0)), ao)); +} +#else +struct ResolvedLight { + air: vec3, + indirect: vec3, + ao: f32, +} +// Bilinear weights rejected across depth edges: sky and foreground never share a tap. +// Both MRTs describe the same surface; depth weights and normalisation are shared. +fn resolved_light(uv: vec2, p: vec2) -> ResolvedLight { + let air_on = post.vol.x > 0.0; + let gi_on = post.gi.x > 0.0; + let ao_on = post.ao.z > 0.5; + var result = ResolvedLight(vec3(0.0), vec3(0.0), 1.0); + if (!air_on && !gi_on && !ao_on) { + return result; + } + let d = depth_at(p); + var lin = 0.0; + if (d > 0.0) { + lin = min(-view_pos(p, d).z, 65000.0); + } + let size = vec2(textureDimensions(air_buffer)); + let q = uv * vec2(size) - vec2(0.5); + let base = vec2(floor(q)); + let f = fract(q); + var weights = 0.0; + var obscurance = 0.0; + for (var i = 0; i < 4; i = i + 1) { + let offset = vec2(i & 1, i >> 1); + let at = clamp(base + offset, vec2(0), size - vec2(1)); + let air = textureLoad(air_buffer, at, 0); + var gi = vec4(0.0); + if (gi_on || ao_on) { + gi = textureLoad(indirect_buffer, at, 0); + } + let tap_depth = air.a; + let delta = abs(tap_depth - lin); + let same_surface = (tap_depth > 0.0) == (d > 0.0); + let spatial = select(vec2(1.0) - f, f, vec2(offset.x == 1, offset.y == 1)); + let w = spatial.x * spatial.y * select(0.0, exp(-delta / max(0.5, lin * 0.02)), same_surface); + result.air = result.air + air.rgb * w; + result.indirect = result.indirect + gi.rgb * w; + obscurance = obscurance + gi.a * w; + weights = weights + w; + } + // A thin foreground surface may have no half-size representative. Compute it at full + // size instead of borrowing light from the background or sky. + if (weights <= 1e-6) { + if (gi_on) { + result.indirect = secondary_light(p, vec3(1.0)); + } + if (air_on) { + result.air = lit_air(p); + } + if (ao_on) { + result.ao = ambient_occlusion(p); + } + return result; + } + result.air = result.air / weights; + result.indirect = result.indirect / weights; + if (ao_on) { + result.ao = obscurance / weights; + } + return result; +} +#endif + // `add` laid over `c` as a screen: added light fills what room is left below white, so a bright // pixel brightens little and none clips. fn screen_over(c: vec3, add: vec3) -> vec3 { @@ -541,6 +635,7 @@ fn grade(c: vec3) -> vec3 { return max(x, vec3(0.0)); } +#ifndef AIR_PASS @fragment fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { let color = textureSample(screen, screen_sampler, in.uv); @@ -549,11 +644,29 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { base = fxaa(in.uv, base); } var rgb = sharpened(vec2(in.position.xy), base); +#ifdef REFERENCE_POST rgb = rgb + secondary_light(vec2(in.position.xy), rgb); - if (post.ao.z > 0.5) { - rgb = rgb * ambient_occlusion(vec2(in.position.xy)); +#else + let light = resolved_light(in.uv, vec2(in.position.xy)); + if (post.gi.x > 0.0) { + let hue = rgb / max(max(max(rgb.r, rgb.g), rgb.b), 0.1); + rgb = rgb + light.indirect * hue; } +#endif + if (post.ao.z > 0.5) { +#ifdef REFERENCE_POST + rgb = rgb * ambient_occlusion(vec2(in.position.xy)); +#else + rgb = rgb * light.ao; +#endif + } +#ifdef REFERENCE_POST rgb = screen_over(rgb, lit_air(vec2(in.position.xy))); +#else + if (post.vol.x > 0.0) { + rgb = screen_over(rgb, light.air); + } +#endif rgb = screen_over(rgb, sun_shafts(in.uv)); rgb = rgb + halos(vec2(in.position.xy)); if (post.tone.y > 0.5) { @@ -562,3 +675,4 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { rgb = darkened(rgb); return vec4(rgb, color.a); } +#endif diff --git a/crates/moonwell-gfx/src/tests.rs b/crates/moonwell-gfx/src/tests.rs index ac7ffdd38..17a3a7a01 100644 --- a/crates/moonwell-gfx/src/tests.rs +++ b/crates/moonwell-gfx/src/tests.rs @@ -215,6 +215,26 @@ fn the_patched_shaders_compose_validate_and_translate() { "moonwell_gfx/shaders/post.wgsl", with(&[], defs), )); + let air_defs: &[&str] = if multisampled { + &["MULTISAMPLED", "AIR_PASS"] + } else { + &["AIR_PASS"] + }; + casters.push(( + include_str!("shaders/post.wgsl").to_string(), + "moonwell_gfx/shaders/post.wgsl", + with(&[], air_defs), + )); + let reference_defs: &[&str] = if multisampled { + &["MULTISAMPLED", "REFERENCE_POST"] + } else { + &["REFERENCE_POST"] + }; + casters.push(( + include_str!("shaders/post.wgsl").to_string(), + "moonwell_gfx/shaders/post.wgsl", + with(&[], reference_defs), + )); casters.push(( include_str!("shaders/water_scene.wgsl").to_string(), "moonwell_gfx/shaders/water_scene.wgsl", diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 81209a98c..485c57f9b 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -76,8 +76,8 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it #### bindings - A press with Super (Cmd) held runs no binding because that way a system chord such as Cmd+Tab or Cmd+Q never acts in game. The reference has no such modifier and runs the bare key's binding: the Mac enqueues the key with Command held and skips only its text (`mac:08a51e`), and Windows hands `VK_LWIN`/`VK_RWIN` to the layout arm (`0x42da39`) like any key. ([`src/bindings.rs:19`](../crates/benilla-app/src/bindings.rs#L19)) -- A trackpad's `Pixel` deltas are carried until they make a line, and step at most once per event (`crate::ui_script::Record::Wheel`). The reference steps on every `WM_MOUSEWHEEL`, whatever its delta (`0x76c1a9`); the carry is kept because a carried trackpad does not zoom or scroll at its event rate. ([`src/bindings.rs:637`](../crates/benilla-app/src/bindings.rs#L637)) -- Latches die with the VM that made them, because a latch names a command of that VM's table and releasing it against a new one would run another load's up-half. The reference keeps a held key running through `ReloadUI`; here it re-latches on its next press. The held bits die with it, as the key's Stop would have cleared them. ([`src/bindings.rs:682`](../crates/benilla-app/src/bindings.rs#L682)) +- A trackpad's `Pixel` deltas are carried until they make a line, and an event steps once per whole line on Wayland and at most once elsewhere (`Self::feed_pixels`). The reference steps on every `WM_MOUSEWHEEL`, whatever its delta (`0x76c1a9`); the carry is kept because a carried trackpad does not zoom or scroll at its event rate, and Wayland takes every whole line because a 10-pixel notch capped per event would lose a fast swipe's travel. ([`src/bindings.rs:642`](../crates/benilla-app/src/bindings.rs#L642)) +- Latches die with the VM that made them, because a latch names a command of that VM's table and releasing it against a new one would run another load's up-half. The reference keeps a held key running through `ReloadUI`; here it re-latches on its next press. The held bits die with it, as the key's Stop would have cleared them. ([`src/bindings.rs:699`](../crates/benilla-app/src/bindings.rs#L699)) - The Z key is named `Z` on a Mac too, because the Mac client uppercases only `a`-`y` (`mac:08a3df`) and so names it `z`, which no binding can press; that fault would leave a binding to `Z`, the stock `TOGGLESHEATH` key, unreachable there. ([`src/bindings/chord.rs:111`](../crates/benilla-app/src/bindings/chord.rs#L111)) - The Windows key table stops at `VK_F12` and drops later F-keys; they are named here because that way a stored `F13`-`F24` can be pressed. ([`src/bindings/chord.rs:171`](../crates/benilla-app/src/bindings/chord.rs#L171)) - On the Mac and Linux a key its platform reports no character for keeps its US name too, where the Mac reference drops it (`mac:08a4aa`), and a Linux dead key not yet pressed bare stays bindable. ([`src/bindings/layout.rs:116`](../crates/benilla-app/src/bindings/layout.rs#L116)) @@ -225,7 +225,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - Only the four party slots hand over, capped at `Self::CAP`, instead of every portrait until `ClientDestroyGame` (`0x401ee0`), because a general cache would pay a texture per unit ever seen. ([`src/portrait/mod.rs:265`](../crates/benilla-app/src/portrait/mod.rs#L265)) - This reads the window's aspect, not `gxResolution`'s, because the window here takes any shape a drag gives it, where the reference's keeps `gxResolution`'s (`0x5989d7`). In fullscreen the window is the screen in logical pixels and `gxResolution` the screen in device pixels, so their aspects agree and their sizes need not. ([`src/portrait/mod.rs:694`](../crates/benilla-app/src/portrait/mod.rs#L694)) - `boothHalfRate` is benilla's own CVar, because the reference draws its doll in the main pass and has no second view; a full-rate pass costs up to 7.6 ms a frame at 4K. ([`src/portrait/mod.rs:733`](../crates/benilla-app/src/portrait/mod.rs#L733)) -- The reference widget draws only its model; a body pane clears to near-black, the chosen look where nothing sits behind the pane. ([`src/portrait/mod.rs:991`](../crates/benilla-app/src/portrait/mod.rs#L991)) +- The reference widget draws only its model; a body pane clears to near-black, the chosen look where nothing sits behind the pane. ([`src/portrait/mod.rs:999`](../crates/benilla-app/src/portrait/mod.rs#L999)) #### quest_markers @@ -501,7 +501,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - `gxWindow "0"` is borderless fullscreen, not 1.12's exclusive mode-set at `gxResolution`, because Wayland has no client mode-setting (winit ignores `Fullscreen::Exclusive` there), and X11's XRandR changes the desktop's mode and a crash leaves it changed. Bevy's `WindowMode::Fullscreen` also panics on a live change that cannot resolve a monitor (`bevy_winit::winit_windows:91`, `system.rs:333`). ([`src/video.rs:5`](../crates/benilla-app/src/video.rs#L5)) - On Windows, NVIDIA's Vulkan driver (610.88 seen) crashes inside `vkQueuePresentKHR` on the first present after any swapchain resize while the present mode is `Fifo`, `FifoRelaxed` or `AutoVsync` (a maximize is a resize). The fault is in the driver's own DXGI present (`d3d11.dll`): it stays with `VK_LAYER_NV_present` disabled, and the validation layer reports nothing before it. `Mailbox`, `AutoNoVsync` and DX12 survive, so there "vsync on" is `AutoNoVsync` paced to the monitor's refresh rate (`present_choice`) because that keeps the synced look without the crash. `$WOW_PRESENT=fifo` restores plain `Fifo`. ([`src/video.rs:17`](../crates/benilla-app/src/video.rs#L17)) -- Sizes are logical px, because the windowed `gxResolution` is a logical inner size here. Read at each verb (`DisplayLive`), so the list follows the display and `widescreen`; the reference builds its list once and never invalidates it. ([`src/video.rs:619`](../crates/benilla-app/src/video.rs#L619)) +- Sizes are logical px, because the windowed `gxResolution` is a logical inner size here. Read at each verb (`DisplayLive`), so the list follows the display and `widescreen`; the reference builds its list once and never invalidates it. ([`src/video.rs:639`](../crates/benilla-app/src/video.rs#L639)) #### vplates @@ -554,14 +554,14 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it #### chain - An archive that fails to open is an error. In 1.12.1 a failed base archive is fatal (Error #134: `0x4038ea` → `0x64d0e0` → `0x64cfa0` → `0x64cc80`, `ExitProcess`) and only a failing patch archive is skipped, silently; benilla fails on that too, because a skipped corrupt archive surfaces only as missing files far downstream. ([`src/chain.rs:122`](../crates/benilla-formats/src/chain.rs#L122)) -- `$WOW_LOCALE` or an installed `Data/ruRU` selects a language pack (`crate::ClientLocale::for_install`); without one the stock `Wow.ini` locale stands. ([`src/chain.rs:193`](../crates/benilla-formats/src/chain.rs#L193)) -- The community ruRU pack's archives mount after the base chain, from `Data/ruRU`, which 1.12.1 never reads. ([`src/chain.rs:204`](../crates/benilla-formats/src/chain.rs#L204)) -- A last line with no CR or LF after it is passed too, because `0x648fb0` ends at the buffer's end without a flush (`0x64907f`) and drops it, a bug that loses a listfile's last name when it lacks a newline. ([`src/chain.rs:342`](../crates/benilla-formats/src/chain.rs#L342)) +- `$WOW_LOCALE` or an installed `Data/ruRU` selects a language pack (`crate::ClientLocale::for_install`); without one the stock `Wow.ini` locale stands, because MoonWell supports the community language pack alongside stock data. ([`src/chain.rs:193`](../crates/benilla-formats/src/chain.rs#L193)) +- The community ruRU pack's archives mount after the base chain, from `Data/ruRU`, because that community pack keeps its localized overrides there instead of in the stock chain. ([`src/chain.rs:204`](../crates/benilla-formats/src/chain.rs#L204)) +- A last line with no CR or LF after it is passed too, because `0x648fb0` ends at the buffer's end without a flush (`0x64907f`) and drops it, a bug that loses a listfile's last name when it lacks a newline. ([`src/chain.rs:343`](../crates/benilla-formats/src/chain.rs#L343)) #### characters - Blends in 8-bit RGBA, not the client's RGB565 with 2-bit coverage, because that format only saves texture memory and loses precision. ([`src/characters/sections.rs:308`](../crates/benilla-formats/src/characters/sections.rs#L308)) -- The reference composites at 256² only; VanillaHelpers' `ResizeCharacterSkin` convention scales it for high-resolution texture packs. ([`src/characters/sections.rs:328`](../crates/benilla-formats/src/characters/sections.rs#L328)) +- The reference composites at 256² only; VanillaHelpers' `ResizeCharacterSkin` convention scales it because high-resolution texture packs would otherwise lose their detail when composited onto the stock atlas. ([`src/characters/sections.rs:328`](../crates/benilla-formats/src/characters/sections.rs#L328)) #### creature_families @@ -582,11 +582,11 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it #### language_pack -- The community 1.12 ruRU pack lives in `Data/ruRU` and overlays the vanilla chain. Its DBCs keep the 1.12 layout and put Russian strings in slot zero, like the base tables. ([`src/language_pack.rs:3`](../crates/benilla-formats/src/language_pack.rs#L3)) +- The community 1.12 ruRU pack lives in `Data/ruRU` and overlays the vanilla chain. It mounts because MoonWell supports that community pack alongside the stock install. Its DBCs keep the 1.12 layout and put Russian strings in slot zero, like the base tables. ([`src/language_pack.rs:3`](../crates/benilla-formats/src/language_pack.rs#L3)) #### locale -- A ruRU tag, any case, names itself. The community ruRU pack (`crate::ClientLocale`) keeps its Russian strings in column 0, the index the table's lack of ruRU leaves, but its interface asks `GetLocale` for "ruRU". ([`src/locale.rs:56`](../crates/benilla-formats/src/locale.rs#L56)) +- A ruRU tag, any case, names itself. The community ruRU pack (`crate::ClientLocale`) keeps its Russian strings in column 0, the index the table's lack of ruRU leaves, but its interface asks `GetLocale` for "ruRU", because its localized UI branches on that name. ([`src/locale.rs:56`](../crates/benilla-formats/src/locale.rs#L56)) #### loose @@ -754,13 +754,13 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - The slot is read before the handler, where the reference reads it after (`[esi+0x4dc]`), because reading first keeps the borrow simple and no stock handler re-slots its row. Shift, ctrl or alt suppresses the take (`0x4c183a`/`0x4c1848`/ `0x4c1856`). ([`src/script/button.rs:1213`](../crates/benilla-ui/src/script/button.rs#L1213)) - The reference's only bound is `luaD_call`'s count of 200 nested calls (`0x6f65ae`), past which it raises `C stack overflow` (`0x6f65bf`); eight here, because each level keeps an apply's frame on the Rust stack. ([`src/script/calls.rs:701`](../crates/benilla-ui/src/script/calls.rs#L701)) - The reference composes the Shortcut with `GetRealZoneText` alone (`0x49efe8`), so in a capital `/leave Trade` sends `Trade - Stormwind City` and leaves nothing; ours uses the join's city-aware name, because the reference's form cannot leave the channel it joined. The slot is freed app-side when the server's `YOU_LEFT` arrives. ([`src/script/channel.rs:535`](../crates/benilla-ui/src/script/channel.rs#L535)) -- The community ruRU pack accepts Russian names up to twelve letters, while retaining the stock Latin alternative. The server remains the naming-policy authority. ([`src/script/char_name.rs:139`](../crates/benilla-ui/src/script/char_name.rs#L139)) +- The community ruRU pack accepts Russian names up to twelve letters, while retaining the stock Latin alternative, because its users need to enter Cyrillic character names. The server remains the naming-policy authority. ([`src/script/char_name.rs:139`](../crates/benilla-ui/src/script/char_name.rs#L139)) - The frame's bottom-left origin is snapped onto the device grid because that way the border art blits 1:1; the reference ceils the anchor point itself onto it (`0x7666a0`, `0x766750`). ([`src/script/chat_bubble.rs:482`](../crates/benilla-ui/src/script/chat_bubble.rs#L482)) - No truncation at 255 characters (`0x49f604`), because vmangos refuses a longer line itself (`Chat.cpp:2177`): it is dropped where the reference sends its first 255. The server throttles in both. ([`src/script/chat_send.rs:26`](../crates/benilla-ui/src/script/chat_send.rs#L26)) - A value outside 0..1 clamps, where the reference keeps the low byte (alpha 2.0 stores 254, -1.0 stores 1), because no FrameXML or addon caller passes one. ([`src/script/chat_window.rs:17`](../crates/benilla-ui/src/script/chat_window.rs#L17)) - An id outside the seven built windows raises, where the reference serves ten records and silently ignores any other id (`0x4a0be1`), because benilla keeps only the seven and every caller loops `1, NUM_CHAT_WINDOWS`. ([`src/script/chat_window.rs:221`](../crates/benilla-ui/src/script/chat_window.rs#L221)) - One past `i32::MAX` clamps where the reference keeps the low 32 bits, for the colours' reason. ([`src/script/chat_window.rs:481`](../crates/benilla-ui/src/script/chat_window.rs#L481)) -- ruRU is a community overlay over DBC column zero, outside the stock table. ([`src/script/client.rs:379`](../crates/benilla-ui/src/script/client.rs#L379)) +- ruRU is a community overlay over DBC column zero, because the supported community pack supplies Russian strings there rather than in a stock locale slot. ([`src/script/client.rs:379`](../crates/benilla-ui/src/script/client.rs#L379)) - The reference reads the clock live at each call (`0x42c010`); ours is the session clock the tick advances by the paced frame step, one value per frame, because that way `GetTime()` deltas and the `elapsed` handed to OnUpdate agree, as FrameXML (CastingBarFrame and kin) assumes. ([`src/script/clock.rs:64`](../crates/benilla-ui/src/script/clock.rs#L64)) - With no wheel it scales by the strip's, because the reference reads the missing wheel unguarded. ([`src/script/colorselect.rs:379`](../crates/benilla-ui/src/script/colorselect.rs#L379)) - The reference's `set` registers an unknown name as a new category-5 record (`0x63d569`-`0x63d582`); benilla refuses it, because nothing would read the row and `config.toml` keeps an unknown key anyway. ([`src/script/console_exec.rs:123`](../crates/benilla-ui/src/script/console_exec.rs#L123)) @@ -805,7 +805,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - The `/cast` argument is trimmed, because the reference binds `/cast Fireball` (two spaces) to a name that never resolves. ([`src/script/macros/bind.rs:63`](../crates/benilla-ui/src/script/macros/bind.rs#L63)) - The client's Lua has no such bound, as nothing sets a hook (`lua_sethook`, `0x6fba40`, has no caller and no pointer to it), so a load that never ends hangs it; benilla bounds it because a hung load would otherwise freeze the loading screen instead of reporting. ([`src/script/mod.rs:959`](../crates/benilla-ui/src/script/mod.rs#L959)) - The reference sets the cells only when glue or the interface is built, `0x41ad10` from `0x46a828` (`0x46a7b0`) and `0x48fe2b` (`0x48fbf0`), and a resize reaches them only through the rebuild it asks for. In glue `0x46abe0` sets the rebuild flag `0xb41d8c` when the aspect moves by 0.01 or more (`fcomp` at `0x46ac66`); in game `0x492e90` reloads the interface (`0x491380`) past 0.001 (`0x492f26`, `0x492f31`). The gate and the rebuild both take the aspect from `gxResolution` (`0x46ab40`, `0x4901e0`; 4/3 while `widescreen` is off), never the live window. Here every resize sets the cells from the window, because the UI is kept live across a resize. A dragged frame with no layout-cache row (unnamed, or `SetUserPlaced(false)`) keeps its drag pin in layout units, where the rebuild would put it back where its script places it. ([`src/script/mod.rs:1048`](../crates/benilla-ui/src/script/mod.rs#L1048)) -- The community ruRU pack reads column zero but names its own language. ([`src/script/model.rs:727`](../crates/benilla-ui/src/script/model.rs#L727)) +- The community ruRU pack reads column zero but names its own language, because its interface branches on `GetLocale` returning "ruRU". ([`src/script/model.rs:727`](../crates/benilla-ui/src/script/model.rs#L727)) - A path that does not resolve is stored, where the reference raises `Invalid model file: %s` (`0x878b44`), because the file loads asynchronously here and the call cannot know yet. ([`src/script/modelframe.rs:333`](../crates/benilla-ui/src/script/modelframe.rs#L333)) - The reference's free list is FIFO (`0x6087f0`, `0x608a5a`); either keeps every index stable, all an addon can observe of the pool's order. ([`src/script/nameplate.rs:807`](../crates/benilla-ui/src/script/nameplate.rs#L807)) - The reference adds this rim over the bar; we brighten the bar (`LIT_BOOST`), because the rim reads as hard edge lines in our linear-blending pipeline. The art has no alpha and is mostly black, so as BLEND it would be an opaque box. ([`src/script/nameplate.rs:1430`](../crates/benilla-ui/src/script/nameplate.rs#L1430)) diff --git a/docs/MAP.md b/docs/MAP.md index 5869a3cde..5c9582946 100644 --- a/docs/MAP.md +++ b/docs/MAP.md @@ -197,7 +197,7 @@ - `WOW_BOOTH_LOG` — benilla-app/src/portrait/mod.rs - `WOW_CAM_CHANGED` — benilla-app/src/perf/mod.rs - `WOW_CAM_DUMP` — benilla-app/src/player/camera.rs -- `WOW_CAPTURE` — benilla-app/src/account_data/sections.rs, benilla-app/src/account_data/tests.rs, benilla-app/src/bindings.rs, benilla-app/src/capture/mod.rs, benilla-app/src/capture/scenarios.rs, benilla-app/src/cvars/mod.rs, benilla-app/src/glue_session/login.rs, benilla-app/src/glue_session/roster.rs, benilla-app/src/lanes/account_data_live_tests.rs, benilla-app/src/lanes/action_live_tests.rs, benilla-app/src/lib.rs, benilla-app/src/local_state.rs, benilla-app/src/net.rs, benilla-app/src/net/apply.rs, benilla-app/src/player/camera_saved.rs, benilla-app/src/run_mode.rs, benilla-app/src/sound/mod.rs, benilla-app/src/ui_chat/settings.rs, benilla-app/src/ui_chat/tests.rs, benilla-app/src/ui_logout.rs, benilla-app/src/ui_macro/tests.rs, benilla-app/src/ui_saved.rs, benilla-app/src/ui_script/addons.rs, benilla-app/src/ui_script/layer_tests.rs, benilla-app/src/ui_script/lifecycle.rs, benilla-app/src/ui_script/mod.rs, benilla-app/src/ui_script/world_entry_tests.rs, benilla-app/src/video.rs, benilla-app/src/wdb/tests.rs, benilla-world/src/bgwin.rs, benilla-world/src/clouds/mod.rs, benilla-world/src/dev_state.rs, moonwell-gfx/src/map_data.rs, moonwell/src/voice.rs +- `WOW_CAPTURE` — benilla-app/src/account_data/sections.rs, benilla-app/src/account_data/tests.rs, benilla-app/src/bindings.rs, benilla-app/src/capture/mod.rs, benilla-app/src/capture/scenarios.rs, benilla-app/src/cvars/mod.rs, benilla-app/src/glue_session/login.rs, benilla-app/src/glue_session/roster.rs, benilla-app/src/lanes/account_data_live_tests.rs, benilla-app/src/lanes/action_live_tests.rs, benilla-app/src/lib.rs, benilla-app/src/local_state.rs, benilla-app/src/net.rs, benilla-app/src/net/apply.rs, benilla-app/src/player/camera_saved.rs, benilla-app/src/run_mode.rs, benilla-app/src/sound/mod.rs, benilla-app/src/ui_chat/settings.rs, benilla-app/src/ui_chat/tests.rs, benilla-app/src/ui_logout.rs, benilla-app/src/ui_macro/tests.rs, benilla-app/src/ui_saved.rs, benilla-app/src/ui_script/addons.rs, benilla-app/src/ui_script/layer_tests.rs, benilla-app/src/ui_script/lifecycle.rs, benilla-app/src/ui_script/mod.rs, benilla-app/src/ui_script/world_entry_tests.rs, benilla-app/src/video.rs, benilla-app/src/wdb/tests.rs, benilla-world/src/bgwin.rs, benilla-world/src/clouds/mod.rs, benilla-world/src/dev_state.rs, moonwell-gfx/src/map_data.rs, moonwell-gfx/src/post.rs, moonwell/src/voice.rs - `WOW_CAPTURE_AGE` — benilla-app/src/capture/mod.rs - `WOW_CAPTURE_DEADLINE` — benilla-app/src/capture/mod.rs - `WOW_CAPTURE_OUT` — benilla-app/src/capture/mod.rs @@ -550,6 +550,7 @@ ## Scripts (`scripts/`) +- `analyze-gfx-diagnostics.py` — Summarize GPU readback means by session and settings epoch, without mixing toggles. - `doc-links.py` — Fail on a doc link whose target is declared nowhere in the workspace. - `gen-message-catalog.py` — Generate `crates/benilla-ui/src/messages/catalog.rs` from `reference/messages.tsv`. - `journal-report.py` — Summarise an FPS journal recording, split wherever its tags change. diff --git a/scripts/analyze-gfx-diagnostics.py b/scripts/analyze-gfx-diagnostics.py new file mode 100644 index 000000000..4a941814d --- /dev/null +++ b/scripts/analyze-gfx-diagnostics.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Summarize GPU readback means by session and settings epoch, without mixing toggles. + +Usage: python scripts/analyze-gfx-diagnostics.py Diagnostics [another/Diagnostics] +Pass times are weighted by readback frames, not by wall-clock seconds. CPU schedule +phases overlap worker/render work and must not be summed into a frame time. +""" + +import argparse +import collections +import csv +import json +import re +from pathlib import Path + + +def percentile(values, fraction): + values = sorted(values) + return values[int((len(values) - 1) * fraction)] if values else None + + +def summarize(root): + text = (root / "gpu-passes.log").read_text(encoding="utf-8-sig") + epochs = [] + current = {} + for line in text.splitlines(): + session = re.search(r"session=(\S+)", line) + if not session: + continue + session = session[1] + if " tag mwgfx=" in line: + settings = dict(re.findall(r"([\w.]+)=([^\s]+)", line.split("tag mwgfx=", 1)[1])) + epoch = {"session": session, "settings": settings, "samples": []} + current[session] = epoch + epochs.append(epoch) + if " GPU_PASSES " not in line: + continue + pairs = dict(re.findall(r"(\w+)=([\d.]+)", line.split("GPU_PASSES", 1)[1])) + frames = int(pairs.pop("frames", 0)) + if frames <= 0: + continue + if session not in current: + current[session] = {"session": session, "settings": {}, "samples": []} + epochs.append(current[session]) + current[session]["samples"].append((frames, {k: float(v) for k, v in pairs.items()})) + + result = {"source": str(root), "adapters": sorted(set(re.findall( + r"# benilla fps journal[^\n]+", text))), "epochs": []} + for epoch in epochs: + samples = epoch.pop("samples") + if not samples: + continue + n = sum(w for w, _ in samples) + totals = collections.defaultdict(float) + for weight, passes in samples: + for name, ms in passes.items(): + totals[name] += weight * ms + mean = {k: v / n for k, v in totals.items()} + epoch.update(readbacks=len(samples), frames=n, + mean_ms=dict(sorted(mean.items(), key=lambda item: -item[1])), + total_measured_ms=sum(mean.values())) + result["epochs"].append(epoch) + + # CSV may contain repeated headers and adapter banners from multiple launches. + header = None + fps = collections.defaultdict(list) + path = root / "fps-journal.csv" + if path.exists(): + for line in path.read_text(encoding="utf-8-sig").splitlines(): + if not line or line.startswith("#"): + continue + row = next(csv.reader([line])) + if row[0] == "t": + header = row + continue + if header is None: + continue + for key, value in zip(header, row): + if key not in ("mean_ms", "main_ms", "gpu_ms", "p95_ms"): + continue + try: + fps[key].append(float(value)) + except ValueError: + pass + result["journal_ms"] = {key: {"samples": len(values), + "median": percentile(values, .5), "p95": percentile(values, .95)} + for key, values in fps.items()} + phases = collections.defaultdict(list) + path = root / "cpu-phases.csv" + if path.exists(): + lines = (line for line in path.read_text(encoding="utf-8-sig").splitlines() + if line and not line.startswith("#")) + for row in csv.DictReader(lines): + try: + phases[row["phase"]].append(float(row["wall_ms"])) + except (ValueError, KeyError, TypeError): + pass + result["cpu_phase_wall_ms"] = {key: {"median": percentile(values, .5), + "p95": percentile(values, .95)} for key, values in phases.items()} + return result + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("folders", nargs="+", type=Path) + parser.add_argument("--output", type=Path, help="Write a UTF-8 JSON report") + args = parser.parse_args() + report = json.dumps([summarize(root) for root in args.folders], indent=2) + if args.output: + args.output.write_text(report + "\n", encoding="utf-8") + else: + print(report)