Синхронизация upstream и оптимизация постобработки MoonWell #12

Merged
sindoring merged 8 commits from moonwell-gfx-gpu-opt into main 2026-11-04 12:55:13 +03:00
25 changed files with 671 additions and 218 deletions
+54 -10
View File
@@ -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]
+81 -139
View File
@@ -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<u64>,
/// The fog the spawn wrote; the scene rebuilds when it changes.
@@ -364,11 +358,7 @@ struct ParkedScene {
file: GlueSceneFile,
handle: Handle<M2Model>,
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<Res<CreateScene>>,
booths: Res<Booths>,
preview: Res<GluePreview>,
m2s: Res<Assets<M2Model>>,
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,
});
+8
View File
@@ -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(
+6 -1
View File
@@ -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
+32 -13
View File
@@ -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<PrimaryWindow>>();
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");
}
}
@@ -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",
+20
View File
@@ -495,9 +495,29 @@ impl Plugin for VideoPlugin {
.after(crate::cvars::sync_cvars),
),
);
#[cfg(windows)]
app.add_message::<bevy::window::WindowCreated>()
.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<bevy::window::WindowCreated>,
_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)]
@@ -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),
@@ -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"),
+3 -2
View File
@@ -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<Self> {
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<Self> {
let mut chain = Self::open_without(path, left_out())?;
if pack == crate::ClientLocale::RuRu {
@@ -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<BlpMipChain>) -> Result<BlpMipChain> {
let size = 256 * self.scale;
let base =
@@ -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};
+1 -1
View File
@@ -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";
+7 -1
View File
@@ -23,7 +23,13 @@ fn cinematic(data: &Path, name: &str) -> Option<PathBuf> {
fn open(name: &str) -> Option<Movie> {
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}")))
}
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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.
+1 -1
View File
@@ -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};
+9 -3
View File
@@ -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)]
+173 -27
View File
@@ -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<GiColour>,
}
/// 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<PostView>>,
) {
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::<AirTexture>();
}
if !ao_on(&frame) {
commands.entity(entity).remove::<AoDepth>();
continue;
@@ -417,33 +464,52 @@ fn init_pipelines(
asset_server: Res<AssetServer>,
pipeline_cache: Res<PipelineCache>,
) {
let reference = std::env::var_os("WOW_CAPTURE").is_some()
&& std::env::var_os("MOONWELL_GFX_REFERENCE_POST").is_some();
let shader: Handle<Shader> = 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::<ViewUniform>(true),
uniform_buffer::<PostParams>(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::<ViewUniform>(true),
uniform_buffer::<PostParams>(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<ShaderDefVal> = 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<Shader> = 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::<ViewUniforms>().uniforms.binding(),
world.resource::<PostUniform>().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();
+116 -2
View File
@@ -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<f32>;
#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<f32>;
// Secondary irradiance in RGB, ambient visibility in alpha; depth comes from air_buffer.
@group(0) @binding(9) var indirect_buffer: texture_2d<f32>;
#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<i32>) -> vec3<f32> {
// The most the lit air adds, in luma.
const AIR_CEILING: f32 = 0.35;
#ifdef AIR_PASS
struct ScatteredLight {
@location(0) air: vec4<f32>,
@location(1) indirect: vec4<f32>,
}
@fragment
fn air_fragment(in: FullscreenVertexOutput) -> ScatteredLight {
let last = vec2<i32>(textureDimensions(depth_map)) - vec2<i32>(1);
let p = clamp(vec2<i32>(in.uv * vec2<f32>(textureDimensions(depth_map))), vec2<i32>(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<f32>(lit_air(p), lin), vec4<f32>(secondary_light(p, vec3<f32>(1.0)), ao));
}
#else
struct ResolvedLight {
air: vec3<f32>,
indirect: vec3<f32>,
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<f32>, p: vec2<i32>) -> 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<f32>(0.0), vec3<f32>(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<i32>(textureDimensions(air_buffer));
let q = uv * vec2<f32>(size) - vec2<f32>(0.5);
let base = vec2<i32>(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<i32>(i & 1, i >> 1);
let at = clamp(base + offset, vec2<i32>(0), size - vec2<i32>(1));
let air = textureLoad(air_buffer, at, 0);
var gi = vec4<f32>(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<f32>(1.0) - f, f, vec2<bool>(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<f32>(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<f32>, add: vec3<f32>) -> vec3<f32> {
@@ -541,6 +635,7 @@ fn grade(c: vec3<f32>) -> vec3<f32> {
return max(x, vec3<f32>(0.0));
}
#ifndef AIR_PASS
@fragment
fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
let color = textureSample(screen, screen_sampler, in.uv);
@@ -549,11 +644,29 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
base = fxaa(in.uv, base);
}
var rgb = sharpened(vec2<i32>(in.position.xy), base);
#ifdef REFERENCE_POST
rgb = rgb + secondary_light(vec2<i32>(in.position.xy), rgb);
if (post.ao.z > 0.5) {
rgb = rgb * ambient_occlusion(vec2<i32>(in.position.xy));
#else
let light = resolved_light(in.uv, vec2<i32>(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<i32>(in.position.xy));
#else
rgb = rgb * light.ao;
#endif
}
#ifdef REFERENCE_POST
rgb = screen_over(rgb, lit_air(vec2<i32>(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<i32>(in.position.xy));
if (post.tone.y > 0.5) {
@@ -562,3 +675,4 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
rgb = darkened(rgb);
return vec4<f32>(rgb, color.a);
}
#endif
+20
View File
@@ -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",
+13 -13
View File
@@ -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))
+2 -1
View File
@@ -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.
+112
View File
@@ -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)