performance night: the interface splices and the walks go filtered

- a single animating entry — the resting blink, a sweeping cooldown — now re-converts alone and stitches into last frame's interface quads instead of dragging the whole batch through conversion, and the staleness sweep stops allocating
- two physics levers measured and flipped: entity clusters and the pre-step transform propagation both default off, −0.28 and −0.40 ms at the Stormwind gates
- the per-frame hygiene basket: env lookups cached once, both mouseover walks filtered by archetype instead of touching every streamed object, ground-shade cards resolve to their nearest shaded root, the nameplate steady frame stops allocating and stops dirtying transforms
- new meters for what remains: an animation memo-sizing counter and a lever that prices the physics stack's rate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-17 16:56:48 +07:00
co-authored by Claude Fable 5
parent 49ba9d599b
commit 4ff2a8ebcf
24 changed files with 1467 additions and 457 deletions
@@ -420,6 +420,13 @@ pub(super) fn drive_animations(
// The missing-clip resolver's DBC source (decision 0082): `None` for the brief window before
// `AnimationData.dbc` loads, in which every lookup below degrades to identity.
let catalog = anim_data.as_deref().map(|d| &d.0);
// `WOW_ANIM_COST=1` — the memo-sizing counter (1370 item 9's bracket): counts the frame's
// RESTING rows, the shape an input-memo fast path would skip. Sizing only — no skip is
// taken here (an `AnimParked` filter is law-barred: 0448's off-screen-events law means a
// parked unit's driver must keep arming clips; the memo is the only safe shape, built when
// this number says it pays).
let anim_cost = anim_cost_enabled();
let (mut cost_rows, mut cost_resting) = (0u32, 0u32);
for (
entity,
anims,
@@ -553,6 +560,32 @@ pub(super) fn drive_animations(
// same-frame land+relaunch is a new arc there ([`crate::player::Player::advance_airborne_arc`]).
let prev_vertical = std::mem::replace(&mut drv.last_vertical_speed, mv.vertical_speed);
let launched = mv.vertical_speed > JUMP_ARC_MIN_UP && prev_vertical <= JUMP_ARC_MIN_UP;
// The resting predicate, counted (never acted on — see the counter's note above): mode
// Gait with nothing in flight, nothing pending, grounded and motionless. A strict lower
// bound on what an input-memo would skip.
if anim_cost {
cost_rows += 1;
if drv.mode == Mode::Gait
&& drv.overlay.is_none()
&& drv.overlay_fade.is_none()
&& drv.wound.is_none()
&& drv.sheath_swap.is_none()
&& !first
&& !mount_edge
&& cast_hold.is_none()
&& !moving
&& !falling
&& !was_falling
&& !launched
&& !pending.contains_key(&entity)
&& !pending_wound.contains_key(&entity)
&& !pending_defense.contains_key(&entity)
&& !pending_slow.contains(&entity)
&& !pending_sheath.contains_key(&entity)
{
cost_resting += 1;
}
}
if falling && (!was_falling || launched) {
drv.jump_arc = mv.vertical_speed > JUMP_ARC_MIN_UP;
}
@@ -1400,4 +1433,18 @@ pub(super) fn drive_animations(
commands.entity(entity).remove::<super::NockLatch>();
}
}
if anim_cost && cost_rows > 0 {
use std::sync::atomic::{AtomicU32, Ordering};
static FRAME: AtomicU32 = AtomicU32::new(0);
// Once a second at 60 fps, not per frame — the counter is a sizing read, not a trace.
if FRAME.fetch_add(1, Ordering::Relaxed).is_multiple_of(64) {
eprintln!("[anim-cost] rows={cost_rows} resting={cost_resting}");
}
}
}
/// `WOW_ANIM_COST=1` — arms the memo-sizing counter above. Read once, the meter posture.
fn anim_cost_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("WOW_ANIM_COST").as_deref() == Ok("1"))
}
@@ -18,7 +18,7 @@ use bevy::prelude::*;
use benilla_assets::materials::WowModelMaterial;
use benilla_world::billboard::BillboardCard;
use benilla_world::interact::WorldObject;
use benilla_world::interact::{CreaturePickPart, GoPickPart, WorldObject};
use benilla_world::interior::part_interior_lit;
use benilla_world::model_fade::{FadeSet, JoinedFade, PartFade};
use benilla_world::model_render::{ModelKind, ModelPart};
@@ -103,6 +103,22 @@ pub(super) fn part_materials<'a>(
}
}
/// Stamp a pickable part (or card) with the pick population its kind belongs to: the mouseover
/// pickers filter by these markers instead of kind-comparing every `WorldObject` row per frame.
/// One site for both the mesh-part and the billboard-card spawn, so the two can't drift.
fn insert_pick_marker(child: &mut bevy::ecs::system::EntityCommands<'_>, kind: ModelKind) {
match kind {
ModelKind::GameObject => {
child.insert(GoPickPart);
}
ModelKind::Creature => {
child.insert(CreaturePickPart);
}
// Doodad/WMO parts (terrain_stream's spawns) are never dressed here and take no marker.
ModelKind::Doodad | ModelKind::Wmo => {}
}
}
impl<'a> PartMaterials<'a> {
/// The three handles the appear/despawn ramps feather through — this batch's slice of
/// [`FadeSet`], so the spawn and the fade can never disagree about which twin it wears.
@@ -212,6 +228,7 @@ pub(super) fn spawn_part(
card: None,
},
));
insert_pick_marker(&mut child, dress.kind);
// Every part gets a tag: the instance slot plus the live alpha field. Unconditional because the
// slot is a per-instance identity every part needs (the tint) rather than a skinning detail —
// and because the two conditional writers that used to seed it (the interior classifier, the
@@ -341,6 +358,7 @@ fn spawn_billboard_part(
benilla_world::interact::PickMesh(part.geometry.clone()),
card_follow,
));
insert_pick_marker(&mut card, dress.kind);
// A card takes its MODEL's indoor law through the same constructor as the sibling meshes it was
// split out of (decision 0778), and carries the unit's INSTANCE slot like every sibling part
// even though it is never skinned by it — it is a batch of the unit's own model, so a tinted
@@ -601,6 +619,85 @@ mod tests {
);
}
/// The dress path stamps every pickable spawn — the mesh part and the billboard CARD (never
/// the bare mirror anchor) — with exactly its kind's pick-population marker, so the mouseover
/// pickers' marker-filtered sets equal the old kind-filtered ones by construction. Asserted
/// differentially: the marker set must equal the `WorldObject`-kind set, and the other kind's
/// marker must land on nothing.
#[test]
fn a_spawned_part_carries_its_kinds_pick_marker() {
for kind in [ModelKind::Creature, ModelKind::GameObject] {
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default()))
.init_asset::<Mesh>();
let unit = app.world_mut().spawn(Transform::default()).id();
let mut billboard = part(None, false);
billboard.billboard = Some(benilla_assets::BillboardInfo {
pivot: Vec3::ZERO,
bone: 1,
kind: benilla_formats::BillboardKind::Spherical,
scale_anim: None,
seq_translations: Vec::new(),
});
let object = WorldObject {
kind,
label: String::new(),
id: 0,
detail: String::new(),
};
let empty: CharSkinMaterials = (None, None, None, (None, None));
let dress = PartDress {
unit,
kind,
char_mats: &empty,
object: &object,
inst_slot: 0,
rigged: false,
anchors: std::collections::HashMap::new(),
bake_center: Vec3::ZERO,
idle_aabb: None,
now: 0.0,
fade: JoinedFade::Steady,
};
let mut queue = bevy::ecs::world::CommandQueue::default();
{
let world = app.world();
let mut commands = Commands::new(&mut queue, world);
spawn_part(&mut commands, &part(None, false), 0, &dress);
spawn_part(&mut commands, &billboard, 1, &dress);
}
queue.apply(app.world_mut());
let world = app.world_mut();
let by_kind: std::collections::HashSet<Entity> = world
.query::<(Entity, &WorldObject)>()
.iter(world)
.filter(|(_, o)| o.kind == kind)
.map(|(e, _)| e)
.collect();
let (creature, go) = (
world
.query_filtered::<Entity, With<CreaturePickPart>>()
.iter(world)
.collect::<std::collections::HashSet<_>>(),
world
.query_filtered::<Entity, With<GoPickPart>>()
.iter(world)
.collect::<std::collections::HashSet<_>>(),
);
let (marked, other) = match kind {
ModelKind::Creature => (creature, go),
_ => (go, creature),
};
assert_eq!(
by_kind.len(),
2,
"the mesh part and the card, not the anchor"
);
assert_eq!(marked, by_kind, "marker set == kind set ({kind:?})");
assert!(other.is_empty(), "never the other kind's marker ({kind:?})");
}
}
/// A batch with no character slot — and a slot whose row the appearance didn't resolve (a bald
/// style has no hair texture, only tauren author an extra skin) — keeps the shared model's own
/// built materials. That is the arm a creature, a GameObject and an unresolvable look all take.
@@ -903,6 +903,9 @@ pub(super) fn attach_entity_visuals(
MeshMaterial3d(material),
Transform::from_translation(Vec3::Y * lift),
object,
// …and into the unit pick's marker-filtered fallback population like any
// dressed creature part.
benilla_world::interact::CreaturePickPart,
// The cube has no resident `RenderSubmesh` to cast against — its `Aabb` IS
// its shape, which it must SAY (decision 0929): the picker requires pick
// geometry rather than inferring a box from its absence.
+5 -2
View File
@@ -85,7 +85,7 @@ impl Plugin for HoverLogPlugin {
out,
"frame_ms,cpu_ms,tick_us,resolve_us,measure_us,extract_us,convert_us,\
diff_us,quads,solves,skipped,measured,measured_texts,tip_owner,tip_lines,\
tip_first_line"
tip_first_line,spliced"
);
info!("hover log: recording every frame to {path}");
app.insert_resource(Recorder {
@@ -155,7 +155,7 @@ fn record(
.unwrap_or_else(|| (String::new(), 0, String::new()));
let _ = writeln!(
rec.out,
"{frame_ms:.3},{cpu_ms:.3},{},{},{},{},{},{},{},{},{},{},{},{},{lines},{}",
"{frame_ms:.3},{cpu_ms:.3},{},{},{},{},{},{},{},{},{},{},{},{},{lines},{},{}",
c.tick,
c.resolve,
c.measure,
@@ -169,6 +169,7 @@ fn record(
cell(&c.measured_texts.join(" ⏎ ")),
cell(&owner),
cell(&first),
c.spliced,
);
rec.rows.push(Row {
frame_ms,
@@ -326,6 +327,8 @@ fn parse_row(line: &str) -> Option<Row> {
quads: num(8)? as usize,
solves: num(9)? as u64,
skipped: num(10)? != 0.0,
// Appended column (absent in pre-splice recordings — those read back as 0).
spliced: fields.get(16).and_then(|f| f.parse().ok()).unwrap_or(0),
},
tip: if owner.is_empty() {
None
+14 -7
View File
@@ -691,9 +691,11 @@ fn emit_minimap(
/// persisting across the transition. Runs before the script tick, so a `SetZoom` fired from a button
/// handler this frame routes to the right index. `CurrentWmoInterior` is the same containment test the
/// interior audio/zone tracker uses.
/// The state push is unconditional (a Minimap widget created *after* a transition — the cluster XML
/// loads late — would otherwise never be told, and the arena scan is nothing next to a frame). On the
/// actual inside↔outside *edge* we also fire `MINIMAP_UPDATE_ZOOM`: the active zoom index just switched
/// The state is pushed on the inside↔outside *edge* and whenever the VM's Minimap-creation count
/// moved (the cluster XML loads late, and an addon can build one whenever it likes — the counter
/// is what keeps "a widget created after the last transition is still told" true without walking
/// the ~3k-frame arena every frame; the memo resets with a fresh VM, whose rebuilt widgets bump
/// the fresh counter). On the edge we also fire `MINIMAP_UPDATE_ZOOM`: the active zoom index just switched
/// to the other (independent) level, so the cluster must re-sync the +/- buttons' enabled state to it
/// — the client's own signal for "the effective zoom changed" (FrameXML `Minimap_OnEvent`). Without it
/// the buttons keep the level you left (e.g. `ZoomIn` greyed from an outdoor max-zoom, still greyed
@@ -702,13 +704,18 @@ fn feed_minimap_inside(
script: Option<bevy::ecs::system::NonSendMut<benilla_ui::script::UiScript>>,
world: benilla_world::world_point::WorldPoint,
mut was_inside: Local<crate::ui_script::VmMemo<Option<bool>>>,
mut pushed_at: Local<crate::ui_script::VmMemo<u64>>,
) {
let Some(mut script) = script else { return };
let was_inside = was_inside.get(&script);
let inside = world.interior().is_some();
script.set_minimap_inside(inside);
if *was_inside != Some(inside) {
*was_inside = Some(inside);
let edge = *was_inside.get(&script) != Some(inside);
let created = script.minimap_widgets_created();
if edge || *pushed_at.get(&script) != created {
script.set_minimap_inside(inside);
*pushed_at.get(&script) = created;
}
if edge {
*was_inside.get(&script) = Some(inside);
script.fire_event("MINIMAP_UPDATE_ZOOM", vec![]);
}
}
+183 -37
View File
@@ -120,6 +120,37 @@ fn flag_prefix(player_flags: u32) -> String {
.collect()
}
/// Whether `cached` is exactly the line stack [`drive_nameplates`]'s stale arm would build from
/// these inputs — compared in place, because the steady frame must not allocate three strings per
/// shown unit just to learn nothing changed. Equivalent to string equality against the built stack
/// (the differential test pins it): line 0 is the set [`FLAG_PREFIXES`] tags in slot order glued
/// onto the name, line 1 the `<{sub}>` decoration when a subname shows.
fn lines_current(cached: &[String], player_flags: u32, name: &str, sub: Option<&str>) -> bool {
let name_line = |line: &str| {
let mut rest = line;
for (bit, tag) in FLAG_PREFIXES {
if player_flags & bit != 0 {
match rest.strip_prefix(tag) {
Some(r) => rest = r,
None => return false,
}
}
}
rest == name
};
match (cached, sub) {
([l0], None) => name_line(l0),
([l0, l1], Some(sub)) => {
name_line(l0)
&& l1
.strip_prefix('<')
.and_then(|r| r.strip_suffix('>'))
.is_some_and(|r| r == sub)
}
_ => false,
}
}
/// The name's world scale for a unit whose overhead anchor sits `d` world units above its feet.
/// Shared with the raid-target marker ([`crate::raid_marks`]) — §6 of the same geometry law.
pub(crate) fn height_scale(d: f32) -> f32 {
@@ -469,31 +500,31 @@ pub(crate) fn drive_nameplates(
if !show {
continue;
}
let Some(name) = names.resolve(guid.0, &net_commands).map(str::to_owned) else {
// Ask-once resolve, then re-read by reference (`peek`, resolve's read-only twin): holding
// resolve's `&mut`-tied return across the subname read below is a borrow conflict, and the
// `str::to_owned` that used to break it was a per-unit-per-frame allocation made just to
// compare against the cache (the steady arm below no longer builds anything).
if names.resolve(guid.0, &net_commands).is_none() {
continue;
}
let Some(name) = names.peek(guid.0) else {
continue;
};
// The player flag decorations (a1–a3): glued straight onto the name line, no space.
let name = if net.kind == EntityKind::Player {
let prefix = flag_prefix(store.map_or(0, |s| s.0.player_flags()));
if prefix.is_empty() {
name
} else {
format!("{prefix}{name}")
}
let flags = if net.kind == EntityKind::Player {
store.map_or(0, |s| s.0.player_flags())
} else {
name
0
};
let mut lines = vec![name];
if net.kind == EntityKind::Unit {
if let Some(sub) = benilla_protocol::guid::entry(guid.0)
let sub = if net.kind == EntityKind::Unit {
benilla_protocol::guid::entry(guid.0)
.and_then(|e| names.creature_subname(e))
// vmangos ships "" (present-but-empty) for most templates — an empty wire
// subname is NO subname, never an empty `<>` line.
.filter(|s| !s.trim().is_empty())
{
lines.push(format!("<{sub}>"));
}
}
} else {
None
};
// The color: the ring's byte-verified rank + the shared palette selector.
let rank = ring_reaction(
factions.as_deref(),
@@ -521,27 +552,12 @@ pub(crate) fn drive_nameplates(
))
};
// (Re)build the plate entity when its content changed. The seat below is only the
// spawn-frame placement — [`place_nameplates`] re-seats every plate each frame from the
// propagated (same-frame) pose.
let anchor = overhead_anchor(
entity,
tf,
&anchor_q.0,
&anchor_q.1,
&anchor_q.2,
&anchor_q.3,
&anchor_q.4,
);
let scale = height_scale(anchor.y - tf.translation.y);
let place = Transform {
translation: anchor,
rotation: facing,
scale: Vec3::splat(scale),
};
seen.insert(entity);
match plates.live.get(&entity) {
Some((_, l, c)) if *l == lines && *c == color => {}
// The steady frame — very nearly all of them — compares the cached stack in place
// ([`lines_current`]): the line stack used to be BUILT here (three allocations per
// shown unit per frame) only to equality-compare against the cache and be dropped.
Some((_, l, c)) if *c == color && lines_current(l, flags, name, sub) => {}
stale => {
if let Some((old, _, _)) = stale {
let old = *old;
@@ -549,6 +565,17 @@ pub(crate) fn drive_nameplates(
e.despawn();
}
}
// Building the stack allocates, and only this arm does it. [`lines_current`]'s
// differential test pins the compare above to exactly this shape.
let prefix = flag_prefix(flags);
let mut lines = vec![if prefix.is_empty() {
name.to_owned()
} else {
format!("{prefix}{name}")
}];
if let Some(sub) = sub {
lines.push(format!("<{sub}>"));
}
debug!("nameplates: rebuild {entity} -> {lines:?} ({color:?})");
let mesh = plates
.meshes
@@ -556,6 +583,25 @@ pub(crate) fn drive_nameplates(
.or_insert_with(|| meshes.add(build_name_mesh(atlas, &lines)))
.clone();
let material = plates.materials[&color].clone();
// The spawn-frame seat only — [`place_nameplates`] re-seats every plate each
// frame from the propagated (same-frame) pose. Kept (not `Transform::default()`):
// the PostUpdate placement can miss a plate whose unit despawned later this same
// Update, and a plate must never render a frame at the origin.
let anchor = overhead_anchor(
entity,
tf,
&anchor_q.0,
&anchor_q.1,
&anchor_q.2,
&anchor_q.3,
&anchor_q.4,
);
let scale = height_scale(anchor.y - tf.translation.y);
let place = Transform {
translation: anchor,
rotation: facing,
scale: Vec3::splat(scale),
};
let plate = commands
.spawn((Mesh3d(mesh), MeshMaterial3d(material), place, NamePlate))
.id();
@@ -653,8 +699,19 @@ fn place_nameplates(
rotation: facing,
scale: Vec3::splat(scale),
};
*ptf = place;
*pglobal = GlobalTransform::from(place);
// The no-op write gate (decision 1362 — the camera's pattern, at the plate): a parked
// unit's seat is bit-stable, but writing it anyway marked every plate's transform changed
// every frame. Bit equality, not an epsilon: a real sub-epsilon drift must still land.
// The global rides the same branch — a root entity's global IS its transform, so the two
// are stale together. The mounted rock-damp seat never settles; the gate just misses there.
{
let t = ptf.bypass_change_detection();
if *t != place {
*t = place;
ptf.set_changed();
*pglobal = GlobalTransform::from(place);
}
}
}
// Dismounted or despawned units drop their damping state (a re-mount starts fresh).
rock.retain(|e, _| anchor_q.4.contains(*e));
@@ -834,6 +891,95 @@ mod tests {
const RED: Color = Color::linear_rgb(1.0, 0.0, 0.0);
/// The steady arm's in-place compare answers exactly "is the cached stack what the stale arm
/// would build from these inputs" — pinned differentially against that build itself (every
/// pair of cases, both directions), so the two shapes cannot drift apart. This is also the
/// keep-vs-rebuild verdict: `true` keeps the plate entity across frames, `false` rebuilds it —
/// e.g. a name that merely LOOKS pre-tagged (`<AFK>Bob`, unflagged) still compares equal to a
/// flagged `Bob`, exactly as the built strings do.
#[test]
fn lines_current_matches_the_built_stack() {
let build = |flags: u32, name: &str, sub: Option<&str>| {
let mut lines = vec![format!("{}{name}", flag_prefix(flags))];
if let Some(sub) = sub {
lines.push(format!("<{sub}>"));
}
lines
};
let cases: &[(u32, &str, Option<&str>)] = &[
(0, "Mankrik", None),
(0, "Young Wolf", Some("Beast")),
(0, "Young Wolf", None),
(0x2, "Bob", None),
(0x2 | 0x8, "Bob", None),
(0, "<AFK>Bob", None),
];
for &(flags, name, sub) in cases {
let cached = build(flags, name, sub);
for &(f2, n2, s2) in cases {
assert_eq!(
lines_current(&cached, f2, n2, s2),
build(f2, n2, s2) == cached,
"cache of ({flags:#x}, {name:?}, {sub:?}) vs ({f2:#x}, {n2:?}, {s2:?})"
);
}
}
}
/// The placement write gate (decision 1362's pattern at the plate): a static world's second
/// placement leaves the plate's `Transform`/`GlobalTransform` change ticks untouched, and a
/// moved unit still lands its write — bit equality, so any real movement passes.
#[test]
fn a_parked_plates_seat_takes_no_write() {
use bevy::ecs::system::RunSystemOnce;
let mut world = World::new();
world.init_resource::<Time>();
world.insert_resource(NameAnchorTrace(false));
world.spawn((Transform::default(), WorldCamera));
let unit = world.spawn(Transform::from_xyz(3.0, -1.0, 5.0)).id();
let plate = world
.spawn((Transform::default(), GlobalTransform::default(), NamePlate))
.id();
let mut plates = Nameplates::default();
plates
.live
.insert(unit, (plate, vec!["Bob".to_string()], NamePaint::Flash));
world.insert_resource(plates);
// First seat: the anchor fallback (no model) is the unit's own translation.
world.run_system_once(place_nameplates).unwrap();
assert_eq!(
world.get::<Transform>(plate).unwrap().translation,
Vec3::new(3.0, -1.0, 5.0),
);
// A parked frame: bit-identical seat, so neither transform's tick may move.
world.clear_trackers();
world.run_system_once(place_nameplates).unwrap();
let e = world.entity(plate);
assert!(
!e.get_ref::<Transform>().unwrap().is_changed(),
"an unchanged seat must not re-mark the plate's transform"
);
assert!(
!e.get_ref::<GlobalTransform>().unwrap().is_changed(),
"…nor its global"
);
// A real move still lands, and marks.
world.get_mut::<Transform>(unit).unwrap().translation.x += 2.0;
world.clear_trackers();
world.run_system_once(place_nameplates).unwrap();
let e = world.entity(plate);
assert_eq!(
e.get::<Transform>().unwrap().translation,
Vec3::new(5.0, -1.0, 5.0)
);
assert!(e.get_ref::<Transform>().unwrap().is_changed());
assert!(e.get_ref::<GlobalTransform>().unwrap().is_changed());
}
/// A populated cache, as a re-bake finds it: one live plate, its mesh, its material.
fn primed(generation: Option<u64>, unit: Entity, plate: Entity) -> Nameplates {
let lines = vec!["Young Wolf".to_string()];
+16 -1
View File
@@ -129,8 +129,22 @@ fn publish_cover(
/// Render world, after `PipelineCache::process_pipeline_queue_system` has merged this frame's new
/// pipelines and started (= on macOS: finished) their builds. `seen` is how many cache entries the
/// previous frame had — everything past it is new this frame.
fn watch_pipelines(cache: Res<PipelineCache>, watch: Res<PipeWatch>, mut seen: Local<usize>) {
fn watch_pipelines(
cache: Res<PipelineCache>,
watch: Res<PipeWatch>,
mut seen: Local<usize>,
mut settled_seen: Local<usize>,
) {
let covered = watch.0.covered.load(Ordering::Relaxed);
// O(1) early-out for the steady state: nothing new since last look AND everything had already
// settled then, so the walk below could only re-derive last frame's counts. `size_hint().0`
// because the opaque `impl Iterator` hides the backing slice's `.len()`; for a slice iterator
// the lower bound is exact. The settled conjunct is load-bearing: Queued/Creating pipelines
// settle on later frames without `total` moving.
let total = cache.pipelines().size_hint().0;
if total == *seen && *settled_seen == total {
return;
}
let mut total = 0usize;
let mut settled = 0usize;
for (id, pipe) in cache.pipelines().enumerate() {
@@ -164,6 +178,7 @@ fn watch_pipelines(cache: Res<PipelineCache>, watch: Res<PipeWatch>, mut seen: L
}
}
*seen = total;
*settled_seen = settled;
watch.0.created.store(total, Ordering::Relaxed);
watch.0.settled.store(settled, Ordering::Relaxed);
}
+11 -8
View File
@@ -133,15 +133,18 @@ pub(super) fn setup_player(
if std::env::var_os("WOW_NO_INDIRECT").is_some() {
world_cam.insert(bevy::render::view::NoIndirectDrawing);
}
// `WOW_NO_CLUSTERS=1` — turn off Bevy's clustered-forward light assignment on the world
// camera. An EXPERIMENT knob (the 1370 bracket): every world shader takes its point-light
// term off OUR storage buffer (`lighting::global_light` — bevy's clusterable buffer is
// fragment-only in the view layout and nothing of ours imports `apply_pbr_lighting`), so
// the whole assign/extract/prepare cluster lane is suspected dead work proportional to the
// resident `PointLight` population (794 at the SW pin). `ClusterConfig::None` short-circuits
// Bevy's clustered-forward light assignment is OFF on the world camera by default (the
// 1370 bracket surfaced the lane; the 3-round SW split then measured the skip at −0.28
// cpu_ms): every world shader takes its point-light term off OUR storage buffer
// (`lighting::global_light` — bevy's clusterable buffer is fragment-only in the view
// layout and nothing of ours imports `apply_pbr_lighting`; the WDL far ring is unlit), so
// the whole assign/extract/prepare cluster lane is dead work proportional to the resident
// `PointLight` population (794 at the SW pin). `ClusterConfig::None` short-circuits
// `assign_objects_to_clusters` per view and starves the light extract/prepare downstream;
// the `Clusters` component stays, so the view bind group still builds.
if std::env::var_os("WOW_NO_CLUSTERS").is_some() {
// the `Clusters` component stays, so the view bind group still builds. Scoped to THIS
// camera: the booth/pane cameras keep bevy's default.
// `WOW_CLUSTERS=1` restores bevy's upstream default (the A/B lever back).
if std::env::var_os("WOW_CLUSTERS").is_none() {
world_cam.insert(bevy::light::cluster::ClusterConfig::None);
}
}
+13 -5
View File
@@ -163,9 +163,13 @@ impl ProbeShield {
/// unattended session logs in with (method.md "The local vmangos server"). Nothing else is ours to
/// modify: `one` is the director's, and a bystander test account is not a probe.
fn is_probe_account(user: &str) -> bool {
let user = user.to_ascii_lowercase();
user.strip_prefix("probe")
.is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()))
// Byte-wise (a &str slice at 5 would panic mid-char on non-ASCII input; bytes can't).
let Some((prefix, digits)) = user.as_bytes().split_at_checked(5) else {
return false;
};
prefix.eq_ignore_ascii_case(b"probe")
&& !digits.is_empty()
&& digits.iter().all(u8::is_ascii_digit)
}
/// Whether this run wants GM mode **off** — `WOW_GM=off`, or an explicit `gm:off` in `WOW_RIG`.
@@ -193,9 +197,13 @@ fn rig_asks_for_gm_off(spec: &str) -> bool {
})
}
/// Whether `WOW_GOD=off` asked for an unshielded run.
/// Whether `WOW_GOD=off` asked for an unshielded run — a launch-time knob, read once (the shield
/// drive asks every frame).
fn disabled_by_env() -> bool {
std::env::var("WOW_GOD").is_ok_and(|v| v.eq_ignore_ascii_case("off"))
static OFF: std::sync::LazyLock<bool> = std::sync::LazyLock::new(|| {
std::env::var("WOW_GOD").is_ok_and(|v| v.eq_ignore_ascii_case("off"))
});
*OFF
}
/// Classify one `CHAT_MSG_SYSTEM` line as an answer about god mode. `None` = not about it.
+23 -22
View File
@@ -18,8 +18,9 @@ use crate::net::{Guid, NetEntity, ObjectStore, SelfPlayer};
use crate::player::CameraControl;
use crate::ui_script::PointerOverUi;
use benilla_world::collision::PickOccluder;
use benilla_world::interact::{ray_mesh_bounds, ray_posed_mesh, PickParts, WorldObject};
use benilla_world::model_render::ModelKind;
use benilla_world::interact::{
ray_mesh_bounds, ray_posed_mesh, CreaturePickPart, GoPickPart, PickParts,
};
use benilla_world::view::WorldCamera;
use super::{Hovered, HoveredObject, PickOcclusion};
@@ -123,15 +124,19 @@ pub(super) fn update_hover(
child_sets: Query<&Children>,
// A part child's mesh + its palette-rig link (the rig its vertices pose through).
parts: Query<(&Mesh3d, &benilla_world::rig_palette::RigPart)>,
// Fallback path: a unit's pickable mesh children — `WorldObject` kind, model-local `Aabb`, world
// Fallback path: a unit's pickable mesh children, by [`CreaturePickPart`] (stamped beside the
// part's `WorldObject` at the attach sites, fallback cube included — the archetype filter that
// replaced a per-frame kind-compare over every streamed row) — model-local `Aabb`, world
// transform, and the link to the streamed parent (whose `Guid` we resolve the hit to).
meshes: Query<(
&ChildOf,
&Aabb,
&GlobalTransform,
&WorldObject,
Option<&InheritedVisibility>,
)>,
meshes: Query<
(
&ChildOf,
&Aabb,
&GlobalTransform,
Option<&InheritedVisibility>,
),
With<CreaturePickPart>,
>,
units: Query<&Guid, Without<SelfPlayer>>,
) {
hovered.target = None;
@@ -265,17 +270,14 @@ pub(super) fn update_hover(
// ── Fallback for skinless units only: the model-bounds box test (pre-RE interim) ────────────
// A unit's parts are separate meshes; testing each and keeping the closest parent is equivalent
// to testing the union. `dir` is normalized, so `t` is a world distance like the hits above.
for (child_of, aabb, gt, obj, drawn) in &meshes {
if obj.kind != ModelKind::Creature {
continue;
}
for (child_of, aabb, gt, drawn) in &meshes {
// …and the same rule on the skinless fallback: a part inherits its root's pass-2 verdict.
if !drawn.is_none_or(|v| v.get()) {
continue;
}
let parent = child_of.parent();
if faithful.contains(&parent) || units.get(parent).is_err() {
continue; // posed-mesh-tested above, or not a targetable unit (e.g. a GameObject part)
continue; // posed-mesh-tested above, or not a targetable unit (e.g. the self player)
}
if let Some(t) = ray_mesh_bounds(origin, dir, aabb, gt) {
if best.is_none_or(|(bt, _)| t < bt) {
@@ -355,9 +357,10 @@ pub(super) fn update_hovered_object(
// means pass 1 + the unit halo already own the mouseover, so the GO pass 2 stays shut.
unit_hovered: Res<Hovered>,
mut hovered: ResMut<HoveredObject>,
// Every pickable GameObject part carries `WorldObject { kind: GameObject }` (units/doodads/WMOs
// carry other kinds and are excluded — units have their own posed-mesh pick).
go_parts: Query<(Entity, &WorldObject)>,
// Every pickable GameObject part carries [`GoPickPart`] (stamped beside its
// `WorldObject { kind: GameObject }` at the attach sites; units/doodads/WMOs never enter this
// query — units have their own posed-mesh pick).
go_parts: Query<Entity, With<GoPickPart>>,
child_of: Query<&ChildOf>,
guids: Query<&Guid>,
stores: Query<&ObjectStore>,
@@ -393,14 +396,12 @@ pub(super) fn update_hovered_object(
// hoverable through the railing, and the hull shows no gear or tooltip.
let pickable: bevy::platform::collections::HashSet<Entity> = go_parts
.iter()
.filter(|(_, o)| o.kind == ModelKind::GameObject)
.filter(|(e, _)| {
let root = child_of.get(*e).map_or(*e, |c| c.parent());
.filter(|&e| {
let root = child_of.get(e).map_or(e, |c| c.parent());
!stores
.get(root)
.is_ok_and(|s| matches!(s.0.gameobject_type_id(), 11 | 14 | 15))
})
.map(|(e, _)| e)
.collect();
if pickable.is_empty() {
*last_pick = None;
+11 -3
View File
@@ -710,6 +710,14 @@ fn ui_mesh_frozen() -> bool {
*ON.get_or_init(|| std::env::var_os("WOW_FREEZE_UI_MESH").is_some())
}
/// `WOW_UI_DIFF` presence — launch-time knob, read once (the rebuild path re-ran getenv per frame).
static UI_DIFF: std::sync::LazyLock<bool> =
std::sync::LazyLock::new(|| std::env::var_os("WOW_UI_DIFF").is_some());
/// `WOW_UI_PROBE=1` — launch-time knob, read once (the per-run check re-ran getenv ~90×/frame).
static UI_PROBE: std::sync::LazyLock<bool> =
std::sync::LazyLock::new(|| std::env::var("WOW_UI_PROBE").as_deref() == Ok("1"));
fn rebuild_ui_mesh(
mut quads: ResMut<UiQuads>,
mut commands: Commands,
@@ -746,7 +754,7 @@ fn rebuild_ui_mesh(
// says the BASE lane's dirty flag did it), once a second. The instrument behind the
// Stormwind mesh-churn hunt: a widget re-emitting a moving/jittering quad every frame drags
// every batch through a full rebuild and the GPU allocator through ~90 frees+reallocs.
if std::env::var_os("WOW_UI_DIFF").is_some() {
if *UI_DIFF {
if q.dirty {
eprintln!("[ui-diff] BASE lane dirty");
} else {
@@ -806,7 +814,7 @@ fn rebuild_ui_mesh(
// Geometry probe (`WOW_UI_PROBE=1`): dump each textured quad's screen rect once — the
// capture-harness companion for diagnosing extracted-vs-rendered geometry by data instead of
// by eyeballing PNGs.
if std::env::var("WOW_UI_PROBE").as_deref() == Ok("1") {
if *UI_PROBE {
// Fire on every rebuild (rebuilds are change-driven and rare); read the LAST block.
{
info!(
@@ -923,7 +931,7 @@ fn rebuild_ui_mesh(
),
None => (Vec4::new(0.0, 0.0, -1.0, -1.0), None),
};
if std::env::var("WOW_UI_PROBE").as_deref() == Ok("1") && run.mask.is_some() {
if *UI_PROBE && run.mask.is_some() {
info!(
"ui probe: masked run {i}: mask_rect={mask_rect:?} scale={scale} tex={:?}",
run.mask.as_ref().map(|m| m.texture.id())
+632 -271
View File
@@ -36,6 +36,14 @@ fn gate_log_enabled() -> bool {
*ON.get_or_init(|| std::env::var("WOW_UI_GATE").as_deref() == Ok("1"))
}
/// `WOW_UI_DIFF=1` — the base-lane rebuild-trigger probe (`ui_pass`' twin). Read once; it also
/// pins the conversion to the FULL path (the probe's job is naming the first differing quad of a
/// whole-list diff, which the per-entry splice below deliberately never computes).
fn ui_diff_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("WOW_UI_DIFF").is_some())
}
/// One line naming why the extract gate did not skip this frame — the input that differed, and for
/// the render list the first entry that moved. A settled UI is what makes the paint pass free; when
/// it is never settled, this says what is moving.
@@ -107,6 +115,17 @@ pub(super) struct GateInputs {
/// The `UiFontAtlas::generation` the held quads' glyph UVs came from — see the gate's own
/// note below. Moves only when the glyph sheet resets (decisions 1339, 1342).
generation: Option<u64>,
/// Per-entry prefix ends into the conversion's output: entry `i`'s quads occupy
/// `spans[i-1]..spans[i]` of `UiQuads::quads` (0 for `i = 0`). Recorded by the full
/// conversion, kept current by the splice — this is what lets a one-entry change (the resting
/// blink) re-convert one entry instead of the whole interface.
spans: Vec<u32>,
}
/// Entry `i`'s quad range under `spans` (the [`GateInputs::spans`] encoding).
fn span_bounds(spans: &[u32], i: usize) -> (usize, usize) {
let a = if i == 0 { 0 } else { spans[i - 1] as usize };
(a, spans[i] as usize)
}
/// Per frame: screen size → `tick` (OnUpdate) → `resolve` → `extract` → [`UiQuads`]. Script errors
@@ -445,6 +464,7 @@ pub(super) fn drive_script(
quads: quads.quads.len(),
solves,
skipped: true,
spliced: 0,
};
}
if printing {
@@ -472,6 +492,170 @@ pub(super) fn drive_script(
booths.images.0 == prev.portraits,
);
}
// ── The per-entry splice: 1361's shape one layer up ──────────────────────────────────────
// The gate above is all-or-nothing, so ONE animating entry (the resting blink, a sweeping
// cooldown) used to re-convert the whole interface every frame. When the only input that
// moved is the extracted list itself, the lists are index-aligned (same length — the
// traversal is stable when nothing was created/destroyed/restacked), few entries differ,
// and every differing entry converts to nothing but quads (no link spans, no minimap slot,
// no booth panes), the changed entries re-convert alone — through the same [`convert_entry`]
// body, which is what makes the spliced output equal the full path's by construction — and
// stitch into last frame's `UiQuads` at the ranges `prev.spans` remembers. Everything else
// (the side channels, the panes map, the engine's link spans) keeps last frame's values,
// which the unchanged entries prove are this frame's too: the settled path's own argument,
// applied per entry.
'splice: {
if capture.is_some()
|| ui_diff_enabled()
|| prev.dims != Some(dims)
|| prev.generation != generation
|| text_ui != prev.text_ui
|| booths.images.0 != prev.portraits
|| prev.extracted.len() != extracted.len()
|| prev.spans.len() != extracted.len()
// spans describe `quads.quads` — if any future writer replaces the base lane out
// from under this pass, degrade to the full conversion instead of mis-stitching.
|| prev.spans.last().copied().unwrap_or(0) as usize != quads.quads.len()
{
break 'splice;
}
// The changed entry set, bounded: a real UI transition (a window opening) moves many
// entries, and the full conversion is the right tool there anyway.
const SPLICE_MAX: usize = 8;
let mut changed: Vec<usize> = Vec::with_capacity(SPLICE_MAX);
for (i, (now, was)) in extracted.iter().zip(&prev.extracted).enumerate() {
if now != was {
if changed.len() == SPLICE_MAX {
break 'splice;
}
changed.push(i);
}
}
// The all-equal case belongs to the settled gate above; reaching here empty means a
// comparison razor slipped — take the full path rather than risk skipping a change.
if changed.is_empty() {
break 'splice;
}
// Only entries whose conversion writes quads alone are spliceable — old AND new: an
// entry morphing into a side-channel kind must reach the full path's plumbing.
let simple = |eq: &benilla_ui::script::ExtractedQuad| {
matches!(
&eq.content,
QuadContent::Frame
| QuadContent::Cooldown { .. }
| QuadContent::Backdrop { .. }
| QuadContent::Texture {
portrait_unit: None,
..
}
)
};
if !changed
.iter()
.all(|&i| simple(&extracted[i]) && simple(&prev.extracted[i]))
{
break 'splice;
}
// Re-convert just the changed entries. The scratch side channels are a tripwire:
// `simple` makes them unreachable today, and if an arm ever grows a new side effect
// the full path is the only safe answer.
let mut scratch: Vec<UiQuad> = Vec::new();
let mut scratch_ranges: Vec<std::ops::Range<usize>> = Vec::with_capacity(changed.len());
let mut scratch_links = Vec::new();
let mut scratch_slot = None;
for &i in &changed {
let at = scratch.len();
convert_entry(
extracted[i].clone(),
s,
h,
&mut assets,
&mut images,
&mut font_atlas,
&mut booths,
text_ui.as_ref(),
false,
&mut scratch,
&mut scratch_links,
&mut scratch_slot,
);
scratch_ranges.push(at..scratch.len());
}
if !scratch_links.is_empty() || scratch_slot.is_some() {
break 'splice;
}
let counts_stable = changed.iter().zip(&scratch_ranges).all(|(&i, r)| {
let (a, b) = span_bounds(&prev.spans, i);
b - a == r.len()
});
let mut dirtied = false;
if counts_stable {
// The blink's shape — same quad count, new bytes: write the spans in place.
for (&i, r) in changed.iter().zip(&scratch_ranges) {
let (a, b) = span_bounds(&prev.spans, i);
if quads.quads[a..b] != scratch[r.clone()] {
quads.quads[a..b].clone_from_slice(&scratch[r.clone()]);
dirtied = true;
}
}
} else {
// An entry's quad count moved: stitch a fresh list from the kept spans plus the
// re-converted ones, and re-derive the span table.
let mut new_out: Vec<UiQuad> = Vec::with_capacity(quads.quads.len() + scratch.len());
let mut new_spans: Vec<u32> = Vec::with_capacity(prev.spans.len());
let mut ci = 0;
for i in 0..extracted.len() {
if ci < changed.len() && changed[ci] == i {
new_out.extend_from_slice(&scratch[scratch_ranges[ci].clone()]);
ci += 1;
} else {
let (a, b) = span_bounds(&prev.spans, i);
new_out.extend_from_slice(&quads.quads[a..b]);
}
new_spans.push(new_out.len() as u32);
}
prev.spans = new_spans;
quads.quads = new_out;
dirtied = true;
}
if dirtied {
quads.dirty = true;
}
for &i in &changed {
prev.extracted[i] = extracted[i].clone();
}
drop(extract_span);
let us_spl = lap();
if cost_on {
let solves = script.layout_solves() - solves_before.unwrap_or(0);
*ui_cost = super::UiFrameCost {
measured: ui_cost.measured,
measured_texts: std::mem::take(&mut ui_cost.measured_texts),
tick: us_tick,
resolve: us_resolve,
measure: us_measure,
extract: us_exm,
convert: us_spl,
diff: 0,
quads: quads.quads.len(),
solves,
skipped: false,
spliced: changed.len(),
};
}
if printing {
let solves = script.layout_solves() - solves_before.unwrap_or(0);
eprintln!(
"[ui-cost] tick={us_tick} resolve={us_resolve} measure={us_measure} \
exm={us_exm} exa={us_spl} diff=0 eq={n_extracted} quads={} \
solves={solves} changed={} skip=0 spliced={}",
quads.quads.len(),
u8::from(dirtied),
changed.len()
);
}
return;
}
prev.dims = Some(dims);
prev.generation = generation;
prev.text_ui = text_ui.clone();
@@ -482,272 +666,25 @@ pub(super) fn drive_script(
// is still this frame's truth — clearing it above the gate would make every quiet frame put the
// body panes' cameras to sleep and freeze their animation.
booths.panes.0.clear();
let mut spans: Vec<u32> = Vec::with_capacity(prev.extracted.len());
for eq in extracted {
let Some(r) = eq.rect else { continue };
// WoW UI space is y-up from the bottom-left in 768-virtual units; the quad pass is
// y-down window px from the top-left — scale ×s, then flip through the window height.
let rect = Rect::new(r.left * s, h - r.top * s, r.right * s, h - r.bottom * s);
// The ScrollFrame clip (decision 0112), through the same conversion as `rect` —
// `UiQuad::clip` is the CPU-clip stand-in `ui_pass` already applies uniformly to
// every quad (texture, backdrop, and glyph alike), so this is the entire app-side plumb.
let clip = eq
.clip
.map(|c| Rect::new(c.left * s, h - c.top * s, c.right * s, h - c.bottom * s));
match eq.content {
// Frames draw nothing themselves in v1 (regions carry the visuals).
QuadContent::Frame => continue,
// The `<Minimap>` widget's content hole: parked for the minimap renderer (an
// UiQuadAppend producer), which fills it at this exact z — the widget slot itself
// emits nothing here (decision 0203 phase 1).
QuadContent::Minimap { zoom, inside_zoom } => {
minimap_slot = Some(crate::minimap::MinimapSlot {
rect,
z: eq.z,
zoom,
inside_zoom,
alpha: eq.alpha,
});
continue;
}
// The Cooldown widget's pie wipe + finish flash (decision 0137 phase 4) — the
// byte-pinned look of `UI-Cooldown-Indicator.m2`, rebuilt natively (see
// [`cooldown_quads`]).
QuadContent::Cooldown { fraction, flash } => {
cooldown_quads(
rect,
eq.z,
eq.alpha,
fraction,
flash,
clip,
&mut assets,
&mut images,
&mut out,
);
continue;
}
QuadContent::Texture {
path,
color,
additive,
tex_coords,
circular,
portrait_unit,
rotation,
desaturated,
} => {
// A live unit portrait (`SetPortraitTexture(region, unit)`): sample this token's
// source ([`crate::portrait::PortraitImages`]) — the off-screen model bake, or the
// ref's 2D TemporaryPortrait stand-in while the model streams in. Absent entry (no
// booth yet) draws nothing rather than the run-splitter's white default.
if let Some(token) = &portrait_unit {
use crate::portrait::PortraitSource;
// A **square** binding is a booth pane (`BenillaSetBoothTexture`, decision
// 0208 §5), not a round unit portrait: publish the rect's aspect so the booth
// can bake at the shape it will be stretched into, and know it is on screen at
// all (decision 1069). Recorded before the readiness `continue` below — a pane
// whose bake hasn't landed yet is still a pane being drawn. The region's rect
// is the whole answer because no pane crops its bake; a pane that grew
// `<TexCoords>` would have to fold that UV window in here too.
if !circular && rect.height() > 0.0 {
booths
.panes
.0
.insert(token.clone(), rect.width() / rect.height());
}
// The bake is a render target and carries PREMULTIPLIED colour; the 2D stand-in
// is an ordinary straight-alpha BLP. The quad pass has to be told which, or it
// premultiplies the bake a second time and erases every effect the pane draws
// over empty space (see [`crate::ui_pass::UiQuad::premultiplied`]).
let (handle, premultiplied) = match booths.images.0.get(token) {
Some(PortraitSource::Live(h)) => (Some(h.clone()), true),
Some(PortraitSource::File(p)) => (
assets
.as_mut()
.and_then(|a| a.sprite_texture(p, &mut images)),
false,
),
None => (None, false),
};
let Some(handle) = handle else {
continue;
};
out.push(UiQuad {
rect,
z_key: eq.z,
texture: Some(handle),
// A portrait binding honours `<TexCoords>`/`SetTexCoord` like any other
// texture region — the bake is just the sampled image. The ref crops one
// this way: the character micro button samples the same portrait slot as
// the unit frame through a narrow (0.2..0.8, 0.0666..0.9) window, and
// swaps that window when the button is pushed. This branch used to pin
// UvRect::FULL, which silently squashed the whole square bake into
// whatever rect the region had.
uv: match tex_coords {
Some(TexCoords::Rect(edges)) => UvRect::from_tex_coords(edges),
Some(TexCoords::Corners(corners)) => UvRect::from_corners(corners),
None => UvRect::FULL,
},
color: [1.0, 1.0, 1.0, eq.alpha],
// The binding's mask flag: `SetPortraitTexture` regions cut the inscribed
// circle (the ref stamps the same shape into its 64² bake's alpha — and
// rounds the square stand-in art the same way); `BenillaSetBoothTexture`
// (the paper-doll model pane, decision 0208 §5) samples the bake square.
circular,
premultiplied,
clip,
..default()
});
continue;
}
// An unset texture region (no file, no color — e.g. a cleared icon) draws nothing;
// defaulting it to white would paint phantom quads.
if path.is_none() && color.is_none() {
continue;
}
// A portrait region samples the circular-masked variant so the square icon/model
// doesn't poke past the frame ring's thin band (SetPortraitToTexture, decision 0084).
//
// A path the archives don't have draws **nothing** — never a white slab. This arm
// used to fall through to `color.unwrap_or(WHITE)` with a `None` texture, which
// `ui_pass` renders as the shared 1×1 white image tinted white: an opaque white
// rectangle at the region's rect, which is how B221's macro icons reached the
// director's screen. wow-re settles what the reference does: `TextureCreate` does
// build an 8×8 placeholder, but `CSimpleTexture::SetTexture` (`0x770200`) checks the
// status severity and at ≥2 releases it and returns **without touching the widget's
// texture** — the widget keeps what it had, and Lua gets `nil`. Nothing goes white.
// We can't keep the *previous* art (a path is re-resolved per frame at extract, not
// latched at `SetTexture`), so the faithful-enough result is an empty cell; what
// matters is that it is never a phantom quad. The `Backdrop` and live-portrait arms
// already guard exactly this way.
//
// `assets` missing ENTIRELY is a data-less run (the headless UI tests), not a bad
// path — those keep the old behaviour rather than blanking every textured quad.
let handle = match (path.as_deref(), assets.as_mut()) {
(Some(p), Some(a)) => {
let resolved = if circular {
a.portrait_texture(p, &mut images)
} else {
a.sprite_texture(p, &mut images)
};
if resolved.is_none() {
continue;
}
resolved
}
_ => None,
};
// A pathless Texture region is a solid color; a textured one tints by it.
let mut color = color.unwrap_or([1.0, 1.0, 1.0, 1.0]);
color[3] *= eq.alpha;
// `<TexCoords>`/`SetTexCoord` slices the sampled sub-rect. The 4-edge form
// `[left,right,top,bottom]` maps to raw UV corners (`left→u0, right→u1, top→v0,
// bottom→v1`) — carried through [`UvRect`] rather than a normalized `Rect` so a
// mirrored slice (`left>right`, e.g. PlayerFrameTexture) keeps its flip to the
// vertex buffer. The 8-arg affine form is already per-corner in the `push_quad`
// winding (the route-line quads). Absent = the full texture.
let uv = match tex_coords {
Some(TexCoords::Rect(edges)) => UvRect::from_tex_coords(edges),
Some(TexCoords::Corners(corners)) => UvRect::from_corners(corners),
None => UvRect::FULL,
};
out.push(UiQuad {
rect,
z_key: eq.z,
texture: handle,
uv,
color,
additive,
clip,
// The engine's SetRotation is counterclockwise-positive; the quad pass spins
// clockwise-on-screen (`UiQuad::rotation`) — negate to convert.
rotation: -rotation,
desaturated,
..default()
});
}
QuadContent::Backdrop {
path,
color,
uvs,
tile,
} => {
// A frame Backdrop piece (bg or one of the 8 border pieces). Repeat-sampled when
// `tile` (edges tile past UV 1; a tiled bg wraps) — its own GPU image + cache
// (`sprite_texture_tiled`) since clamp/repeat bake into the `Image`. The four UVs are
// explicit per-corner (the rotated TOP/BOTTOM edges), so they go straight to `UvRect`.
let handle = assets.as_mut().and_then(|a| {
if tile {
a.sprite_texture_tiled(&path, &mut images)
} else {
a.sprite_texture(&path, &mut images)
}
});
// No texture (missing BLP) ⇒ draw nothing rather than a phantom solid quad.
let Some(handle) = handle else { continue };
let mut color = color;
color[3] *= eq.alpha;
out.push(UiQuad {
rect,
z_key: eq.z,
texture: Some(handle),
uv: UvRect::from_corners(uvs),
color,
clip,
..default()
});
}
QuadContent::Text {
text,
color,
justify_h,
justify_v,
font,
font_height,
text_height,
shadow,
outline,
alpha_gradient,
} => {
// No atlas (no client data / Friz Quadrata unreadable — see `ui_text`'s startup
// system) means text simply doesn't render, same graceful-absence posture as a
// missing `WorldAssets`. The rasterization itself (editbox window, ellipsis,
// shadow, links, caret) lives in `text::emit`.
let (Some(atlas), Some(text)) = (font_atlas.as_deref_mut(), text) else {
continue;
};
text::emit(
atlas,
&text,
text::TextStyle {
color,
justify_h,
justify_v,
font,
font_height,
text_height,
shadow,
outline,
alpha_gradient,
},
text::TextHost {
z: eq.z,
alpha: eq.alpha,
target: eq.target,
rect,
clip,
ebox: text_ui.as_ref(),
screen_h: h,
scale: s,
font_scale: eq.scale,
caret_pinned: capture.is_some(),
},
&mut out,
&mut link_spans,
);
}
}
convert_entry(
eq,
s,
h,
&mut assets,
&mut images,
&mut font_atlas,
&mut booths,
text_ui.as_ref(),
capture.is_some(),
&mut out,
&mut link_spans,
&mut minimap_slot,
);
spans.push(out.len() as u32);
}
prev.spans = spans;
// The payload held on the cursor draws last (a 32×32 icon at the mouse, over the whole UI) —
// but ONLY in capture mode (decision 0216 §5): a normal run shows it as the hardware cursor
// instead (`crate::cursor`), which can't appear in a screenshot's pixels, so the quad is the
@@ -789,7 +726,7 @@ pub(super) fn drive_script(
if changed {
// `WOW_UI_DIFF=1` — the base-lane half of the rebuild-trigger probe (`ui_pass`' twin):
// names the first Lua-UI quad that differs from last frame's extraction.
if std::env::var_os("WOW_UI_DIFF").is_some() {
if ui_diff_enabled() {
match quads.quads.iter().zip(&out).position(|(a, b)| a != b) {
Some(i) => {
let (b, a) = (&quads.quads[i], &out[i]);
@@ -827,6 +764,7 @@ pub(super) fn drive_script(
quads: n_quads,
solves,
skipped: false,
spliced: 0,
};
if printing {
eprintln!(
@@ -839,6 +777,286 @@ pub(super) fn drive_script(
}
}
/// One extracted entry converted to its screen quads, pushed onto `out` — with the side channels
/// some arms carry: chat link spans (Text), the minimap widget slot (Minimap), the booth pane
/// aspects (portrait-bound Texture). The ONE conversion body: the full pass and the per-entry
/// splice both call this, which is what makes the splice's output equal the full path's by
/// construction. An arm is splice-eligible only if it writes nothing but `out` — keep
/// [`splice_simple`] in agreement when an arm's side effects change.
#[allow(clippy::too_many_arguments)] // the conversion's whole environment, threaded explicitly
fn convert_entry(
eq: benilla_ui::script::ExtractedQuad,
s: f32,
h: f32,
assets: &mut Option<ResMut<WorldAssets>>,
images: &mut Assets<Image>,
font_atlas: &mut Option<ResMut<UiFontAtlas>>,
booths: &mut crate::portrait::BoothBridge,
text_ui: Option<&benilla_ui::script::EditBoxTextUi>,
caret_pinned: bool,
out: &mut Vec<UiQuad>,
link_spans: &mut Vec<(
benilla_ui::widget::FrameHandle,
benilla_ui::layout::Rect,
String,
String,
)>,
minimap_slot: &mut Option<crate::minimap::MinimapSlot>,
) {
let Some(r) = eq.rect else { return };
// WoW UI space is y-up from the bottom-left in 768-virtual units; the quad pass is
// y-down window px from the top-left — scale ×s, then flip through the window height.
let rect = Rect::new(r.left * s, h - r.top * s, r.right * s, h - r.bottom * s);
// The ScrollFrame clip (decision 0112), through the same conversion as `rect` —
// `UiQuad::clip` is the CPU-clip stand-in `ui_pass` already applies uniformly to
// every quad (texture, backdrop, and glyph alike), so this is the entire app-side plumb.
let clip = eq
.clip
.map(|c| Rect::new(c.left * s, h - c.top * s, c.right * s, h - c.bottom * s));
match eq.content {
// Frames draw nothing themselves in v1 (regions carry the visuals).
QuadContent::Frame => {}
// The `<Minimap>` widget's content hole: parked for the minimap renderer (an
// UiQuadAppend producer), which fills it at this exact z — the widget slot itself
// emits nothing here (decision 0203 phase 1).
QuadContent::Minimap { zoom, inside_zoom } => {
*minimap_slot = Some(crate::minimap::MinimapSlot {
rect,
z: eq.z,
zoom,
inside_zoom,
alpha: eq.alpha,
});
}
// The Cooldown widget's pie wipe + finish flash (decision 0137 phase 4) — the
// byte-pinned look of `UI-Cooldown-Indicator.m2`, rebuilt natively (see
// [`cooldown_quads`]).
QuadContent::Cooldown { fraction, flash } => {
cooldown_quads(
rect, eq.z, eq.alpha, fraction, flash, clip, assets, images, out,
);
}
QuadContent::Texture {
path,
color,
additive,
tex_coords,
circular,
portrait_unit,
rotation,
desaturated,
} => {
// A live unit portrait (`SetPortraitTexture(region, unit)`): sample this token's
// source ([`crate::portrait::PortraitImages`]) — the off-screen model bake, or the
// ref's 2D TemporaryPortrait stand-in while the model streams in. Absent entry (no
// booth yet) draws nothing rather than the run-splitter's white default.
if let Some(token) = &portrait_unit {
use crate::portrait::PortraitSource;
// A **square** binding is a booth pane (`BenillaSetBoothTexture`, decision
// 0208 §5), not a round unit portrait: publish the rect's aspect so the booth
// can bake at the shape it will be stretched into, and know it is on screen at
// all (decision 1069). Recorded before the readiness `continue` below — a pane
// whose bake hasn't landed yet is still a pane being drawn. The region's rect
// is the whole answer because no pane crops its bake; a pane that grew
// `<TexCoords>` would have to fold that UV window in here too.
if !circular && rect.height() > 0.0 {
booths
.panes
.0
.insert(token.clone(), rect.width() / rect.height());
}
// The bake is a render target and carries PREMULTIPLIED colour; the 2D stand-in
// is an ordinary straight-alpha BLP. The quad pass has to be told which, or it
// premultiplies the bake a second time and erases every effect the pane draws
// over empty space (see [`crate::ui_pass::UiQuad::premultiplied`]).
let (handle, premultiplied) = match booths.images.0.get(token) {
Some(PortraitSource::Live(h)) => (Some(h.clone()), true),
Some(PortraitSource::File(p)) => (
assets.as_mut().and_then(|a| a.sprite_texture(p, images)),
false,
),
None => (None, false),
};
let Some(handle) = handle else {
return;
};
out.push(UiQuad {
rect,
z_key: eq.z,
texture: Some(handle),
// A portrait binding honours `<TexCoords>`/`SetTexCoord` like any other
// texture region — the bake is just the sampled image. The ref crops one
// this way: the character micro button samples the same portrait slot as
// the unit frame through a narrow (0.2..0.8, 0.0666..0.9) window, and
// swaps that window when the button is pushed. This branch used to pin
// UvRect::FULL, which silently squashed the whole square bake into
// whatever rect the region had.
uv: match tex_coords {
Some(TexCoords::Rect(edges)) => UvRect::from_tex_coords(edges),
Some(TexCoords::Corners(corners)) => UvRect::from_corners(corners),
None => UvRect::FULL,
},
color: [1.0, 1.0, 1.0, eq.alpha],
// The binding's mask flag: `SetPortraitTexture` regions cut the inscribed
// circle (the ref stamps the same shape into its 64² bake's alpha — and
// rounds the square stand-in art the same way); `BenillaSetBoothTexture`
// (the paper-doll model pane, decision 0208 §5) samples the bake square.
circular,
premultiplied,
clip,
..default()
});
return;
}
// An unset texture region (no file, no color — e.g. a cleared icon) draws nothing;
// defaulting it to white would paint phantom quads.
if path.is_none() && color.is_none() {
return;
}
// A portrait region samples the circular-masked variant so the square icon/model
// doesn't poke past the frame ring's thin band (SetPortraitToTexture, decision 0084).
//
// A path the archives don't have draws **nothing** — never a white slab. This arm
// used to fall through to `color.unwrap_or(WHITE)` with a `None` texture, which
// `ui_pass` renders as the shared 1×1 white image tinted white: an opaque white
// rectangle at the region's rect, which is how B221's macro icons reached the
// director's screen. wow-re settles what the reference does: `TextureCreate` does
// build an 8×8 placeholder, but `CSimpleTexture::SetTexture` (`0x770200`) checks the
// status severity and at ≥2 releases it and returns **without touching the widget's
// texture** — the widget keeps what it had, and Lua gets `nil`. Nothing goes white.
// We can't keep the *previous* art (a path is re-resolved per frame at extract, not
// latched at `SetTexture`), so the faithful-enough result is an empty cell; what
// matters is that it is never a phantom quad. The `Backdrop` and live-portrait arms
// already guard exactly this way.
//
// `assets` missing ENTIRELY is a data-less run (the headless UI tests), not a bad
// path — those keep the old behaviour rather than blanking every textured quad.
let handle = match (path.as_deref(), assets.as_mut()) {
(Some(p), Some(a)) => {
let resolved = if circular {
a.portrait_texture(p, images)
} else {
a.sprite_texture(p, images)
};
if resolved.is_none() {
return;
}
resolved
}
_ => None,
};
// A pathless Texture region is a solid color; a textured one tints by it.
let mut color = color.unwrap_or([1.0, 1.0, 1.0, 1.0]);
color[3] *= eq.alpha;
// `<TexCoords>`/`SetTexCoord` slices the sampled sub-rect. The 4-edge form
// `[left,right,top,bottom]` maps to raw UV corners (`left→u0, right→u1, top→v0,
// bottom→v1`) — carried through [`UvRect`] rather than a normalized `Rect` so a
// mirrored slice (`left>right`, e.g. PlayerFrameTexture) keeps its flip to the
// vertex buffer. The 8-arg affine form is already per-corner in the `push_quad`
// winding (the route-line quads). Absent = the full texture.
let uv = match tex_coords {
Some(TexCoords::Rect(edges)) => UvRect::from_tex_coords(edges),
Some(TexCoords::Corners(corners)) => UvRect::from_corners(corners),
None => UvRect::FULL,
};
out.push(UiQuad {
rect,
z_key: eq.z,
texture: handle,
uv,
color,
additive,
clip,
// The engine's SetRotation is counterclockwise-positive; the quad pass spins
// clockwise-on-screen (`UiQuad::rotation`) — negate to convert.
rotation: -rotation,
desaturated,
..default()
});
}
QuadContent::Backdrop {
path,
color,
uvs,
tile,
} => {
// A frame Backdrop piece (bg or one of the 8 border pieces). Repeat-sampled when
// `tile` (edges tile past UV 1; a tiled bg wraps) — its own GPU image + cache
// (`sprite_texture_tiled`) since clamp/repeat bake into the `Image`. The four UVs are
// explicit per-corner (the rotated TOP/BOTTOM edges), so they go straight to `UvRect`.
let handle = assets.as_mut().and_then(|a| {
if tile {
a.sprite_texture_tiled(&path, images)
} else {
a.sprite_texture(&path, images)
}
});
// No texture (missing BLP) ⇒ draw nothing rather than a phantom solid quad.
let Some(handle) = handle else { return };
let mut color = color;
color[3] *= eq.alpha;
out.push(UiQuad {
rect,
z_key: eq.z,
texture: Some(handle),
uv: UvRect::from_corners(uvs),
color,
clip,
..default()
});
}
QuadContent::Text {
text,
color,
justify_h,
justify_v,
font,
font_height,
text_height,
shadow,
outline,
alpha_gradient,
} => {
// No atlas (no client data / Friz Quadrata unreadable — see `ui_text`'s startup
// system) means text simply doesn't render, same graceful-absence posture as a
// missing `WorldAssets`. The rasterization itself (editbox window, ellipsis,
// shadow, links, caret) lives in `text::emit`.
let (Some(atlas), Some(text)) = (font_atlas.as_deref_mut(), text) else {
return;
};
text::emit(
atlas,
&text,
text::TextStyle {
color,
justify_h,
justify_v,
font,
font_height,
text_height,
shadow,
outline,
alpha_gradient,
},
text::TextHost {
z: eq.z,
alpha: eq.alpha,
target: eq.target,
rect,
clip,
ebox: text_ui,
screen_h: h,
scale: s,
font_scale: eq.scale,
caret_pinned,
},
out,
link_spans,
);
}
}
}
/// One pass of the FontString measure round-trip: hand every unmeasured FontString to the font
/// engine and push the answers back. Returns whether anything was measured — the caller's gate for
/// its backstop pass (see the call site for why the order matters).
@@ -1093,10 +1311,8 @@ mod extract_gate_tests {
use crate::portrait::PortraitImages;
fn app_with_marker() -> App {
let script = UiScript::new().unwrap();
script
.run(
r#"
app_from_script(
r#"
local plain = CreateFrame("Frame", "Plain")
plain:SetPoint("TOPLEFT", 0, 0)
plain:SetSize(50, 50)
@@ -1104,8 +1320,15 @@ mod extract_gate_tests {
marker:SetTexture(1, 0, 0)
marker:SetAllPoints() -- templateless Lua region: no implicit anchor (1310)
"#,
)
.unwrap();
)
}
/// The same headless app around an arbitrary boot script. The splice tests boot a SECOND
/// app straight into a mutated app's final state: its first frame is by construction the
/// full conversion the spliced output must equal.
fn app_from_script(lua: &str) -> App {
let script = UiScript::new().unwrap();
script.run(lua).unwrap();
let mut app = App::new();
app.insert_non_send_resource(script);
app.init_resource::<UiQuads>();
@@ -1222,4 +1445,142 @@ mod extract_gate_tests {
"every straight-alpha region stays unflagged — the flag is the booth's alone"
);
}
/// The per-entry splice, in-place half: a one-entry paint write must ride the splice
/// (`UiFrameCost::spliced == 1` — nonzero is the proof the path FIRED, without which this
/// test is vacuous) and produce byte-identically what the full conversion produces —
/// checked against a fresh app booted straight into the final state.
#[test]
fn a_one_entry_paint_write_splices_and_matches_the_full_conversion() {
let mut app = app_with_marker();
app.world_mut()
.resource_mut::<crate::ui_script::UiCostWanted>()
.0 = true;
app.update();
app.world_mut().resource_mut::<UiQuads>().dirty = false;
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("marker:SetTexture(0, 0, 1)")
.unwrap();
app.update();
assert_eq!(
app.world()
.resource::<crate::ui_script::UiFrameCost>()
.spliced,
1,
"the color write is one splice-simple entry"
);
assert!(
app.world().resource::<UiQuads>().dirty,
"the spliced write re-marks the quads dirty"
);
let mut reference = app_from_script(
r#"
local plain = CreateFrame("Frame", "Plain")
plain:SetPoint("TOPLEFT", 0, 0)
plain:SetSize(50, 50)
marker = plain:CreateTexture(nil, "ARTWORK")
marker:SetTexture(0, 0, 1)
marker:SetAllPoints() -- templateless Lua region: no implicit anchor (1310)
"#,
);
reference.update();
assert!(
app.world().resource::<UiQuads>().quads
== reference.world().resource::<UiQuads>().quads,
"spliced output equals the full conversion of the same model"
);
}
/// The stitch half: a write that CHANGES an entry's quad count (a cleared texture emits
/// nothing) re-derives the span table — and a later splice against the re-derived spans
/// still matches the full conversion, in both directions (1 → 0 quads, then 0 → 1).
#[test]
fn a_count_changing_write_stitches_and_keeps_the_spans_true() {
let mut app = app_with_marker();
app.world_mut()
.resource_mut::<crate::ui_script::UiCostWanted>()
.0 = true;
app.update();
let before = app.world().resource::<UiQuads>().quads.len();
app.world_mut().resource_mut::<UiQuads>().dirty = false;
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("marker:SetTexture(nil)")
.unwrap();
app.update();
assert_eq!(
app.world()
.resource::<crate::ui_script::UiFrameCost>()
.spliced,
1,
"the clear rides the splice (stitch branch)"
);
let quads = app.world().resource::<UiQuads>();
assert!(quads.dirty, "a vanished quad is a real change");
assert_eq!(
quads.quads.len(),
before - 1,
"the cleared marker's quad is gone from the stitched list"
);
// The follow-up (0 → 1 quads) splices against the RE-DERIVED spans.
app.world_mut().resource_mut::<UiQuads>().dirty = false;
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("marker:SetTexture(0, 1, 0)")
.unwrap();
app.update();
assert_eq!(
app.world()
.resource::<crate::ui_script::UiFrameCost>()
.spliced,
1
);
let mut reference = app_from_script(
r#"
local plain = CreateFrame("Frame", "Plain")
plain:SetPoint("TOPLEFT", 0, 0)
plain:SetSize(50, 50)
marker = plain:CreateTexture(nil, "ARTWORK")
marker:SetTexture(0, 1, 0)
marker:SetAllPoints() -- templateless Lua region: no implicit anchor (1310)
"#,
);
reference.update();
assert!(
app.world().resource::<UiQuads>().quads
== reference.world().resource::<UiQuads>().quads,
"post-stitch splice output equals the full conversion"
);
}
/// A raster-environment change (window resize) must pin the conversion to the FULL path —
/// the splice's held quads were rasterized under the old seam and every one of them is
/// wrong-sized (decision 1342's edge, applied to the splice).
#[test]
fn a_resize_takes_the_full_path_not_the_splice() {
let mut app = app_with_marker();
app.world_mut()
.resource_mut::<crate::ui_script::UiCostWanted>()
.0 = true;
app.update();
let win = app
.world_mut()
.query_filtered::<Entity, With<PrimaryWindow>>()
.single(app.world())
.unwrap();
app.world_mut().get_mut::<Window>(win).unwrap().resolution = UVec2::new(800, 600).into();
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("marker:SetTexture(0, 0, 1)")
.unwrap();
app.update();
assert_eq!(
app.world()
.resource::<crate::ui_script::UiFrameCost>()
.spliced,
0,
"a moved raster environment forbids splicing"
);
}
}
@@ -13,6 +13,10 @@ use benilla_ui::widget::FrameHandle;
use crate::ui_pass::{UiQuad, UvRect};
use crate::ui_text::{layout_text_quads, layout_text_quads_links, UiFontAtlas};
/// `WOW_TEXT_PROBE=1` — launch-time knob, read once (the check ran per Text quad per frame).
static TEXT_PROBE: std::sync::LazyLock<bool> =
std::sync::LazyLock::new(|| std::env::var("WOW_TEXT_PROBE").as_deref() == Ok("1"));
/// The `QuadContent::Text` payload minus the text itself — the region's resolved style, passed
/// verbatim from the destructure at the [`super::drive_script`] call site.
pub(super) struct TextStyle {
@@ -183,7 +187,7 @@ pub(super) fn emit(
}
// The probe prints the DISPLAY string (post-ellipsis, post-editbox-window) — what this pass
// actually draws, which is what a truncation report needs to show.
let probe = std::env::var("WOW_TEXT_PROBE").as_deref() == Ok("1");
let probe = *TEXT_PROBE;
if probe {
// For the focused edit box, also the two numbers that must agree: where the engine puts the
// caret (`caret=`, advance-table-derived) and how wide the text this pass actually draws is
+4
View File
@@ -101,6 +101,10 @@ pub(crate) struct UiFrameCost {
pub(crate) quads: usize,
pub(crate) solves: u64,
pub(crate) skipped: bool,
/// How many entries the per-entry splice re-converted this frame — `0` on a settled or
/// full-conversion frame. Nonzero is the proof the splice path fired (the equivalence tests
/// and the live `[ui-cost] spliced=` field both read it).
pub(crate) spliced: usize,
}
/// Does anything want [`UiFrameCost`] filled in this run? Measuring the split costs a clock read
+21 -3
View File
@@ -16,6 +16,8 @@
//! caught (mlua's `Function::call` is a protected call) and returned to the caller, which records
//! them in [`super::Model::errors`] — never a panic, never a print.
use std::borrow::Cow;
use mlua::{Function, Lua, MultiValue, Table, Value};
use super::{Model, ScriptValue, REG_SCRIPTS};
@@ -203,6 +205,22 @@ fn fire(
invoke_with_globals(lua, wrapper, &func, event_name, extra)
}
/// The legacy `argN` global names, pre-spelled through the arities that occur in practice so the
/// per-arg save/set/restore below allocates nothing.
const ARG_NAMES: [&str; 16] = [
"arg1", "arg2", "arg3", "arg4", "arg5", "arg6", "arg7", "arg8", "arg9", "arg10", "arg11",
"arg12", "arg13", "arg14", "arg15", "arg16",
];
/// The global name for arg `i` — **1-based**, matching the globals themselves (`arg1..`). Past
/// [`ARG_NAMES`] it falls back to allocating.
fn arg_name(i: usize) -> Cow<'static, str> {
match ARG_NAMES.get(i - 1) {
Some(&name) => Cow::Borrowed(name),
None => Cow::Owned(format!("arg{i}")),
}
}
/// The RF-0025 calling convention itself — the single home for it, shared by [`fire`] (registry
/// handlers: events, OnUpdate, OnShow/OnHide) and the loader's bottom-up `OnLoad` (which holds the
/// compiled `Function` directly). Sets the legacy `this`/`event`/`arg1..argN` globals and passes the
@@ -222,7 +240,7 @@ pub(crate) fn invoke_with_globals(
let n = extra.len();
let mut saved_args: Vec<Value> = Vec::with_capacity(n);
for i in 1..=n {
saved_args.push(g.get::<Value>(format!("arg{i}"))?);
saved_args.push(g.get::<Value>(arg_name(i).as_ref())?);
}
g.set("this", wrapper.clone())?;
@@ -230,7 +248,7 @@ pub(crate) fn invoke_with_globals(
g.set("event", lua.create_string(ev)?)?;
}
for (i, v) in extra.iter().enumerate() {
g.set(format!("arg{}", i + 1), v.clone())?;
g.set(arg_name(i + 1).as_ref(), v.clone())?;
}
let mut modern: Vec<Value> = Vec::with_capacity(2 + n);
@@ -246,7 +264,7 @@ pub(crate) fn invoke_with_globals(
g.set("this", saved_this)?;
g.set("event", saved_event)?;
for (i, v) in saved_args.into_iter().enumerate() {
g.set(format!("arg{}", i + 1), v)?;
g.set(arg_name(i + 1).as_ref(), v)?;
}
outcome
+44 -26
View File
@@ -1061,20 +1061,24 @@ impl UiScript {
/// alone left those rects zero-width and their anchored neighbours overlapping.
pub fn fontstrings_needing_measure(&mut self) -> Vec<MeasureRequest> {
let mut model = self.model_mut();
let mut out = Vec::new();
let handles: Vec<RegionHandle> = model
// ANY FontString with text — not only the auto-sized ones. A region with both axes
// declared needs no measure for its *layout*, but `GetStringWidth` still has to answer
// with the string's natural width, and only a measure can supply it (decision 0997: a
// kit that reads that number and then SETS a width on the string would otherwise stop
// receiving measures the instant it did so, and start reading its own box back). The
// key cache is what keeps this from costing a re-measure per frame — and the staleness
// check runs allocation-free ([`stale_measure_key`]) so the whole-roster sweep stays
// cheap: the request build (text/font clones, the id mint) is paid only by rows that
// actually need a measure, which on a quiet frame is none.
let needy: Vec<RegionHandle> = model
.region_data
.iter()
// ANY FontString with text — not only the auto-sized ones. A region with both axes
// declared needs no measure for its *layout*, but `GetStringWidth` still has to answer
// with the string's natural width, and only a measure can supply it (decision 0997: a
// kit that reads that number and then SETS a width on the string would otherwise stop
// receiving measures the instant it did so, and start reading its own box back). The
// key cache below is what keeps this from costing a re-measure per frame.
.filter(|(_, d)| d.text.as_deref().is_some_and(|t| !t.is_empty()))
.map(|(&rh, _)| rh)
.filter(|&rh| stale_measure_key(&model, rh).is_some())
.collect();
for rh in handles {
let mut out = Vec::with_capacity(needy.len());
for rh in needy {
if let Some(req) = measure_request_for(&mut model, rh) {
out.push(req);
}
@@ -1149,17 +1153,7 @@ impl UiScript {
/// agree byte for byte on the cache key, or a same-tick measure and the batch pass would each think
/// the other's answer was stale and re-measure forever.
pub(super) fn measure_request_for(model: &mut Model, rh: RegionHandle) -> Option<MeasureRequest> {
let region = model.arena.region(rh)?;
if !matches!(region.kind, crate::widget::RegionKind::FontString) {
return None;
}
// The owner's effective_scale: the host measures at the drawn raster size
// ([`MeasureRequest::scale`]), and the key carries it so a SetScale re-measures.
let scale = model
.arena
.frame(region.owner)
.map(|f| f.effective_scale)
.unwrap_or(1.0);
let (key, scale) = stale_measure_key(model, rh)?;
let d = model.region_data.get(&rh)?;
let text = d.text.clone().filter(|t| !t.is_empty())?;
let wrap_width = d.size.map(|s| s.0).filter(|w| *w > 0.0);
@@ -1167,12 +1161,6 @@ pub(super) fn measure_request_for(model: &mut Model, rh: RegionHandle) -> Option
let height = d.font_height;
let text_height = d.text_height;
let outline = d.outline;
// The shared key recipe ([`RegionData::measure_key`]) — the metric reads (region.rs) check the
// stored measure against the same hash.
let key = d.measure_key(scale);
if d.measured.map(|m| m.key) == Some(key) {
return None;
}
let id = model.region_id(rh);
Some(MeasureRequest {
id,
@@ -1186,3 +1174,33 @@ pub(super) fn measure_request_for(model: &mut Model, rh: RegionHandle) -> Option
key,
})
}
/// The staleness half of [`measure_request_for`], allocation-free: `Some((key, scale))` iff `rh`
/// is a FontString holding text whose stored measure no longer matches its current key. This is
/// the ONE place the "does this region need a measure?" question is answered — the per-frame
/// sweep ([`super::UiScript::fontstrings_needing_measure`]) asks it for every FontString, so it
/// must not clone; the request build above pays the clones only after this says stale.
fn stale_measure_key(model: &Model, rh: RegionHandle) -> Option<(u64, f32)> {
let region = model.arena.region(rh)?;
if !matches!(region.kind, crate::widget::RegionKind::FontString) {
return None;
}
// The owner's effective_scale: the host measures at the drawn raster size
// ([`MeasureRequest::scale`]), and the key carries it so a SetScale re-measures.
let scale = model
.arena
.frame(region.owner)
.map(|f| f.effective_scale)
.unwrap_or(1.0);
let d = model.region_data.get(&rh)?;
if d.text.as_deref().is_none_or(|t| t.is_empty()) {
return None;
}
// The shared key recipe ([`RegionData::measure_key`]) — the metric reads (region.rs) check the
// stored measure against the same hash.
let key = d.measure_key(scale);
if d.measured.map(|m| m.key) == Some(key) {
return None;
}
Some((key, scale))
}
+10 -1
View File
@@ -690,7 +690,10 @@ impl UiScript {
/// Push the player's WMO-containment state onto every Minimap widget (the client's `0xceaa60`).
/// It selects which of the two persisted zoom indices `GetZoom`/`SetZoom` act on, so the zoom
/// buttons drive the indoor level while indoors and the outdoor level while outside. The app
/// owns the containment test, so it owns this push — call it before the script tick.
/// owns the containment test, so it owns this push — call it before the script tick. The
/// caller pushes on the inside↔outside edge and whenever [`Self::minimap_widgets_created`]
/// moved (a widget born after the last transition still gets told, without paying the arena
/// walk every frame).
pub fn set_minimap_inside(&mut self, inside: bool) {
for (_, frame) in self.model_mut().arena.iter_frames_mut() {
if let KindState::Minimap(m) = &mut frame.kind_state {
@@ -699,6 +702,12 @@ impl UiScript {
}
}
/// Monotonic count of Minimap widgets ever created in this VM — the O(1) signal that a new
/// one exists and needs the containment state pushed.
pub fn minimap_widgets_created(&self) -> u64 {
self.model_ref().arena.minimap_created()
}
/// Seed every Minimap widget's two zoom indices from the persisted levels — the client's
/// minimap reset path copying each CVar object's parsed int into its live index
/// (`[0x86f698] ← [[0xb4b410]+0x28]`, `[0x86f69c] ← [[0xb4d90c]+0x28]`; wow-re
+4 -1
View File
@@ -582,7 +582,10 @@ impl RegionData {
pub(crate) fn measure_key(&self, scale: f32) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
self.text.clone().unwrap_or_default().hash(&mut hasher);
// By reference: `String` hashes exactly as `str` does, and this runs for every
// FontString on every frame's staleness sweep — a clone here was ~1.5k heap allocs a
// frame at a city pin, all discarded at the key compare.
self.text.as_deref().unwrap_or("").hash(&mut hasher);
self.font_path.hash(&mut hasher);
self.font_height.map(f32::to_bits).hash(&mut hasher);
self.text_height.map(f32::to_bits).hash(&mut hasher);
+13
View File
@@ -373,6 +373,10 @@ pub struct WidgetArena {
regions: Arena<Region>,
names: HashMap<String, FrameHandle>,
next_insertion: u32,
/// Monotonic count of Minimap-kind frames ever created — an O(1) "a new Minimap widget
/// exists" signal, so the per-frame state feed (`set_minimap_inside`'s caller) re-pushes
/// exactly when one appears instead of walking every frame to find out.
minimap_created: u64,
}
impl Default for WidgetArena {
@@ -389,9 +393,15 @@ impl WidgetArena {
regions: Arena::new(),
names: HashMap::new(),
next_insertion: 0,
minimap_created: 0,
}
}
/// Monotonic count of Minimap widgets ever created (see the field note).
pub fn minimap_created(&self) -> u64 {
self.minimap_created
}
// ── Read access ────────────────────────────────────────────────────────────────────────────
/// Borrow a frame, or `None` if the handle is stale/unknown.
@@ -585,6 +595,9 @@ impl WidgetArena {
next_decl: 0,
};
if matches!(kind, FrameKind::Minimap) {
self.minimap_created += 1;
}
let (index, generation) = self.frames.insert(frame);
let handle = FrameHandle { index, generation };
+91 -15
View File
@@ -126,8 +126,8 @@ pub(crate) fn project_decal(
}
for i in trimesh.bvh().intersect_aabb(&gather) {
let tri = trimesh.triangle(i);
let poly = clip_to_frame([tri.a, tri.b, tri.c], frame);
if poly.len() < 3 {
let (poly, n) = clip_to_frame([tri.a, tri.b, tri.c], frame);
if n < 3 {
continue;
}
let vert = |p: Vec3| {
@@ -141,7 +141,7 @@ pub(crate) fn project_decal(
};
// Fan-triangulate the clipped convex polygon, unrolled (the stream's tri-list
// topology: identity indices, no shared vertices).
for k in 1..poly.len() - 1 {
for k in 1..n - 1 {
out.push(vert(poly[0]));
out.push(vert(poly[k]));
out.push(vert(poly[k + 1]));
@@ -155,8 +155,9 @@ pub(crate) fn project_decal(
/// horizontal rectangle (clipping in the rotated frame is exactly the texture frame, so UVs stay
/// in `[0,1]` and the texture can never wrap ghost copies in at the corners) and the vertical
/// slab. Interpolates full 3D positions along clipped edges, so the result stays on the source
/// triangle's plane. Returns fewer than 3 vertices when the triangle lies outside the box.
fn clip_to_frame(tri: [Vec3; 3], frame: &DecalFrame) -> Vec<Vec3> {
/// triangle's plane. Returns the polygon buffer and its vertex count — fewer than 3 when the
/// triangle lies outside the box.
fn clip_to_frame(tri: [Vec3; 3], frame: &DecalFrame) -> ([Vec3; 9], usize) {
let rx = |p: Vec3| frame.in_frame(p).0;
let rz = |p: Vec3| frame.in_frame(p).1;
// Signed inside-distances for the six half-planes of the box.
@@ -168,26 +169,34 @@ fn clip_to_frame(tri: [Vec3; 3], frame: &DecalFrame) -> Vec<Vec3> {
&|p: Vec3| (frame.center.y + frame.max_y) - p.y,
&|p: Vec3| p.y - (frame.center.y + frame.min_y),
];
let mut poly: Vec<Vec3> = tri.to_vec();
// Stack ping-pong (this runs per gathered triangle per frame): a convex clip grows the
// polygon by at most one vertex per plane, so 3 + 6 = 9 bounds both buffers.
let mut poly = [Vec3::ZERO; 9];
let mut next = [Vec3::ZERO; 9];
poly[..3].copy_from_slice(&tri);
let mut len = 3;
for dist in planes {
let mut out = Vec::with_capacity(poly.len() + 1);
for i in 0..poly.len() {
let (a, b) = (poly[i], poly[(i + 1) % poly.len()]);
let mut out = 0;
for i in 0..len {
let (a, b) = (poly[i], poly[(i + 1) % len]);
let (da, db) = (dist(a), dist(b));
if da >= 0.0 {
out.push(a);
next[out] = a;
out += 1;
}
// The edge crosses the plane → emit the intersection point.
if (da >= 0.0) != (db >= 0.0) {
out.push(a + (b - a) * (da / (da - db)));
next[out] = a + (b - a) * (da / (da - db));
out += 1;
}
}
poly = out;
if poly.len() < 3 {
return poly;
std::mem::swap(&mut poly, &mut next);
len = out;
if len < 3 {
return (poly, len);
}
}
poly
(poly, len)
}
/// **Project a decal onto the world's receiving surfaces** — the face for every lane that draws
@@ -235,3 +244,70 @@ impl WorldDecal<'_, '_> {
self.surfaces.contains(entity)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// An axis-aligned 2×2×2 box around the origin (`(sin, cos) = (0, 1)`), so in-frame
/// coordinates ARE world `(x, z)` and the expected polygons can be written by hand.
fn frame() -> DecalFrame {
DecalFrame {
center: Vec3::ZERO,
sin: 0.0,
cos: 1.0,
min_x: -1.0,
max_x: 1.0,
min_z: -1.0,
max_z: 1.0,
min_y: -1.0,
max_y: 1.0,
}
}
#[test]
fn a_triangle_inside_the_box_passes_through_unchanged() {
let tri = [
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(0.5, 0.0, 0.0),
Vec3::new(0.0, 0.0, 0.5),
];
let (poly, n) = clip_to_frame(tri, &frame());
assert_eq!(n, 3);
// Same vertices, same order — the caller's fan (`poly[0], poly[k], poly[k+1]`) rides it.
assert_eq!(&poly[..3], &tri);
}
#[test]
fn a_half_clipped_triangle_emits_the_hand_computed_polygon() {
// Crosses only the `max_x` plane; every value below is exact in f32, so `==` is honest.
let tri = [
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(2.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 0.5),
];
let (poly, n) = clip_to_frame(tri, &frame());
assert_eq!(n, 4);
// The emit order: each surviving vertex first, then its edge's crossing point.
assert_eq!(
&poly[..4],
&[
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.25),
Vec3::new(0.0, 0.0, 0.5),
]
);
}
#[test]
fn a_triangle_outside_the_box_clips_to_nothing() {
let tri = [
Vec3::new(2.0, 0.0, 0.0),
Vec3::new(3.0, 0.0, 0.0),
Vec3::new(2.0, 0.0, 1.0),
];
let (_, n) = clip_to_frame(tri, &frame());
assert!(n < 3, "the caller drops it before fanning");
}
}
+113 -21
View File
@@ -233,12 +233,16 @@ pub(crate) fn update_ground_shade(
// A card is a world ROOT (the facing system owns its transform), so the descendant walk below
// cannot reach one — it carries its owner instead. Disjoint from `parts` by the filter above.
mut cards: Query<(&crate::billboard::BillboardCard, &mut MeshTag)>,
// Reused across frames: every entity in a shaded root's tree → that root's shade byte, so the
// card pass is one lookup per card instead of a walk per card.
mut tree_shade: Local<bevy::platform::collections::HashMap<Entity, u8>>,
// Reused across frames: each shaded ROOT → its shade byte this frame (a few hundred entries).
// The card pass walks `ChildOf` up to the nearest such root — this map used to record every
// descendant too (~10-20k inserts/frame) so that walk could be a single lookup.
mut root_shade: Local<bevy::ecs::entity::EntityHashMap<u8>>,
// The card pass's up-walk: a card can follow a deep JOINT (`following_joint` — the swinging
// lamp's glow), and a joint is not a shade root.
child_of: Query<&ChildOf>,
mut self_log: Local<f32>,
) {
tree_shade.clear();
root_shade.clear();
let Some(streamer) = streamer else {
return;
};
@@ -248,10 +252,7 @@ pub(crate) fn update_ground_shade(
// `WOW_INTERIOR_LOG=1`: a periodic SELF-node dump (every 3 s) — the probe run's definitive
// "what state is the parked character actually in" line, attributable unlike the
// change-triggered `[node]` lines below (wandering NPCs fire those constantly).
if is_self.is_some()
&& time.elapsed_secs() - *self_log > 3.0
&& std::env::var_os("WOW_INTERIOR_LOG").is_some()
{
if is_self.is_some() && time.elapsed_secs() - *self_log > 3.0 && interior_log_enabled() {
let p = gt.translation();
eprintln!(
"[self-node] at wow ({:.1}, {:.1}, {:.1}) t {:.2} -> {:.2} (I {:.2}) indoor {} \
@@ -296,9 +297,7 @@ pub(crate) fn update_ground_shade(
// `WOW_INTERIOR_LOG=1`: one line whenever a node's intensity target moves — the live
// instrument for "which stage is this character actually in?" — MCSH-shadowed 0.5 ⇒ t 1;
// exterior lit and day/night both ⇒ t 0.75 / committed 1.0 while the gain cap stands (0821).
if (target - shade.logged_target).abs() > f32::EPSILON
&& std::env::var_os("WOW_INTERIOR_LOG").is_some()
{
if (target - shade.logged_target).abs() > f32::EPSILON && interior_log_enabled() {
eprintln!(
"[node] root {root:?} at ({:.1}, {:.1}, {:.1}) -> target t {target:.2} \
(I {:.2}, indoor {}) from t {:.2}",
@@ -328,12 +327,11 @@ pub(crate) fn update_ground_shade(
let byte = (shade.t * 255.0).round().clamp(0.0, 255.0) as u8;
// Push to every part below the root (body submeshes are direct children; held items/helm ride
// joint entities deeper down — same full-tree walk as the self-fade). Change-gated per part on
// the byte, so a settled entity writes nothing and never re-triggers render extraction.
tree_shade.insert(root, byte);
// the byte, so a settled entity writes nothing and never re-triggers render extraction. The
// walk itself is unconditional: it is the MeshTag re-assert over the classifier's exterior
// reclaim (1358), not a change-only push.
root_shade.insert(root, byte);
for part in children.iter_descendants(root) {
// Recorded whether or not it is a drawable part: a card can follow a JOINT
// (`following_joint` — the swinging lamp's glow), and a joint carries no `MeshTag`.
tree_shade.insert(part, byte);
let Ok((mut tag, lit)) = parts.get_mut(part) else {
continue;
};
@@ -349,12 +347,12 @@ pub(crate) fn update_ground_shade(
// the reference shades every batch of an object through one node (0778) — but it is a world root,
// so the walk above skips it and it kept the lit rung while its owner dimmed. 0811 scoped this to
// GameObjects because 0809 had pinned units flat; 0814 put units back on the chase, so it is once
// again every carried card — a torch's flame card dims with the hand that holds it.
// again every carried card — a torch's flame card dims with the hand that holds it. The owner (an
// anchor or a deep joint) resolves to the NEAREST shaded root above it: with nested roots (a
// mounted unit — rider and mount each carry a node) that is the mount's, matching the
// one-node-per-object structure above; the old whole-tree map made this pick last-writer-wins.
for (card, mut tag) in &mut cards {
let Some(byte) = card
.follows()
.and_then(|owner| tree_shade.get(&owner).copied())
else {
let Some(byte) = card_root_shade(&root_shade, &child_of, card.follows()) else {
continue; // a fixed terrain doodad's card — its shade rides the material selector
};
if shade_of(tag.0) != byte {
@@ -363,6 +361,30 @@ pub(crate) fn update_ground_shade(
}
}
/// The shade byte a card inherits: the NEAREST shaded root at or above `follows` (the owner itself
/// first, then the `ChildOf` chain). `None` — no owner, or no shaded root over it — skips the card,
/// exactly as the old whole-tree map's miss did.
fn card_root_shade(
root_shade: &bevy::ecs::entity::EntityHashMap<u8>,
child_of: &Query<&ChildOf>,
follows: Option<Entity>,
) -> Option<u8> {
let mut node = follows?;
loop {
if let Some(&byte) = root_shade.get(&node) {
return Some(byte);
}
node = child_of.get(node).ok()?.parent();
}
}
/// `WOW_INTERIOR_LOG=1` — the interior/shade instrument lines. Resolved once: the raw env read ran
/// per root per frame.
fn interior_log_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("WOW_INTERIOR_LOG").is_some())
}
/// One linear ramp step toward a target, never past it (the binary's clamp-no-overshoot chase).
fn ramp_toward(v: f32, target: f32, step: f32) -> f32 {
if v < target {
@@ -371,3 +393,73 @@ fn ramp_toward(v: f32, target: f32, step: f32) -> f32 {
(v - step).max(target)
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::entity::EntityHashMap;
use bevy::ecs::system::SystemState;
/// Run the card resolve against a world's live `ChildOf` topology.
fn resolve(world: &mut World, map: &EntityHashMap<u8>, follows: Option<Entity>) -> Option<u8> {
let mut state: SystemState<Query<&ChildOf>> = SystemState::new(world);
card_root_shade(map, &state.get(world), follows)
}
/// A card's owner can sit arbitrarily deep in its root's tree (a held torch's flame card
/// follows a hand JOINT under an item entity under the unit) — the up-walk finds the root's
/// byte; the root itself, as an owner, is the walk's zero-hop case.
#[test]
fn a_card_takes_its_roots_byte_from_any_depth() {
let mut world = World::new();
let root = world.spawn_empty().id();
let a = world.spawn(ChildOf(root)).id();
let b = world.spawn(ChildOf(a)).id();
let c = world.spawn(ChildOf(b)).id();
let mut map = EntityHashMap::default();
map.insert(root, 7);
assert_eq!(resolve(&mut world, &map, Some(c)), Some(7), "3 deep");
assert_eq!(
resolve(&mut world, &map, Some(root)),
Some(7),
"the root itself"
);
}
/// No owner (a fixed terrain doodad's card), and an owner whose ancestor chain holds no shaded
/// root (a card of something this system doesn't shade) — both skip, like the old map miss.
#[test]
fn an_unshaded_card_is_skipped() {
let mut world = World::new();
let stray_root = world.spawn_empty().id();
let stray = world.spawn(ChildOf(stray_root)).id();
let mut map = EntityHashMap::default();
map.insert(world.spawn_empty().id(), 9);
assert_eq!(resolve(&mut world, &map, None), None, "follows nothing");
assert_eq!(
resolve(&mut world, &map, Some(stray)),
None,
"no shaded ancestor"
);
}
/// Nested shade roots — a mounted unit: rider root and mount root each carry a node, the mount
/// a descendant of the rider. A card under the MOUNT takes the mount's byte (the nearest root),
/// pinned here because the old whole-tree map answered this by insert order.
#[test]
fn a_nested_root_wins_by_nearness() {
let mut world = World::new();
let rider = world.spawn_empty().id();
let mount = world.spawn(ChildOf(rider)).id();
let mount_joint = world.spawn(ChildOf(mount)).id();
let mut map = EntityHashMap::default();
map.insert(rider, 3);
map.insert(mount, 9);
assert_eq!(
resolve(&mut world, &map, Some(mount_joint)),
Some(9),
"the MOUNT's byte"
);
assert_eq!(resolve(&mut world, &map, Some(mount)), Some(9));
}
}
+67 -21
View File
@@ -25,7 +25,7 @@ use bevy::render::render_graph::{
};
use bevy::render::render_resource::binding_types::{sampler, texture_2d, uniform_buffer_sized};
use bevy::render::render_resource::*;
use bevy::render::renderer::{RenderContext, RenderDevice};
use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue};
use bevy::render::texture::{CachedTexture, TextureCache};
use bevy::render::view::ViewTarget;
use bevy::render::{Render, RenderApp, RenderStartup, RenderSystems};
@@ -271,20 +271,32 @@ fn init_pipelines(
}
/// The two ¼-res ping-pong targets (the reference downsamples full→¼ in ONE Box4 pass —
/// wow-re blur-geometry.md; a ½ intermediate would be one downsample too many).
/// wow-re blur-geometry.md; a ½ intermediate would be one downsample too many), plus the
/// GPU objects derived from them, so the node does not recreate them per frame.
#[derive(Component)]
struct FfxGlowTextures {
quarter_a: CachedTexture,
quarter_b: CachedTexture,
/// The combine's 16-byte `(gain, death, haze, pad)` uniform — persistent, rewritten each
/// frame with a queue write (queue writes land before the graph's submit executes).
gain_buf: Buffer,
/// The two Gauss bind groups bind only the ¼-res ping-pong views + sampler — all stable
/// while the textures hold. The downsample and combine bind groups bind the view target's
/// post-process source, which `ViewTarget::post_process_write` flips per call, so those two
/// CANNOT be cached (they would sample last frame's texture) and stay per-frame in `run`.
gauss_h_bind: BindGroup,
gauss_v_bind: BindGroup,
}
fn prepare_textures(
mut commands: Commands,
mut texture_cache: ResMut<TextureCache>,
render_device: Res<RenderDevice>,
views: Query<(Entity, &ExtractedCamera), With<FfxGlow>>,
pipeline_cache: Res<PipelineCache>,
pipelines: Res<FfxGlowPipelines>,
views: Query<(Entity, &ExtractedCamera, Option<&FfxGlowTextures>), With<FfxGlow>>,
) {
for (entity, camera) in &views {
for (entity, camera, existing) in &views {
let Some(vp) = camera.physical_viewport_size else {
continue;
};
@@ -310,9 +322,38 @@ fn prepare_textures(
};
let quarter_a = tex("ffx_glow_quarter_a", vp.x / 4, vp.y / 4);
let quarter_b = tex("ffx_glow_quarter_b", vp.x / 4, vp.y / 4);
// `TextureCache::get` hands the same textures back while the viewport holds, and the
// derived objects only depend on them — rebuilding the component anyway would be right
// back to per-frame bind-group creation.
if existing.is_some_and(|t| {
t.quarter_a.texture.id() == quarter_a.texture.id()
&& t.quarter_b.texture.id() == quarter_b.texture.id()
}) {
continue;
}
let layout_filter = pipeline_cache.get_bind_group_layout(&pipelines.layout_filter);
let gauss_h_bind = render_device.create_bind_group(
"ffx_glow_gauss_h",
&layout_filter,
&BindGroupEntries::sequential((&quarter_a.default_view, &pipelines.sampler)),
);
let gauss_v_bind = render_device.create_bind_group(
"ffx_glow_gauss_v",
&layout_filter,
&BindGroupEntries::sequential((&quarter_b.default_view, &pipelines.sampler)),
);
let gain_buf = render_device.create_buffer(&BufferDescriptor {
label: Some("ffx_glow_gain"),
size: 16,
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
commands.entity(entity).insert(FfxGlowTextures {
quarter_a,
quarter_b,
gain_buf,
gauss_h_bind,
gauss_v_bind,
});
}
}
@@ -352,42 +393,46 @@ impl ViewNode for FfxGlowNode {
return Ok(()); // pipelines still compiling — draw the frame un-glowed
};
let render_device = render_context.render_device().clone();
let gain_buf = render_device.create_buffer_with_data(&BufferInitDescriptor {
label: Some("ffx_glow_gain"),
contents: bytemuck::cast_slice(&[gain, death, haze, 0.0]),
usage: BufferUsages::UNIFORM,
});
world.resource::<RenderQueue>().write_buffer(
&textures.gain_buf,
0,
bytemuck::cast_slice(&[gain, death, haze, 0.0]),
);
let post = view_target.post_process_write();
let layout_filter = pipeline_cache.get_bind_group_layout(&pipelines.layout_filter);
let layout_combine = pipeline_cache.get_bind_group_layout(&pipelines.layout_combine);
// The downsample binds `post.source`, which `post_process_write` flips per call — this
// bind group cannot be cached (it would sample last frame's texture); the two Gauss ones
// bind only the stable ¼-res ping-pong and ride prepared on [`FfxGlowTextures`].
let down_bind = render_device.create_bind_group(
"ffx_glow_down_quarter",
&layout_filter,
&BindGroupEntries::sequential((post.source, &pipelines.sampler)),
);
// The filter passes (byte-pinned chain): source→¼ (one Box4), ¼a→¼b (H), ¼b→¼a (V).
let filter_passes: [(&str, &RenderPipeline, &TextureView, &TextureView); 3] = [
let filter_passes: [(&str, &RenderPipeline, &BindGroup, &TextureView); 3] = [
(
"ffx_glow_down_quarter",
downsample,
post.source,
&down_bind,
&textures.quarter_a.default_view,
),
(
"ffx_glow_gauss_h",
gauss_h,
&textures.quarter_a.default_view,
&textures.gauss_h_bind,
&textures.quarter_b.default_view,
),
(
"ffx_glow_gauss_v",
gauss_v,
&textures.quarter_b.default_view,
&textures.gauss_v_bind,
&textures.quarter_a.default_view,
),
];
for (label, pipeline, src, dst) in filter_passes {
let bind = render_device.create_bind_group(
label,
&layout_filter,
&BindGroupEntries::sequential((src, &pipelines.sampler)),
);
for (label, pipeline, bind, dst) in filter_passes {
let mut pass =
render_context
.command_encoder()
@@ -404,11 +449,12 @@ impl ViewNode for FfxGlowNode {
occlusion_query_set: None,
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &bind, &[]);
pass.set_bind_group(0, bind, &[]);
pass.draw(0..3, 0..1);
}
// Combine: screen + w·blur² (gamma-space byte math in the shader) → the post destination.
// Also binds the flipping `post.source` — per-frame for the downsample's reason.
let bind = render_device.create_bind_group(
"ffx_glow_combine",
&layout_combine,
@@ -416,7 +462,7 @@ impl ViewNode for FfxGlowNode {
post.source,
&pipelines.sampler,
&textures.quarter_a.default_view,
gain_buf.as_entire_binding(),
textures.gain_buf.as_entire_binding(),
)),
);
let mut pass = render_context
+14
View File
@@ -50,6 +50,20 @@ pub struct WorldObject {
pub detail: String,
}
/// Marker on every pickable **GameObject** part — the GO mouseover pick's population
/// (`target/hover.rs`). Inserted beside the part's `WorldObject { kind: GameObject }` at the
/// attach spawn sites, so building the pick set is an archetype-filtered query over the handful
/// of GO parts on screen instead of a kind-compare over every streamed `WorldObject` row (the
/// doodad/WMO population dwarfs it).
#[derive(Component, Clone, Copy)]
pub struct GoPickPart;
/// The creature twin of [`GoPickPart`]: every pickable unit/player part — the model-less fallback
/// cube included — the unit pick's skinless-fallback population. Doodad/WMO parts carry neither
/// marker.
#[derive(Component, Clone, Copy)]
pub struct CreaturePickPart;
/// A left *select* gesture in the world. Emitted by [`crate::player::control`] on the button's
/// **release**, when the press satisfied the reference's click predicate — under 200 ms whatever the
/// mouse did, or under 800 ms having turned the camera less than 2.25° of yaw / 2.0° of pitch
+25 -11
View File
@@ -204,22 +204,36 @@ impl Plugin for WorldFoundation {
// read, whose defaults ARE the player behaviour. The debug panel is only its editor
// and may not be installed at all.
.init_resource::<crate::dev_state::DebugState>();
// `WOW_NO_PHYS_PREPROP=1` — skip avian's own copy of Bevy's transform propagation in
// FixedPostUpdate. An EXPERIMENT knob (the 1370 bracket): `PhysicsTransformPlugin`
// re-registers `mark_dirty_trees`/`propagate_parent_transforms`/`sync_simple_transforms`
// to run before each physics step — a second whole-world transform sweep on top of
// PostUpdate's own. Our world is static geometry plus kinematic movers that write
// `Position` directly, so the pre-step copy is suspected dead work; the gate is a
// runtime `run_if` on this resource, so inserting it IS the whole mechanism. Known
// semantic edge while on: a collider whose `Transform` moves during Update reaches
// physics with last frame's `GlobalTransform` (one-frame lag) — a measurement lever,
// never a setting.
if std::env::var_os("WOW_NO_PHYS_PREPROP").is_some() {
// Avian's pre-step copy of Bevy's transform propagation is OFF by default (the 1370
// bracket surfaced the lane; the 3-round SW split then measured the skip at −0.40
// cpu_ms, negative in every round): `PhysicsTransformPlugin` re-registers
// `mark_dirty_trees`/`propagate_parent_transforms`/`sync_simple_transforms` to run
// before each physics step — a second whole-world transform sweep on top of
// PostUpdate's own. Our world is static geometry plus kinematic movers
// (`transport::tick_transports`) that mirror `Position`/`Rotation` directly — built
// that way precisely because the sync's own timing already lagged — so the pre-step
// sweep buys nothing. The one Transform-moved collider class left is an opening door
// (a GameObject's model-local hull riding its pose): it reaches physics one frame late
// via PostUpdate's own propagation — transient, bounded, imperceptible.
// `WOW_PHYS_PREPROP=1` restores avian's upstream default (the A/B lever back).
if std::env::var_os("WOW_PHYS_PREPROP").is_none() {
app.insert_resource(avian3d::physics_transform::PhysicsTransformConfig {
propagate_before_physics: false,
..Default::default()
});
}
// `WOW_PHYS_HZ=<hz>` — run the fixed loop at a different rate. An EXPERIMENT lever
// (1370 item 10): avian's per-tick stack is the fixed loop's ONLY occupant (nothing
// first-party lives in any fixed schedule — zero-hit grep, re-verified at flip time),
// so 8 vs the default 64 prices the whole-avian bracket in one leg. A measurement
// lever, never a setting: at 8 Hz the spatial trees ingest streamed colliders up to
// 125 ms late, which the player's shape-cast controller would feel at a tile seam.
if let Some(hz) = std::env::var("WOW_PHYS_HZ")
.ok()
.and_then(|v| v.parse::<f64>().ok())
{
app.insert_resource(bevy::time::Time::<bevy::time::Fixed>::from_hz(hz));
}
// Direct draws on EVERY camera — the default, not a knob (1374/1376). Bevy's indirect
// lane is a per-draw indirect encode loop on Metal (no MULTI_DRAW_INDIRECT) plus the GPU
// preprocessing dispatches, and the 1374 bracket priced it ~4.3 cpu_ms at LBRS on 0.18 —