the create screen's clouds move, and every battleground is entered

- the character-create and main-menu backdrop runs its material animation: the clouds drift on their 20, 30 and 40 second loops, which were seeded once and never sampled (2300)
- Arathi Basin and Alterac Valley are entered and censused beside Warsong Gulch, the flag is picked up, and a match runs to its end (2296)
- the animation variation roll is one clock-seeded stream, as the reference's C runtime is, not four streams seeded 0 (2299, 2301)
- a cancelled channel never turns the bar red and a finished one closes on the bar's own clock, now pinned by tests that drive the real packet sequence (2284)
- the field watch fires one edge per moved descriptor dword, from the merge that holds both sides (2297)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-17 16:44:40 +07:00
co-authored by Claude Fable 5.1
parent b85bc81a03
commit befda9da54
44 changed files with 1674 additions and 627 deletions
@@ -241,6 +241,68 @@ fn channel_counts_down_not_up() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **A completed channel closes on the BAR's own clock, because the server's stop runs a second
/// late** — and that is the path every naturally-finished channel in the game takes.
///
/// vmangos does not send `MSG_CHANNEL_UPDATE(0)` when a channel simply runs out.
/// `Spell::SendChannelUpdate(0, interrupted=false)` schedules a `ChannelResetEvent` **+1000 ms**
/// ("Else, we have some visual bugs (arcane projectile, last tick)"), and only that event's
/// `CancelSpellChannelingAnimationInstantly()` finally emits the packet. The *interrupt* path calls
/// the same function inline, which is why a broken channel stops at once and a finished one does
/// not.
///
/// So for a whole second after the fill empties there is no stop edge at all, and the stock
/// `OnUpdate` is what ends it: once `GetTime()` reaches `endTime` it clamps, `time == this.endTime`
/// trips, and the frame hands itself to `fadeOut` with **no green flash** — a finished channel
/// fades, where a cast completes green. The late packet then lands on a hidden frame and both of
/// `CastingBarFrame_OnEvent`'s stop-arm guards reject it.
///
/// Worth a test of its own because nothing else exercises it: the sibling test above fires
/// `SPELLCAST_CHANNEL_STOP` by hand, which is the *interrupt* timing, not this one.
#[test]
fn a_finished_channel_fades_on_its_own_clock_and_the_late_stop_is_inert() {
let mut s = harness();
s.fire_event(
"SPELLCAST_CHANNEL_START",
vec![
ScriptValue::Int(1000),
ScriptValue::Str("Channeling".into()),
],
);
assert!(s.eval::<bool>("return CastingBarFrame:IsShown()").unwrap());
// The whole second the channel runs: still up, still orange, no stop packet has been sent.
for _ in 0..9 {
s.tick(0.1);
}
assert!(
s.eval::<bool>("return CastingBarFrame:IsShown()").unwrap(),
"still channelling at 0.9 s of a 1 s channel"
);
let (r, g, b) = bar_color(&s);
assert!(
(r - 1.0).abs() < 1e-6 && (g - 0.7).abs() < 1e-6 && b.abs() < 1e-6,
"never flashes green — a finished channel fades, it does not complete (got {r} {g} {b})"
);
// Past endTime with nothing from the server: the frame ends itself.
for _ in 0..40 {
s.tick(0.1);
}
assert!(
!s.eval::<bool>("return CastingBarFrame:IsShown()").unwrap(),
"the bar closed on its own clock, a full second before the server says so"
);
// ~1 s after the fill emptied, the deferred ChannelResetEvent finally fires.
s.fire_event("SPELLCAST_CHANNEL_STOP", vec![]);
assert!(
!s.eval::<bool>("return CastingBarFrame:IsShown()").unwrap(),
"the late stop lands on a hidden frame and does nothing"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── The draw list ────────────────────────────────────────────────────────────────────────────────
const FLASH: &str = "UI-CastingBar-Flash";