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:
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user