From 27bd2670aae2f376a50a6885f687505611f20fac Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 29 Aug 2026 17:23:44 +0700 Subject: [PATCH] scrolling chat brings lines back, and Onyxia's lava traps spurt again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- crates/benilla-app/src/go_anim.rs | 353 ++++++++++++++---- .../benilla-app/src/ui_script/chat_tests.rs | 28 +- .../src/bin/benilla-extract/scan/sequence.rs | 24 ++ .../src/script/messageframe/scrolling.rs | 141 +++++-- .../src/widget/kinds/messageframe.rs | 126 ++++++- 5 files changed, 547 insertions(+), 125 deletions(-) diff --git a/crates/benilla-app/src/go_anim.rs b/crates/benilla-app/src/go_anim.rs index c344a7bb..8b3611f6 100644 --- a/crates/benilla-app/src/go_anim.rs +++ b/crates/benilla-app/src/go_anim.rs @@ -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, + transient: Option, + /// 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, + /// 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, +} + +/// 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>, ) { 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, >, + // 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, ) { 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::().unwrap().clone(), + AnimationPlayer::default(), + AnimationTransitions::new(), + e.get::() + .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::() + .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::() + .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::().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" ); } diff --git a/crates/benilla-app/src/ui_script/chat_tests.rs b/crates/benilla-app/src/ui_script/chat_tests.rs index 3f326338..9048c2a0 100644 --- a/crates/benilla-app/src/ui_script/chat_tests.rs +++ b/crates/benilla-app/src/ui_script/chat_tests.rs @@ -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] diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/sequence.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/sequence.rs index 0101af58..4f77789e 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/sequence.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/sequence.rs @@ -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 = 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 \ diff --git a/crates/benilla-ui/src/script/messageframe/scrolling.rs b/crates/benilla-ui/src/script/messageframe/scrolling.rs index 04cf955d..b80de96d 100644 --- a/crates/benilla-ui/src/script/messageframe/scrolling.rs +++ b/crates/benilla-ui/src/script/messageframe/scrolling.rs @@ -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::().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!( diff --git a/crates/benilla-ui/src/widget/kinds/messageframe.rs b/crates/benilla-ui/src/widget/kinds/messageframe.rs index bb07fc40..77835a6e 100644 --- a/crates/benilla-ui/src/widget/kinds/messageframe.rs +++ b/crates/benilla-ui/src/widget/kinds/messageframe.rs @@ -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 { + 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;