auras: -1 ms is "does not run out", not 49.71 days

grom's mount buff carried a "50d" countdown. pfUI formats a buff's remaining
time as `round(remaining / 86400) .. "d"` (api/api.lua:1689), so the span behind
that is 4 294 967 seconds -- 0xFFFFFFFF milliseconds read unsigned, which is the
core's own -1 for a permanent aura.

SMSG_UPDATE_AURA_DURATION is four bytes of signed milliseconds and is supposed
never to arrive for a permanent aura; this realm sends one anyway. AuraDurations
read it as u32 and stamped an expiry 50 days out. A negative span now leaves the
slot unstamped, which join_duration already answers (0, 0) for and which
GetPlayerBuffTimeLeft returns 0 from -- no countdown, which is what a mount has.
Zero is left alone: an expiring aura's last update is a real 0.

Ruled out first: nothing in the chain's SpellDuration.dbc is within three orders
of magnitude of it (the one infinite row, 21, is the usual -1).

Also adds `ironforge-lava`, a capture scenario that actually has magma in frame
-- `ironforge-forge` is a lighting shot of a railing. Its viewpoint is group
g061's pool, one of the nine static nibble-2 pools the liquid census already
pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-11-09 09:26:14 -07:00
co-authored by Claude Opus 5
parent f080e95b8d
commit e2257f84e4
3 changed files with 65 additions and 3 deletions
@@ -1318,6 +1318,19 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: None,
},
// Ironforge's magma, filling the frame: the static nibble-2 pool in group g061, whose own
// extent is x -4891..-4803, y -938..-851 at z 421.2 (the census in `liquid::surface`'s tests
// places all eleven). `ironforge-forge` above is a lighting shot and has no liquid in it;
// this is the one to look at when the question is what magma itself looks like, and its
// outdoor opposite number is `lava-searing-river`.
Scenario {
name: "ironforge-lava",
map: Some(MAP_AZEROTH),
eye: [-4847.0, -895.0, 432.0],
look: [-4815.0, -905.0, 421.0],
minute: 720,
ui: None,
},
// Ironforge: the Great Forge (lava, fire light, a huge interior).
Scenario {
name: "ironforge-forge",
+46 -1
View File
@@ -43,13 +43,30 @@ struct DurationStamp {
impl AuraDurations {
/// Records one `SMSG_UPDATE_AURA_DURATION`.
///
/// The field is four bytes of **signed** milliseconds, and a core sends `-1` for an aura that
/// does not run out (vmangos `SpellAuraHolder::GetAuraDuration` on a permanent holder). Read
/// unsigned that is `0xFFFFFFFF`, which is 4 294 967 seconds, which is 49.71 days: grom's
/// mount carried a `50d` countdown in pfUI's buff frame, whose own formatter is
/// `round(remaining / 86400) .. "d"` (`pfUI/api/api.lua:1689`). Nothing in the chain's
/// `SpellDuration.dbc` is anywhere near it — the one infinite row, 21, is the usual `-1`.
///
/// So a negative span is the "no duration" sentinel and leaves the slot unstamped, which is
/// what `join_duration` already answers `(0, 0)` for and what `GetPlayerBuffTimeLeft` then
/// returns 0 from. The packet's own doc comment said this never arrives for a permanent aura;
/// on this realm it does. Zero is left alone: an expiring aura's last update is a real 0 and
/// has always stamped an expiry of now.
pub(crate) fn set(&mut self, slot: u8, remaining_ms: u32, now: f64) {
let total = f64::from(remaining_ms) / 1000.0;
if trace_period().is_some() {
info!(
"aura trace: SMSG_UPDATE_AURA_DURATION slot {slot} = {remaining_ms} ms @ {now:.2}"
);
}
if (remaining_ms as i32) < 0 {
self.by_slot.remove(&slot);
return;
}
let total = f64::from(remaining_ms) / 1000.0;
self.by_slot.insert(
slot,
DurationStamp {
@@ -1520,6 +1537,34 @@ mod tests {
assert_eq!(join_duration(None, 200.0, 200.0, 5000.0), (0.0, 0.0));
}
/// `-1` milliseconds is "this one does not run out", not 49.71 days.
///
/// grom's mount buff showed `50d` in pfUI's frame, which formats with
/// `round(remaining / 86400) .. "d"` — so the span behind it was 4 294 967 seconds, which is
/// `0xFFFFFFFF` milliseconds read unsigned. The chain's `SpellDuration.dbc` has no row within
/// three orders of magnitude of it.
#[test]
fn a_negative_duration_is_the_permanent_sentinel_and_leaves_no_stamp() {
let mut durations = AuraDurations::default();
durations.set(3, 300_000, 10.0);
assert!(durations.by_slot.contains_key(&3), "a real span stamps");
durations.set(3, u32::MAX, 11.0);
assert!(
!durations.by_slot.contains_key(&3),
"-1 ms means permanent, so the slot keeps no expiry at all"
);
assert_eq!(
join_duration(durations.by_slot.get(&3), 11.0, 11.0, 900.0),
(0.0, 0.0),
"and GetPlayerBuffTimeLeft answers 0 from it, which is no countdown"
);
// Zero is a real last update from an aura that is going, and still stamps.
durations.set(4, 0, 12.0);
assert!(durations.by_slot.contains_key(&4));
}
/// The duration packet arrives about a frame before the delta that fills its slot.
#[test]
fn a_stamp_that_arrives_one_frame_before_its_aura_still_joins_it() {
@@ -622,8 +622,12 @@ pub enum ServerPacket {
ChannelUpdate {
remaining_ms: u32,
},
/// `SMSG_UPDATE_AURA_DURATION` (self-only, never for a permanent aura): one aura slot's time
/// left. It arrives before the delta naming the slot's spell, so buffer it by slot.
/// `SMSG_UPDATE_AURA_DURATION` (self-only): one aura slot's time left, four bytes of signed
/// milliseconds. It arrives before the delta naming the slot's spell, so buffer it by slot.
///
/// A permanent aura is supposed to get no packet at all. OctoWoW sends one anyway, carrying
/// the core's own `-1`, so the consumer treats a negative span as "no duration" rather than
/// as the 49.71 days it reads as unsigned.
UpdateAuraDuration {
slot: u8,
remaining_ms: u32,