liquids keep time in the shader, and the frame is measured CPU-bound

- water and lava keep time in the shader: the 24 Hz CPU tick that re-uploaded every liquid material is gone, −0.28 ms at the Stormwind gates, and lava scrolls smoothly instead of stepping
- static transform tracking is pinned on — the adaptive check spent two full scans a frame to say yes in a world where movers are 1–3% of the rows
- WOW_MEGA_STATIC merges static geometry per material and 133-yard cell — an experiment lever, priced at −1.15 ms CPU for +0.3 ms GPU at the Stormwind gates
- WOW_GPU_MS times the whole frame on the GPU and reads 2.2 ms against 17.4 CPU at the Stormwind gates: the frame is CPU-bound, so the work ahead is on the CPU side

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-20 18:50:55 +07:00
co-authored by Claude Fable 5
parent 19e861bb96
commit a822d99bd4
25 changed files with 837 additions and 118 deletions
@@ -163,6 +163,9 @@ impl UiScript {
pub fn message_lines_needing_measure(&mut self) -> Vec<LineMeasureRequest> {
use std::hash::{Hash, Hasher};
let mut model = self.model_mut();
// A plain reborrow so the arena borrow inside the loop and the sweep-token field writes
// split by FIELD instead of fighting over the RefMut.
let model = &mut *model;
let mut out = Vec::new();
let frames: Vec<(FrameHandle, Rect)> = model
.resolved
@@ -185,7 +188,7 @@ impl UiScript {
.frame(fh)
.map(|f| f.effective_scale)
.unwrap_or(1.0);
let font = Self::message_frame_font(&model, fh);
let font = Self::message_frame_font(model, fh);
let frame_id = model.frame_id(fh);
let Some(frame) = model.arena.frame(fh) else {
continue;
@@ -193,7 +196,30 @@ impl UiScript {
let Some(lines) = frame.kind_state.message_lines() else {
continue;
};
// The skip token (the W4 fix, 1410's lane one door over): a frame whose line set
// (lines_gen) and measure environment (font/wrap/scale/outline) both match its last
// CLEAN sweep can produce nothing — every line's rows_key was minted under exactly
// these inputs and matched then. Environment changes (a resize, a font swap, a
// SetScale) miss the env hash; text changes miss the generation. The token is only
// stored by a zero-request sweep (below), so an unanswered request keeps
// re-requesting — the same rule the region ledger runs under.
let lines_gen = frame.kind_state.lines_gen().unwrap_or(0);
let env = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
font.path.hash(&mut h);
font.height.map(f32::to_bits).hash(&mut h);
wrap_width.to_bits().hash(&mut h);
(font.outline as u8).hash(&mut h);
scale.to_bits().hash(&mut h);
h.finish()
};
if model.msg_swept.get(&fh) == Some(&(lines_gen, env)) {
continue;
}
let requests_before = out.len();
for (index, line) in lines.iter().enumerate() {
model.msg_lines_hashed += 1;
let mut hasher = std::collections::hash_map::DefaultHasher::new();
line.text.hash(&mut hasher);
font.path.hash(&mut hasher);
@@ -217,6 +243,11 @@ impl UiScript {
key,
});
}
if out.len() == requests_before {
model.msg_swept.insert(fh, (lines_gen, env));
} else {
model.msg_swept.remove(&fh);
}
}
out
}
@@ -695,4 +695,57 @@ mod tests {
s.run("CF:Clear()").unwrap();
assert_eq!(s.eval::<i64>("return CF:GetNumMessages()").unwrap(), 0);
}
/// The W4 skip token: a settled message frame's sweep hashes ZERO lines (the counter is the
/// claim — 0735's counts rule), and each side of the token catches its own change class:
/// text through the generation, the resolved width through the environment hash. The token
/// is only stored by a zero-request sweep, so an UNANSWERED request keeps re-requesting —
/// the region ledger's own rule (1410), inherited.
#[test]
fn a_settled_message_frame_hashes_no_lines_until_something_moves() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s.run(
"local f = CreateFrame('ScrollingMessageFrame', 'CF')\n\
f:SetPoint('BOTTOMLEFT', 32, 90)\n\
f:SetWidth(430)\n\
f:SetHeight(34)",
)
.unwrap();
s.run("CF:AddMessage('one', 1, 1, 1)").unwrap();
s.run("CF:AddMessage('two', 1, 1, 1)").unwrap();
s.resolve();
// Unanswered requests re-request: no token may be stored while keys mismatch.
let first = s.message_lines_needing_measure();
assert_eq!(first.len(), 2);
let again = s.message_lines_needing_measure();
assert_eq!(again.len(), 2, "unanswered lines must keep re-requesting");
let answers: Vec<(u32, u32, u16, u64)> =
again.iter().map(|r| (r.frame, r.index, 1, r.key)).collect();
s.set_message_line_rows(&answers);
assert!(s.message_lines_needing_measure().is_empty());
// Settled: the NEXT sweep must skip the frame whole — zero lines hashed.
let hashed_before = s.model_mut().msg_lines_hashed;
assert!(s.message_lines_needing_measure().is_empty());
assert_eq!(
s.model_mut().msg_lines_hashed,
hashed_before,
"a settled frame's sweep must hash no lines"
);
// Text change reopens through the GENERATION…
s.run("CF:AddMessage('three', 1, 1, 1)").unwrap();
let reqs = s.message_lines_needing_measure();
assert_eq!(reqs.len(), 1, "the new line re-surfaces");
assert_eq!(reqs[0].text, "three");
let answers: Vec<(u32, u32, u16, u64)> =
reqs.iter().map(|r| (r.frame, r.index, 1, r.key)).collect();
s.set_message_line_rows(&answers);
assert!(s.message_lines_needing_measure().is_empty());
// …and a resolved-width change reopens through the ENVIRONMENT: every line re-keys.
s.run("CF:SetWidth(300)").unwrap();
s.resolve();
let reqs = s.message_lines_needing_measure();
assert_eq!(reqs.len(), 3, "a new wrap width re-measures every line");
assert!(reqs.iter().all(|r| (r.wrap_width - 300.0).abs() < 0.5));
}
}