scrolling chat brings lines back, and Onyxia's lava traps spurt again

- every chat scroll re-arms the displayed lines' fade — up, down, top, bottom, page up/down, and a no-op scroll at the bottom too, which is what clicking the arrows on an already-at-bottom chat does; a line at alpha 0 was unreachable for the rest of the session (play two minutes and the chat was empty and stayed empty), because "scrolling off the bottom freezes all fade countdowns; nothing un-fades" had been read as a law about the class when it was a finding about one function's gate; the displayed set only, never the whole ring, with the frame's current defaults, as the reference's re-arm writes them; there is no hover reveal — the scroll re-arm is the whole mechanism; and the fade ramp reads the un-rounded countdown, not the stored f32
- the GameObject animation arm rolls a variation: Onyxia's 104 lava traps and 26 falling rocks author Stand twice — a 29491 : 3276 chain whose second take is the `LAVALUMP2` ember burst — and the arm is issued with `variationIdx = −1`, a weighted random walk of the alias chain, where we resolved the chain head every time (215 emitters, every one at live = 0); and a resting substate re-arms itself through the model's completion callback, so the roll is re-taken every window for ever (~867 ms for the traps) — 45 emitters firing and 1299 live particles at +25 s, a standing field rather than a one-time flurry; 72 of 1582 GameObject models author such a chain (the fish schools, the AQ fireflies, the plague cauldrons, lanterns, mugs) and every one was playing take 1 of N; `benilla-extract goanimscan` censuses it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-29 17:23:44 +07:00
co-authored by Claude Fable 5
parent 164b0a7a44
commit 27bd2670aa
5 changed files with 547 additions and 125 deletions
+284 -69
View File
@@ -72,7 +72,7 @@ use bevy::animation::RepeatAnimation;
use bevy::prelude::*;
use std::time::Duration;
use crate::creature_anim::{advance_track, scan_events, AnimSoundEvent};
use crate::creature_anim::{advance_track, scan_events, select, AnimSoundEvent};
use crate::net::{GuidIndex, ObjectStore};
use benilla_world::schedule::WorldStage;
@@ -131,7 +131,37 @@ pub(crate) struct GoAnim {
/// 5 Destroy / 7 Rebuild) and the Custom0..3 block (8..11). [`retire_transient_anim`] models
/// the advance for both; without it a bit-0-clear clip runs for ever — the bobber's splash
/// looping ~1.3 s (1100's 2-3 audible splashes) and the crate lid never settling shut (1151).
transient: Option<u16>,
transient: Option<Transient>,
/// The **rest** pose's armed clip node, when that pose is on the re-arm cycle below. Distinct
/// from [`Self::transient`], which is the reference's ONE transient-substate slot
/// (`[handler+0x10]`) and drives the §2d *advance*: a rest pose never advances, it re-arms
/// **itself**. `None` while a transient owns the model, on the rate-0 frozen leg (which never
/// completes), and once the §2c already-playing check has refused the re-arm and the pose has
/// settled for good.
rest_window: Option<AnimationNodeIndex>,
/// The **requested** animation id currently armed — the reference's `[block0+0xf8]`, written by
/// op4 (`0x71252f`) and read back by `0x712090(model, -1)`. The §2c *remap* legs consult it
/// (`0x5f39fa: cmp esi,eax; je 0x5f3b32`) and refuse an arm that would request what is already
/// playing; the model-owns-it leg (`0x5f396c jne`) and the collapse-to-Stand leg
/// (`0x5f3a54 jmp 0x5f3a0b`, past the check) do not. That asymmetry is the whole difference
/// between a prop that **settles** and one that **cycles** — see [`drive_go_anim`].
armed_id: Option<u16>,
}
/// The **armed** transient clip — its `AnimationData.dbc` id *and* the graph node the arm actually
/// landed on. The id alone was enough while every arm took a model's **head** variation; it stops
/// being enough now that the GameObject arm rolls one (`variationIdx = -1`, see [`drive_go_anim`]),
/// because two sequences sharing an id are two different nodes and only one of them is playing.
/// [`retire_transient_anim`] must watch the node that was armed, or it retires against a sibling
/// variation's (idle) player state and ends the substate on the wrong frame — immediately, for a
/// variation that was never played.
#[derive(Clone, Copy)]
struct Transient {
/// The `AnimationData.dbc` id — the reference's `[handler+0x10]` substate identity, what the
/// inspector and the despawn pin compare against.
id: u16,
/// The graph node of the clip actually armed: this id's *rolled* variation, not its head.
node: AnimationNodeIndex,
}
/// The GameObject's **stored** state — the binary's `go+0x27c`, which is what every consumer reads
@@ -171,7 +201,7 @@ pub(crate) fn armed_anim(
.min_by(|a, b| a.1.seek_time().total_cmp(&b.1.seek_time()))?;
Some((
clip.anim_id,
go.transient == Some(clip.anim_id),
go.transient.is_some_and(|t| t.id == clip.anim_id),
active.repeat_mode(),
))
}
@@ -524,7 +554,7 @@ fn release_despawn_pin(
pinned: Query<(Entity, Option<&GoAnim>), With<PendingDestroy>>,
) {
for (e, go) in &pinned {
if go.is_some_and(|g| g.transient == Some(ANIM_DESPAWN)) {
if go.is_some_and(|g| g.transient.is_some_and(|t| t.id == ANIM_DESPAWN)) {
continue;
}
commands.entity(e).try_despawn();
@@ -570,7 +600,7 @@ fn close_go_lid(
/// | 5 Destroy | 6 Destroyed | |
/// | 7 Rebuild | 1 Closed | |
/// | 0 Spawn, 8..11 Custom0-3 | re-run at the current state (`0x5f4190`) | |
/// | 1/3/6 (a rest pose) | nothing (`0x5f4167`) | a held pose never advances |
/// | 1/3/6 (a rest pose) | **itself** (`0x5f4167`) | the held pose re-arms, with a fresh variation roll |
///
/// **This — not the animation kernel — is what turns a transition clip into a resting state**, and
/// it is why the kernel's loop bit is not the transition's duration: `G_Crate01`'s Close is
@@ -579,27 +609,50 @@ fn close_go_lid(
/// landing one frame after the 1333 ms window — before the looping kernel's second `$GC0` crossing
/// at 1533 ms. Net law: one splash per 0xB3, one swing per state change, then the state pose.
///
/// Benilla arms every transient clip `Never`-repeat (one window, the same endpoint), so "the
/// **The last row is not "nothing", and 1151 read it as such.** `0x5f4167` is byte-for-byte slot
/// 34's own selection and calls `0x5f3930` **directly**, bypassing slot 34's change guard on an
/// unchanged `[handler+0x10]` — so a resting substate re-issues its own op4, and with it a fresh
/// `variationIdx = -1` roll, every `span × R` ms for ever (wow-re `gameobject-anim-arm.md` §6c/§6d,
/// §5-arbitrated). The completion that drives it ignores the loop bit (`0x719503` tests it only
/// after the notify block), so a bit-0-CLEAR pose completes too. That is why Onyxia's lava traps
/// spurt *continuously* rather than once at stream-in, and it is the same law `loop-replay-fidget.md`
/// §7 and `doodad-anim-host.md` §5 already carried for units and placed doodads — the GameObject
/// note was the outlier.
///
/// Benilla therefore arms EVERY clip `Never`-repeat (one window, the same endpoint), so "the
/// window ended" is the player's finished flag; the retire then clears `shown`, which makes the
/// state arm of [`drive_go_anim`] re-resolve the current state as a fresh rest pose — our
/// state-machine re-run, and (for a motion) exactly the table above, since `rest_anim(state)` is
/// the destination row of whichever motion that state's change armed. Runs before
/// [`drive_go_anim`] in the chain so the re-arm lands the same frame. Reads never deref-mut, so a
/// quiet GO stays out of the Changed stream.
fn retire_transient_anim(mut gos: Query<(&mut GoAnim, &AnimationPlayer, &ModelAnimations)>) {
for (mut go, player, anims) in &mut gos {
let Some(id) = go.transient else {
continue;
fn retire_transient_anim(mut gos: Query<(&mut GoAnim, &AnimationPlayer)>) {
for (mut go, player) in &mut gos {
// The ARMED node, not `find(id)`'s head variation — see [`Transient::node`].
let done = |n| {
player
.animation(n)
.is_none_or(bevy::animation::ActiveAnimation::is_finished)
};
let done = anims
.find(id)
.is_none_or(|clip| player.animation(clip.node).is_none_or(|a| a.is_finished()));
if done {
go.transient = None;
// The completion's state-machine re-run: forget the shown pose so the state arm
// re-resolves it (a silent rest snap — `resolve(None, state)`), exactly the
// reference's re-arm over the finished transient block.
go.shown = None;
if let Some(t) = go.transient {
if done(t.node) {
go.transient = None;
// The completion's state-machine re-run: forget the shown pose so the state arm
// re-resolves it (a silent rest snap — `resolve(None, state)`), exactly the
// reference's re-arm over the finished transient block.
go.shown = None;
}
continue;
}
// A REST pose completes too, and its completion re-arms *itself* — see [`drive_go_anim`]'s
// rest arm. Same device: forget the shown pose and the state arm re-resolves it, rolling a
// fresh variation. `rest_window` is cleared first so a refused re-arm settles instead of
// spinning.
if let Some(node) = go.rest_window {
if done(node) {
go.rest_window = None;
go.shown = None;
}
}
}
}
@@ -617,6 +670,9 @@ fn drive_go_anim(
),
Changed<GoAnim>,
>,
// The client's single `_rand` stream (wow-re `rf36-rand-stub.md`), as `creature_anim` keeps
// one: op4's variation roll draws from it on every GameObject arm below.
mut rng: Local<u32>,
) {
for (mut go, mut player, mut tr, anims) in &mut gos {
// ── The §243 state arm ─────────────────────────────────────────────────────────────────
@@ -628,20 +684,47 @@ fn drive_go_anim(
// exactly ONE (`[handler+0x10]`), so an old motion's completion can never fire
// over the pose that superseded it.
go.transient = None;
go.rest_window = None;
if let Some(play) = resolve(prev, state) {
// Resolve the id to this model's clip (keyed by AnimationData.dbc id, as
// `creature_anim` does), through the §2c remap for a model that doesn't author
// it — that is what keeps a lidless model on a real pose instead of bind.
let (want, frozen) = remap_missing(anims, play.anim_id());
if let Some(clip) = anims.find(want) {
// **`variationIdx = -1` — the arm ROLLS a variation** (wow-re
// `gameobject-anim-arm.md` §2c, `0x5f3aee: push -1`), unlike the §1 loader seed
// beneath it, which passes an explicit `0` (`0x710189`). Where a model authors
// one sequence per id the two are the same clip and nothing changes; where it
// authors a chain they are not, and taking the head is a whole authored
// behaviour that never plays. Onyxia's lava traps are the case that named it:
// `ONYZIASLAIRLAVATRAP.M2` authors Stand twice — seq 0 (`freq` 29491, looping,
// silent) and seq 1 (`freq` 3276, one-shot) — and it is seq **1** that carries
// the 300/s `LAVALUMP2` burst, the lava spurting out of the floor. Every one of
// the 208 trap GameObjects in the lair took the head, so the chamber's whole
// ember field was missing.
// **The §2c already-playing skip, and it decides settle-vs-cycle.** Three
// edges reach the arm (`0x5f3a0b`): the model OWNS the table id
// (`0x5f396c jne`), the model is not live, and the 147-absent collapse to
// Stand (`0x5f3a52 xor esi,esi; 0x5f3a54 jmp 0x5f3a0b` — a backwards jump
// PAST the check). Only the other remap legs fall through `0x5f39fa`'s
// `cmp esi,eax; je 0x5f3b32`, and there a re-arm requesting what is already
// armed is refused — so a prop the remap sent to a *non-zero* substitute
// settles on it (the fishing bobber), while one that owns its id or collapsed
// to Stand re-arms, and re-rolls, every window (the lava trap).
let skip =
want != 0 && !anims.owns(play.anim_id()) && go.armed_id == Some(want);
if skip {
continue;
}
if let Some(clip) = anims.pick_variation(want, select::msvc_rand(&mut rng)) {
go.armed_id = Some(want);
// Snap a rest pose (blend 0 — a stream-in must not swing); ease a motion
// over its authored blend.
let blend = match play {
Play::Rest(_) => 0.0,
Play::Motion(_) => clip.blend_time.max(0.0),
};
let active =
tr.play(&mut player, clip.node, Duration::from_secs_f32(blend));
let node = clip.node;
let active = tr.play(&mut player, node, Duration::from_secs_f32(blend));
if frozen {
// A motion standing in for a missing rest pose: the reference arms it
// at playback rate 0, so it holds frame 0 forever — the pose that
@@ -655,25 +738,26 @@ fn drive_go_anim(
// clock but keeps `speed` — so re-arming a node a frozen leg previously
// parked at rate 0 (the same Open clip serves both) would stay stuck.
active.set_speed(1.0);
// **The transition motion is ONE window, whatever the loop bit says**
// (decision 1151): it is a transient substate, ended by the object
// layer's §2d advance ([`retire_transient_anim`]), never by the
// kernel — whose bit-0-clear branch wraps the band for ever and would
// make the crate lid spring open and slam shut ~1.5×/s. A *rest* pose
// is the opposite: nothing advances off it (slot 14's `0x5f4167`), so
// it holds or loops exactly as the model authored it.
// **ONE baked window, rest pose included** (decision 1151 for the
// motions; wow-re
// `gameobject-anim-arm.md` §6, correcting 1151's "nothing advances off
// a held pose"). The completion notification `0x719370` fires at
// `span × R` **without consulting the loop bit** (`0x719503` tests it
// only afterwards), so a bit-0-CLEAR rest clip completes too — and
// slot 14's row for a resting substate, `0x5f4167`, calls `0x5f3930`
// *directly*, past slot 34's change guard, on an unchanged substate.
// The arm is therefore re-issued every ~`span` ms with a fresh
// `variationIdx = -1` roll, for ever. For a single-variation id that
// is the loop it always was, restarted at its own band start; for a
// chain it is the whole point — Onyxia's traps re-roll every 867 ms,
// so ~10 % of ~200 of them light a 5 s lava plume at any moment
// instead of one flurry at stream-in.
active.set_repeat(RepeatAnimation::Never);
match play {
Play::Motion(_) => {
active.set_repeat(RepeatAnimation::Never);
go.transient = Some(want);
}
Play::Rest(_) => {
active.set_repeat(if clip.looping {
RepeatAnimation::Forever
} else {
RepeatAnimation::Never
});
go.transient = Some(Transient { id: want, node });
}
Play::Rest(_) => go.rest_window = Some(node),
}
}
}
@@ -697,15 +781,21 @@ fn drive_go_anim(
if go.one_shot.is_some() {
let id = go.one_shot.take().expect("checked is_some");
if anims.owns(id) {
if let Some(clip) = anims.find(id) {
// Slot 15 funnels through the same slot-34 → `0x5f3930` arm as the state channel
// (§2c), so it rolls a variation for the same reason — a Custom0 with two authored
// takes alternates them.
if let Some(clip) = anims.pick_variation(id, select::msvc_rand(&mut rng)) {
go.armed_id = Some(id);
go.rest_window = None;
let node = clip.node;
let active = tr.play(
&mut player,
clip.node,
node,
Duration::from_secs_f32(clip.blend_time.max(0.0)),
);
active.set_speed(1.0);
active.set_repeat(RepeatAnimation::Never);
go.transient = Some(id);
go.transient = Some(Transient { id, node });
}
}
}
@@ -917,17 +1007,17 @@ mod tests {
/// `benilla-formats/tests/m2_go_crate_lid.rs`): Open/Opened/Close/Closed, **all four `looping`**
/// — `flags` bit 0 clear — with an empty replay range. Blend times are zeroed so the arm is a
/// cut and the assertions read the armed clip, not a fade.
const CRATE_FAMILY: [(u16, f32); 4] = [
(0x94, 0.666), // 148 Open — the lid sweeps 0° → 75°
(0x95, 0.100), // 149 Opened — holds 75°
(0x92, 0.667), // 146 Close — sweeps 75° → 0°
(0x93, 0.167), // 147 Closed — holds 0°
const CRATE_FAMILY: [(u16, f32, u16); 4] = [
(0x94, 0.666, 0), // 148 Open — the lid sweeps 0° → 75°
(0x95, 0.100, 0), // 149 Opened — holds 75°
(0x92, 0.667, 0), // 146 Close — sweeps 75° → 0°
(0x93, 0.167, 29491), // 147 Closed — holds 0° (the chain HEAD, weighted like the traps')
];
/// An app running the two systems this file's law lives in, plus one GameObject wearing a
/// crate's animation set: REAL `AnimationClip` assets and a real graph, so Bevy's own
/// `advance_animations` ticks the completions [`retire_transient_anim`] watches.
fn crate_app(extra_ids: &[(u16, f32)]) -> (App, Entity) {
fn crate_app(extra_ids: &[(u16, f32, u16)]) -> (App, Entity) {
use bevy::animation::graph::{AnimationGraph, AnimationGraphHandle};
use bevy::animation::AnimationClip;
@@ -950,10 +1040,11 @@ mod tests {
.chain(),
);
let authored: Vec<(u16, f32)> = CRATE_FAMILY.iter().chain(extra_ids).copied().collect();
let authored: Vec<(u16, f32, u16)> =
CRATE_FAMILY.iter().chain(extra_ids).copied().collect();
let handles: Vec<_> = authored
.iter()
.map(|&(_, dur)| {
.map(|&(_, dur, _)| {
let mut c = AnimationClip::default();
c.set_duration(dur);
app.world_mut()
@@ -968,15 +1059,19 @@ mod tests {
.add(graph);
let mut lookup = vec![0xffffu16; 160];
for (slot, &(id, _)) in authored.iter().enumerate() {
lookup[id as usize] = slot as u16;
for (slot, &(id, _, _)) in authored.iter().enumerate() {
// A variation chain shares one id, and `animationLookup` holds its HEAD — so only the
// first slot claiming an id writes here, exactly as the exporter bakes it.
if lookup[id as usize] == 0xffff {
lookup[id as usize] = slot as u16;
}
}
let anims = ModelAnimations {
graph: graph.clone(),
clips: authored
.iter()
.zip(&nodes)
.map(|(&(id, dur), &node)| benilla_assets::AnimClip {
.map(|(&(id, dur, frequency), &node)| benilla_assets::AnimClip {
anim_id: id,
seq_index: 0,
node,
@@ -993,7 +1088,7 @@ mod tests {
events: Vec::new().into(),
arm_nodes: None,
upper_node: None,
frequency: 0,
frequency,
replay: (0, 0),
poses_bones: true,
})
@@ -1059,11 +1154,13 @@ mod tests {
.state = Some(s);
};
// Streamed in closed: the rest pose, snapped, and it keeps the loop the model authored —
// nothing advances off a held pose (`0x5f4167`), so this leg must NOT be narrowed to one
// window along with the motions.
// Streamed in closed: the rest pose, snapped. It is ONE baked window like everything else
// — `0x5f4167` re-arms a resting substate through `0x5f3930` at its own completion, so the
// pose repeats by re-arming (and re-rolling its variation), not by the kernel's loop bit
// (wow-re `gameobject-anim-arm.md` §6; this corrects 1151's "nothing advances off a held
// pose"). What must hold across those windows is the armed ID, asserted below.
app.update();
assert_eq!(armed(&app, go), Some((0x93, RepeatAnimation::Forever)));
assert_eq!(armed(&app, go), Some((0x93, RepeatAnimation::Never)));
// The open-lock cast lands: the lid swings open, ONE window.
state(&mut app, GO_STATE_ACTIVE);
@@ -1079,7 +1176,7 @@ mod tests {
app.update();
assert_eq!(
armed(&app, go),
Some((0x95, RepeatAnimation::Forever)),
Some((0x95, RepeatAnimation::Never)),
"the completion advance settles the swing onto the Opened rest pose"
);
@@ -1088,34 +1185,152 @@ mod tests {
app.update();
assert_eq!(armed(&app, go), Some((0x92, RepeatAnimation::Never)));
// 4 Close → 1 Closed, and then it STAYS there. Pre-1151 the Close clip was armed
// `Forever`, so this is the assertion the director's report failed at.
// 4 Close → 1 Closed, and then it STAYS there — through many completion re-arms of the
// Closed pose itself. Pre-1151 the Close *motion* was armed `Forever`, so this is the
// assertion the director's crate report failed at, and the re-arm cycle must not
// resurrect it.
advance(&mut app, 700);
app.update();
assert_eq!(
armed(&app, go),
Some((0x93, RepeatAnimation::Forever)),
Some((0x93, RepeatAnimation::Never)),
"the lid must settle on Closed"
);
for _ in 0..20 {
advance(&mut app, 100);
assert_eq!(
armed(&app, go),
Some((0x93, RepeatAnimation::Forever)),
Some((0x93, RepeatAnimation::Never)),
"two seconds on — three Close windows — the crate is still shut"
);
}
}
/// **The arm rolls a VARIATION** — `variationIdx = -1` (wow-re `gameobject-anim-arm.md` §2c,
/// `0x5f3aee: push -1`), not the id's head. The §1 loader seed under it takes an explicit
/// variation 0 (`0x710189`), so a model's second and later takes are reachable ONLY through
/// this arm; resolving the id with `find()` made them unreachable everywhere.
///
/// The subject is Onyxia's lava traps in miniature: a Closed chain authored 29491 : 3276, where
/// the 10 %-weighted second take is the one that does something (there, a 300/s `LAVALUMP2`
/// burst — the lava spurting out of the lair floor; 208 trap GameObjects all played the silent
/// head). Spawning many objects makes the roll observable without a statistical test on one:
/// the count must be neither 0 (the bug) nor all (a broken walk), and near the authored tenth.
#[test]
fn the_arm_rolls_a_weighted_variation_not_the_head() {
const N: usize = 400;
let (mut app, go) = crate_app(&[(0x93, 0.866, 3276)]);
let (anims, player, tr, graph) = {
let e = app.world().entity(go);
(
e.get::<ModelAnimations>().unwrap().clone(),
AnimationPlayer::default(),
AnimationTransitions::new(),
e.get::<bevy::animation::graph::AnimationGraphHandle>()
.unwrap()
.clone(),
)
};
let second = anims
.clips
.iter()
.filter(|c| c.anim_id == 0x93)
.nth(1)
.expect("the chain's second take")
.node;
let mut gos = vec![go];
for _ in 1..N {
gos.push(
app.world_mut()
.spawn((
anims.clone(),
player.clone(),
tr.clone(),
graph.clone(),
GoAnim {
state: Some(GO_STATE_READY),
..Default::default()
},
))
.id(),
);
}
app.update();
let rolled = gos
.iter()
.filter(|&&e| {
app.world()
.entity(e)
.get::<AnimationPlayer>()
.unwrap()
.animation(second)
.is_some()
})
.count();
assert!(
rolled > 0,
"not one of {N} arms reached the second take — the arm is taking the head, which is \
the whole Onyxia lava-trap bug"
);
assert!(
(N / 40..N / 4).contains(&rolled),
"{rolled}/{N} on a 3276/32768 chain — the weighted walk is not weighting"
);
}
/// **A rest pose is ONE window, and its completion re-arms it — with a FRESH roll, for ever**
/// (wow-re `gameobject-anim-arm.md` §6c/§6d, correcting 1151's "nothing advances off a held
/// pose"). `0x719370` fires the completion at `span × R` without consulting the loop bit
/// (`0x719503` tests it only afterwards), and slot 14's resting row `0x5f4167` calls
/// `0x5f3930` directly — past slot 34's change guard — on an unchanged substate.
///
/// This is the half that makes Onyxia's Lair a *continuous* ember field rather than one flurry
/// at stream-in: ~200 trap GameObjects each re-roll a 10 %-weighted Stand chain every 867 ms,
/// against a 5 s particle lifespan. One object, many windows, is the same law in the small.
#[test]
fn a_rest_pose_re_arms_every_window_and_re_rolls_its_variation() {
let (mut app, go) = crate_app(&[(0x93, 0.866, 3276)]);
let nodes: Vec<_> = {
let e = app.world().entity(go);
e.get::<ModelAnimations>()
.unwrap()
.clips
.iter()
.filter(|c| c.anim_id == 0x93)
.map(|c| c.node)
.collect()
};
assert_eq!(nodes.len(), 2, "the chain is two takes");
let mut seen = [0u32; 2];
app.update();
for _ in 0..300 {
let player = app.world().entity(go).get::<AnimationPlayer>().unwrap();
for (i, n) in nodes.iter().enumerate() {
if player.animation(*n).is_some() {
seen[i] += 1;
}
}
// Longer than either take's band, so whichever is armed completes and re-arms.
advance(&mut app, 900);
}
assert!(
seen[0] > 0 && seen[1] > 0,
"over 300 windows the pose showed takes {seen:?} — a rest pose that never re-armed \
would show exactly one, and Onyxia's floor would spurt once and go quiet"
);
// The pose itself never wanders off Closed: re-arming is not the §2d substate advance.
assert!(matches!(armed(&app, go), Some((0x93, _))));
}
/// The Custom channel shares the ONE transient slot with the motions (the reference's
/// `[handler+0x10]`), so 1100's bobber law has to keep holding through it: a Custom0 arms over
/// the state pose, runs exactly one window, and the completion re-runs the machine back onto
/// the state's own pose.
#[test]
fn a_custom_block_still_runs_one_window_and_hands_back_to_the_state() {
let (mut app, go) = crate_app(&[(153, 0.667)]); // Custom0 — the crate authors one
let (mut app, go) = crate_app(&[(153, 0.667, 0)]); // Custom0 — the crate authors one
app.update();
assert_eq!(armed(&app, go), Some((0x93, RepeatAnimation::Forever)));
assert_eq!(armed(&app, go), Some((0x93, RepeatAnimation::Never)));
app.world_mut()
.entity_mut(go)
@@ -1129,7 +1344,7 @@ mod tests {
app.update();
assert_eq!(
armed(&app, go),
Some((0x93, RepeatAnimation::Forever)),
Some((0x93, RepeatAnimation::Never)),
"one Custom window, then the state pose — never a churning loop"
);
}
@@ -1144,9 +1359,9 @@ mod tests {
/// so 271 eggs blinked out with no animation at all.
#[test]
fn an_announced_despawn_plays_its_window_before_the_object_pops() {
let (mut app, go) = crate_app(&[(157, 2.667)]);
let (mut app, go) = crate_app(&[(157, 2.667, 0)]);
app.update();
assert_eq!(armed(&app, go), Some((0x93, RepeatAnimation::Forever)));
assert_eq!(armed(&app, go), Some((0x93, RepeatAnimation::Never)));
// The wire pair, in the order vmangos sends it and Commands apply it.
app.world_mut()
@@ -1196,7 +1411,7 @@ mod tests {
/// object with no destroy plays its window and returns to its state pose, still there.
#[test]
fn an_announcement_without_a_destroy_never_despawns_anything() {
let (mut app, go) = crate_app(&[(157, 2.667)]);
let (mut app, go) = crate_app(&[(157, 2.667, 0)]);
app.update();
app.world_mut().entity_mut(go).insert(DespawnAnimAnnounced);
@@ -1208,7 +1423,7 @@ mod tests {
assert!(app.world().get_entity(go).is_ok(), "no destroy, no despawn");
assert_eq!(
armed(&app, go),
Some((0x93, RepeatAnimation::Forever)),
Some((0x93, RepeatAnimation::Never)),
"and the window hands back to the state pose, like any other one-shot"
);
}
+17 -11
View File
@@ -115,8 +115,13 @@ fn newest_line_sits_at_the_bottom() {
assert!(y("newer") < y("older"), "newest renders at the bottom");
}
/// The whole fade round trip as it reaches a real chat window: a line ramps down, a scroll brings
/// it back to full, the scrolled-up view then holds it there, and returning to the bottom lets it
/// ramp again. The re-arm half is `msgframe-fade-rearm-law.md` — every scroll entry reaches
/// `0x788b80` or the relayout's `0x788af0`; before it, a faded-out chat could not be recovered by
/// any input the client offers (director-reported, 2026-08-29).
#[test]
fn wheel_scroll_freezes_the_fade_then_resumes() {
fn wheel_scroll_re_arms_the_fade_then_freezes_it() {
let mut s = chat_frame();
// Short fade so a tick visibly ramps: straight into phase 2 (timeVisible 0), 4s ramp.
s.run("ChatFrame1:SetTimeVisible(0); ChatFrame1:SetFadeDuration(4)")
@@ -133,23 +138,24 @@ fn wheel_scroll_freezes_the_fade_then_resumes() {
assert!(a1 < 1.0 && a1 > 0.0, "the line faded partway: {a1}");
// Scroll up via the wheel (a point inside the docked frame: BOTTOMLEFT (32,85), 430x160). The
// Lua OnMouseWheel handler calls ScrollUp → no longer AtBottom → the fade must freeze.
// Lua OnMouseWheel handler calls ScrollUp, which re-arms every displayed line to full alpha —
// and, no longer AtBottom, the tick then holds it there.
s.mouse_wheel(100.0, 150.0, 1.0);
s.resolve();
let a2 = text_color(&s.extract(), "L1").expect("L1 still visible")[3];
assert!(close(a2, 1.0), "the scroll brought the line back: {a2}");
s.tick(2.0);
s.resolve();
let a2 = text_color(&s.extract(), "L1").expect("L1 still visible");
assert!(
close(a2[3], a1),
"frozen while scrolled up: {} vs {a1}",
a2[3]
);
let a3 = text_color(&s.extract(), "L1").expect("L1 still visible")[3];
assert!(close(a3, 1.0), "frozen while scrolled up: {a3}");
// Wheel back down to the bottom → the fade resumes.
// Wheel back down to the bottom — that scroll re-arms too, so the ramp restarts from full.
s.mouse_wheel(100.0, 150.0, -1.0);
s.tick(1.0);
s.resolve();
let a3 = text_color(&s.extract(), "L1").expect("L1 visible")[3];
assert!(a3 < a1, "the fade resumed at the bottom: {a3} < {a1}");
let a4 = text_color(&s.extract(), "L1").expect("L1 visible")[3];
assert!(a4 < 1.0 && a4 > 0.0, "the fade resumed at the bottom: {a4}");
assert!(close(a4, a1), "and from a full re-arm: {a4} vs {a1}");
}
#[test]
@@ -132,6 +132,14 @@ pub fn goanimscan(chain: &mut Chain) -> Result<()> {
// advance, and a consumer that arms it by the loop bit instead flaps. And `replay` decides
// whether that window is one band or several.
let (mut looping_motion, mut looping_motion_sensitive, mut multi_replay) = (0u32, 0u32, 0u32);
// The VARIATION half (wow-re `gameobject-anim-arm.md` §2c, `0x5f3aee: push -1`): the GameObject
// arm rolls a `_rand`-weighted variation, while the §1 loader seed underneath it takes an
// explicit variation 0. So a model that authors a CHAIN on a reachable substate's id plays
// something the seed never reaches — and a consumer resolving the id to its head variation
// renders the whole rest of the chain unreachable. Onyxia's lava traps are the case
// (`ONYZIASLAIRLAVATRAP`: Stand ×2, and only the 10 %-weighted second one spurts lava).
let mut chained = 0u32;
let mut chained_paths: Vec<String> = Vec::new();
for (path, displays) in &models {
let Ok(bytes) = chain.read_file(path) else {
continue;
@@ -150,6 +158,7 @@ pub fn goanimscan(chain: &mut Chain) -> Result<()> {
let mut lines = Vec::new();
let (mut differs, mut remapped, mut froze) = (false, false, false);
let (mut flaps, mut replays) = (false, false);
let mut has_chain = false;
for (sub, label) in REACHABLE {
let lut = SUBSTATE_ANIM[sub];
let (req, r0) = go_remap(m, lut);
@@ -166,6 +175,10 @@ pub fn goanimscan(chain: &mut Chain) -> Result<()> {
let motion = MOTION_SUBSTATES.contains(&sub);
flaps |= motion && !r0 && played.is_some_and(|s| s.looping);
replays |= played.is_some_and(|s| (s.min_replay, s.max_replay) != (0, 0));
// A variation chain on THIS substate's armed id: >1 sequence sharing it.
let variations =
armed.map_or(0, |(id, _)| seqs.iter().filter(|s| s.anim_id == id).count());
has_chain |= variations > 1;
lines.push(format!(
" {label} sub{sub} lut {lut}{} -> {}{}",
if req == lut {
@@ -198,6 +211,10 @@ pub fn goanimscan(chain: &mut Chain) -> Result<()> {
},
));
}
if has_chain {
chained += 1;
chained_paths.push(path.clone());
}
flaps.then(|| looping_motion += 1);
(flaps && differs).then(|| looping_motion_sensitive += 1);
replays.then(|| multi_replay += 1);
@@ -237,6 +254,13 @@ pub fn goanimscan(chain: &mut Chain) -> Result<()> {
models a GO type that skips the arm renders in the wrong pose"
);
println!(" needing the §2c remap on some substate: {needs_remap}");
println!(
" authoring a VARIATION CHAIN on a reachable substate: {chained} — the models the arm's \
`variationIdx = -1` roll can reach and the loader seed's explicit variation 0 cannot"
);
for p in &chained_paths {
println!(" {p}");
}
println!(
" arming a LOOPING band on a transition (motion) substate: {looping_motion} \
({looping_motion_sensitive} of them state-SENSITIVE, i.e. the transition is a clip the \
@@ -4,7 +4,9 @@
//! Grounded in wow-re's byte-verified runtime model (msgframe-runtime.md, §5 pair): a **true ring**
//! of `maxLines` (drop-oldest, `SetMaxLines` destructive), `AddMessage(text[,r,g,b[,id]])` with the
//! `trunc(x*255+0.5)` color quantization + forced-opaque alpha, per-line fade snapshots ticked only
//! while **AtBottom** (scrolled up freezes every alpha), and 1-slot scrollback. The heavy lifting
//! while **AtBottom** (scrolled up freezes every alpha), 1-slot scrollback, and — the other half of
//! that fade, `msgframe-fade-rearm-law.md` — **every scroll entry re-arming the displayed lines**,
//! which is what brings a faded-out chat back. The heavy lifting
//! (the ring, the fade phases, the scroll clamps) lives on [`ScrollingMessageState`]
//! (`crate::widget`), unit-tested there; this module is the thin Lua binding over it, plus the
//! host-facing [`UiScript::add_chat_message`]/fade advance the app drives.
@@ -56,11 +58,17 @@ fn num_f32(v: &Value) -> f32 {
}
}
/// `PageUp`/`PageDown`: page by the currently-displayed message count minus one (one message of
/// overlap — msgframe-runtime.md's page law). The viewport's row budget comes from the frame's last
/// resolved rect at the ring font's pitch (the font-height line-step law, the emit pass's basis);
/// an unresolved frame pages by one message.
fn page(lua: &Lua, this: &Table, up: bool) -> mlua::Result<()> {
/// Run a scroll entry over a frame's state with the viewport's row budget in hand.
///
/// Every scroll method needs it, not just the pages: each one re-arms the **displayed** lines'
/// fade ([`ScrollingMessageState::reset_all_fade_times`]), and which lines those are depends on the
/// row budget. It comes from the frame's last resolved rect at the ring font's pitch (the
/// font-height line-step law, the emit pass's basis); an unresolved frame reads as one row.
fn scroll(
lua: &Lua,
this: &Table,
op: impl FnOnce(&mut ScrollingMessageState, usize),
) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let viewport_rows = UiScript::message_viewport_rows(&model, h);
@@ -70,11 +78,7 @@ fn page(lua: &Lua, this: &Table, up: bool) -> mlua::Result<()> {
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
match &mut frame.kind_state {
KindState::ScrollingMessage(smf) => {
if up {
smf.page_up(viewport_rows);
} else {
smf.page_down(viewport_rows);
}
op(smf, viewport_rows);
Ok(())
}
_ => Err(mlua::Error::runtime("not a ScrollingMessageFrame")),
@@ -112,34 +116,36 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
m.set(
"ScrollUp",
lua.create_function(|lua, this: Table| {
with_smf(lua, &this, ScrollingMessageState::scroll_up)
scroll(lua, &this, ScrollingMessageState::scroll_up)
})?,
)?;
m.set(
"ScrollDown",
lua.create_function(|lua, this: Table| {
with_smf(lua, &this, ScrollingMessageState::scroll_down)
scroll(lua, &this, ScrollingMessageState::scroll_down)
})?,
)?;
m.set(
"ScrollToTop",
lua.create_function(|lua, this: Table| {
with_smf(lua, &this, ScrollingMessageState::scroll_to_top)
scroll(lua, &this, ScrollingMessageState::scroll_to_top)
})?,
)?;
m.set(
"ScrollToBottom",
lua.create_function(|lua, this: Table| {
with_smf(lua, &this, ScrollingMessageState::scroll_to_bottom)
scroll(lua, &this, ScrollingMessageState::scroll_to_bottom)
})?,
)?;
m.set(
"PageUp",
lua.create_function(|lua, this: Table| page(lua, &this, true))?,
lua.create_function(|lua, this: Table| scroll(lua, &this, ScrollingMessageState::page_up))?,
)?;
m.set(
"PageDown",
lua.create_function(|lua, this: Table| page(lua, &this, false))?,
lua.create_function(|lua, this: Table| {
scroll(lua, &this, ScrollingMessageState::page_down)
})?,
)?;
m.set(
"AtTop",
@@ -334,6 +340,9 @@ mod tests {
assert_eq!(s.lines[0].alpha, 0.0);
}
/// The tick's AtBottom gate, isolated from the scroll re-arm that now sits next to it: with a
/// one-row viewport only the newest displayed line is re-armed, so the two lines either side of
/// it are the ones that show the freeze.
#[test]
fn scrolled_up_freezes_the_fade() {
let mut s = ScrollingMessageState {
@@ -342,18 +351,92 @@ mod tests {
fade_duration: 4.0,
..Default::default()
};
s.add("a".into(), 1.0, 1.0, 1.0);
s.add("b".into(), 1.0, 1.0, 1.0);
// Scroll up one line — no longer AtBottom, so ticks must not advance any alpha.
s.scroll_up();
for n in 0..3 {
s.add(format!("l{n}"), 1.0, 1.0, 1.0);
}
s.tick(2.0); // half-way down the ramp: trunc(2.0/4.0*255) = 127
let half = 127.0 / 255.0;
assert_eq!(s.lines[0].alpha, half);
// Scroll up one line — no longer AtBottom, so ticks must not advance any alpha. The line
// that lands in the one-row viewport (l1) is re-armed by the scroll; l0 and l2 are not.
s.scroll_up(1);
assert!(!s.at_bottom());
assert_eq!(s.lines[1].alpha, 1.0, "the displayed line was re-armed");
s.tick(2.0);
assert_eq!(s.lines[0].alpha, 1.0, "frozen while scrolled up");
assert_eq!(s.lines[0].alpha, half, "frozen while scrolled up");
assert_eq!(s.lines[2].alpha, half);
assert_eq!(s.lines[1].alpha, 1.0);
// Back to bottom → the fade resumes.
s.scroll_to_bottom();
// Back to bottom → the fade resumes (from the re-arm this scroll performs).
s.scroll_to_bottom(1);
s.tick(2.0);
assert!(s.lines[1].alpha < 1.0);
assert!(s.lines[2].alpha < 1.0);
}
/// **The director's bug (2026-08-29): a fully-faded chat could never be brought back.** Every
/// scroll entry re-arms the displayed lines — including the ones that move no cursor at all,
/// which is what clicking the arrows on an already-at-bottom chat does.
#[test]
fn any_scroll_brings_faded_lines_back() {
let armed = |op: fn(&mut ScrollingMessageState, usize)| {
let mut s = ScrollingMessageState {
max_lines: 8,
time_visible: 1.0,
fade_duration: 1.0,
..Default::default()
};
for n in 0..3 {
s.add(format!("l{n}"), 1.0, 1.0, 1.0);
}
s.tick(1.5); // spend phase 1
s.tick(1.5); // spend phase 2 — everything is gone
assert!(s.lines.iter().all(|l| l.alpha == 0.0), "faded out first");
op(&mut s, 8);
s
};
for (name, op) in [
(
"ScrollUp",
ScrollingMessageState::scroll_up as fn(&mut _, usize),
),
("ScrollDown", ScrollingMessageState::scroll_down),
("ScrollToTop", ScrollingMessageState::scroll_to_top),
("ScrollToBottom", ScrollingMessageState::scroll_to_bottom),
("PageUp", ScrollingMessageState::page_up),
("PageDown", ScrollingMessageState::page_down),
] {
let s = armed(op);
// Whatever the op left in view is what the engine re-arms — `ScrollUp` carries the
// newest line off the bottom of the viewport, so the set is the op's, not the ring.
let shown = s.displayed_range(8);
assert!(!shown.is_empty(), "{name} displayed nothing");
for i in shown {
let line = &s.lines[i];
assert_eq!(line.alpha, 1.0, "{name} left line {i} invisible");
assert_eq!(line.time_left, 1.0, "{name} left line {i} un-armed");
assert_eq!(line.fade_left, 1.0, "{name} left line {i} un-armed");
}
}
}
/// `0x788b80` walks the display vector, not the ring: a line scrolled out of view keeps
/// whatever fade state it had.
#[test]
fn the_re_arm_reaches_only_the_displayed_lines() {
let mut s = ScrollingMessageState {
max_lines: 8,
time_visible: 1.0,
fade_duration: 1.0,
..Default::default()
};
for n in 0..3 {
s.add(format!("l{n}"), 1.0, 1.0, 1.0);
}
s.tick(1.5);
s.tick(1.5);
s.scroll_down(1); // a no-op scroll at the bottom, one row of viewport
assert_eq!(s.lines[2].alpha, 1.0, "the one displayed line came back");
assert_eq!(s.lines[1].alpha, 0.0, "off-screen lines are untouched");
assert_eq!(s.lines[0].alpha, 0.0);
}
#[test]
@@ -366,15 +449,15 @@ mod tests {
s.add(format!("l{n}"), 1.0, 1.0, 1.0);
}
assert!(s.at_bottom());
s.scroll_down(); // already at bottom — no-op
s.scroll_down(8); // already at bottom — no cursor move
assert!(s.at_bottom());
// 3 lines → max_scroll = 2; scroll past it clamps.
for _ in 0..10 {
s.scroll_up();
s.scroll_up(8);
}
assert!(s.at_top());
assert_eq!(s.scroll_offset, 2);
s.scroll_to_bottom();
s.scroll_to_bottom(8);
assert!(s.at_bottom());
}
@@ -387,7 +470,7 @@ mod tests {
for n in 0..4 {
s.add(format!("l{n}"), 1.0, 1.0, 1.0);
}
s.scroll_up(); // viewing one line back (bottom row = l2)
s.scroll_up(8); // viewing one line back (bottom row = l2)
let off = s.scroll_offset;
s.add("l4".into(), 1.0, 1.0, 1.0); // a new line arrives at the bottom
assert_eq!(
@@ -64,7 +64,9 @@ pub struct ScrollingMessageState {
/// `fadingEnabled` (ctor 1). While false, lines never fade.
pub fading_enabled: bool,
/// The scrollback offset, counted up from the newest line: `0` = pinned to the bottom (AtBottom,
/// where fades tick); `n` = the view is `n` lines older. Clamped in [`Self::scroll_up`].
/// the only state in which fades tick — though every scroll entry re-arms the displayed lines
/// regardless, [`Self::reset_all_fade_times`]); `n` = the view is `n` lines older. Clamped in
/// [`Self::scroll_up`].
pub scroll_offset: usize,
}
@@ -155,24 +157,89 @@ impl ScrollingMessageState {
self.scroll_offset = self.scroll_offset.min(self.max_scroll());
}
/// `ScrollUp` (`0x788610`) — one line older (no-op at the top).
pub fn scroll_up(&mut self) {
/// **Re-arm the displayed lines' fade** — the engine's `0x788b80`, which every scroll entry
/// reaches (see the scroll methods below). Per displayed line it writes the quartet
/// `alpha = 0xFF` (`0x788baf`), the in-use flag (`0x788bb3`), and the frame's *current*
/// `timeVisible`/`fadeDuration` **defaults** back onto the record's `+0xc`/`+0x10` snapshots
/// (`0x788bc0`/`0x788bc9`) — full values, never partial, and the frame default rather than
/// whatever the line was inserted with.
///
/// Two properties that are easy to get wrong and are both byte-verified:
///
/// - **The displayed set only, never the whole ring.** `0x788b80` walks `[+0x34c]` entries of
/// the display vector `[+0x3a4]`, reaching each record through `node.+0xc`. A line scrolled
/// out of view keeps whatever fade state it had.
/// - **A fully-faded line is recoverable.** Expiry (`0x788525`–`0x788538`) only clears the
/// in-use flag, blanks the display object and hides it; `0x788460` contains no allocator call
/// at all, so the record, its text and its wrapped height all survive and come straight back.
///
/// Not a Lua binding: it is in no vtable and no data dword, and 1.12's FrameXML has no
/// `ResetAllFadeTimes` caller. It is engine behavior, reached only through the scroll entries.
pub fn reset_all_fade_times(&mut self, viewport_rows: usize) {
let (time_visible, fade_duration) = (self.time_visible, self.fade_duration);
for line in self.lines.range_mut(self.displayed_range(viewport_rows)) {
line.time_left = time_visible;
line.fade_left = fade_duration;
line.alpha = 1.0;
}
}
/// The ring indices the view currently shows, oldest-first — the set
/// [`Self::reset_all_fade_times`] walks. The newest displayed line sits at the top end (it is
/// the bottom row on screen); the walk runs older from there for [`Self::displayed_count`]
/// messages.
pub fn displayed_range(&self, viewport_rows: usize) -> std::ops::Range<usize> {
let count = self.displayed_count(viewport_rows);
if count == 0 {
return 0..0;
}
let newest = self.lines.len().saturating_sub(1 + self.scroll_offset);
newest + 1 - count.min(newest + 1)..newest + 1
}
// ── The scroll entries ──────────────────────────────────────────────────────────────────
//
// **Every one of them re-arms the displayed lines' fade**, by one of two engine routes that
// between them leave no gap (wow-re `msgframe-fade-rearm-law.md`):
//
// - the cursor does NOT move (a scroll that is already at the boundary, or a no-op jump) — the
// binding calls `0x788b80` directly: `ScrollUp` at AtTop (`0x788626`), `ScrollDown` at
// AtBottom (`0x788666`), `ScrollToBottom` at AtBottom (`0x7886e5`);
// - the cursor DOES move — the relayout `0x788750` runs and its per-line helper `0x788af0`
// (sole caller `0x7888be`) writes the same quartet under `arg3 != 0 || AtBottom == 0`, where
// `arg3` is the relayout's own 0→1 AtBottom edge flag (`0x7887a3`). Land off the bottom and
// the second term fires; land back ON the bottom and the edge flag does.
//
// So the re-arm is unconditional at this level and the two routes collapse into one call after
// the cursor has moved — which is also the right order, since the displayed set the engine
// walks is the one the new cursor selects. **Correction (2026-08-29):** benilla previously had
// scrolling merely *freeze* the countdown, on the strength of msgframe-runtime.md's "nothing
// un-fades" — that note described the tick's gate, not the scroll bindings, which had not been
// walked. Faded chat could never be brought back; the director reported it.
/// `ScrollUp` (`0x788610`) — one line older (no cursor move at the top), then re-arm.
pub fn scroll_up(&mut self, viewport_rows: usize) {
self.scroll_offset = (self.scroll_offset + 1).min(self.max_scroll());
self.reset_all_fade_times(viewport_rows);
}
/// `ScrollDown` (`0x788650`) — one line newer (no-op at the bottom).
pub fn scroll_down(&mut self) {
/// `ScrollDown` (`0x788650`) — one line newer (no cursor move at the bottom), then re-arm.
pub fn scroll_down(&mut self, viewport_rows: usize) {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
self.reset_all_fade_times(viewport_rows);
}
/// `ScrollToBottom` (`0x7886d0`) — jump to the newest line (re-arms the fade).
pub fn scroll_to_bottom(&mut self) {
/// `ScrollToBottom` (`0x7886d0`) — jump to the newest line, then re-arm.
pub fn scroll_to_bottom(&mut self, viewport_rows: usize) {
self.scroll_offset = 0;
self.reset_all_fade_times(viewport_rows);
}
/// `ScrollToTop` (`0x788690`) — jump to the oldest line.
pub fn scroll_to_top(&mut self) {
/// `ScrollToTop` (`0x788690`) — jump to the oldest line, then re-arm. This one never reaches
/// `0x788b80`; it re-arms because it always relayouts with AtBottom = 0.
pub fn scroll_to_top(&mut self, viewport_rows: usize) {
self.scroll_offset = self.max_scroll();
self.reset_all_fade_times(viewport_rows);
}
/// How many messages the view shows from the current anchor, given the viewport's row budget
@@ -202,25 +269,39 @@ impl ScrollingMessageState {
pub fn page_up(&mut self, viewport_rows: usize) {
let page = self.displayed_count(viewport_rows).saturating_sub(1).max(1);
self.scroll_offset = (self.scroll_offset + page).min(self.max_scroll());
self.reset_all_fade_times(viewport_rows);
}
/// `PageDown` — the same page size toward the newest line.
/// `PageDown` — the same page size toward the newest line. Pages inherit both re-arm routes
/// from the `ScrollUp`/`ScrollDown` steps they are built out of.
pub fn page_down(&mut self, viewport_rows: usize) {
let page = self.displayed_count(viewport_rows).saturating_sub(1).max(1);
self.scroll_offset = self.scroll_offset.saturating_sub(page);
self.reset_all_fade_times(viewport_rows);
}
/// Advance the fade by `dt` (the OnUpdate tick, `0x788460`). The entry gate is **AtBottom** and
/// `fading_enabled` — scrolled up, every line holds its current alpha and nothing un-fades. Each
/// `fading_enabled` — scrolled up, every line holds its current alpha and *this* function never
/// un-fades one. Un-fading is the scroll entries' job, through
/// [`Self::reset_all_fade_times`] — do not read the freeze below as "nothing ever comes back",
/// which is the reading that cost us the bug. Each
/// line runs phase 1 (`time_left` countdown at full alpha), then phase 2 (`fade_left` countdown,
/// alpha = `trunc(fade_left/fade_duration*255)`); `fade_duration == 0` snaps straight to 0.
///
/// **Both countdowns are `fst`-without-pop** (`0x7884d7` phase 1, `0x788544` phase 2): memory
/// takes the `f32` rounding of `remaining − dt` while the x87 stack keeps the un-rounded value,
/// and it is the *un-rounded* one that the `fdiv`/`fmul 255.0`/`__ftol` ramp then consumes. So
/// the arithmetic runs wide and only the stored countdown narrows to `f32`. Phase 1's negative
/// overshoot is discarded — it stores exactly `0`, and phase 2 starts on the next tick with the
/// full fade rather than the remainder.
pub fn tick(&mut self, dt: f32) {
if !self.fading_enabled || !self.at_bottom() {
return;
}
let dt = f64::from(dt);
for line in &mut self.lines {
if line.time_left > 0.0 {
line.time_left -= dt;
line.time_left = ((f64::from(line.time_left) - dt).max(0.0)) as f32;
continue;
}
if self.fade_duration <= 0.0 {
@@ -228,14 +309,16 @@ impl ScrollingMessageState {
line.alpha = 0.0;
continue;
}
line.fade_left -= dt;
if line.fade_left <= 0.0 {
let remaining = f64::from(line.fade_left) - dt;
line.fade_left = remaining as f32;
if remaining <= 0.0 {
line.fade_left = 0.0;
line.alpha = 0.0;
} else {
// Divisor is the LIVE frame fadeDuration (a mid-fade SetFadeDuration rescales the
// ramp), byte-quantized like the client.
let byte = quantize_fade(line.fade_left / self.fade_duration);
// ramp), byte-quantized like the client — off the wide `remaining`, not the stored
// f32, per the `fst`-no-pop shape above.
let byte = quantize_fade_wide(remaining / f64::from(self.fade_duration));
line.alpha = f32::from(byte) / 255.0;
}
}
@@ -247,6 +330,12 @@ fn quantize_fade(x: f32) -> u8 {
(x.clamp(0.0, 1.0) * 255.0).trunc() as u8
}
/// [`quantize_fade`] off the x87 stack's un-rounded ratio — see [`ScrollingMessageState::tick`] on
/// the `fst`-without-pop shape that makes the ramp wider than the countdown it stores.
fn quantize_fade_wide(x: f64) -> u8 {
(x.clamp(0.0, 1.0) * 255.0).trunc() as u8
}
/// Where a [`MessageFrameState`]'s newest message enters the display stack — the `insertMode` XML
/// attribute (`0x87a618`) and the `SetInsertMode`/`GetInsertMode` pair (`0x794ed0`/`0x794ff0`).
///
@@ -358,6 +447,11 @@ impl MessageFrameState {
/// `trunc(fade_left/fade_duration*255)` against the **live** frame `fade_duration`. A finished
/// line is then **freed** rather than left in place — this class has no ring to hold a slot, so
/// the survivors re-pack (`0x786570`, the retire helper that decrements the active count).
///
/// Deliberately still narrow arithmetic: the `fst`-without-pop shape is byte-cited for the
/// *scrolling* class's tick (`0x7884d7`/`0x788544`), and `0x786200` has not been walked for it.
/// Copying the wide form across on the strength of "same formula" would be the scope error
/// decision 1692 is about, one class over.
pub fn tick(&mut self, dt: f32) {
if !self.fading_enabled {
return;