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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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![]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 —
|
||||
|
||||
Reference in New Issue
Block a user