From e2257f84e40c73704b0ef00f98071256eb0cf1a7 Mon Sep 17 00:00:00 2001 From: grom Date: Mon, 9 Nov 2026 09:26:14 -0700 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/src/capture/scenarios.rs | 13 +++++ crates/benilla-app/src/ui_aura.rs | 47 ++++++++++++++++++- .../benilla-protocol/src/messages/packet.rs | 8 +++- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index 9ddef204..e56740b9 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -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", diff --git a/crates/benilla-app/src/ui_aura.rs b/crates/benilla-app/src/ui_aura.rs index 3289173d..f208fd24 100644 --- a/crates/benilla-app/src/ui_aura.rs +++ b/crates/benilla-app/src/ui_aura.rs @@ -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() { diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs index d0aae26e..49c50e52 100644 --- a/crates/benilla-protocol/src/messages/packet.rs +++ b/crates/benilla-protocol/src/messages/packet.rs @@ -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,