Skip to main content

wowlab_analytics/analytics/
convert.rs

1use hdrhistogram::{Histogram, serialization::Deserializer};
2use wowlab_types::{
3    constants::{
4        HUNDRED, PROTO_DPS_SCALE, PROTO_RESOURCE_SCALE, QUANTILE_P01, QUANTILE_P05, QUANTILE_P10,
5        QUANTILE_P25, QUANTILE_P50, QUANTILE_P75, QUANTILE_P90, QUANTILE_P95, QUANTILE_P99,
6    },
7    numeric::u64_to_f64,
8    proto,
9};
10
11use super::views::{
12    ActionView, AuraView, AuraWindowView, CooldownView, CooldownWindowView, DamageByTargetView,
13    DamageProfileView, DictionaryMap, EncounterEventView, ExecutionView, HistogramView, MarkerView,
14    PercentilesView, PhaseMarkerView, ResourceView, TargetMapEntry, TimelineView, UnitMapEntry,
15};
16
17const fn action_source_str(source: i32) -> &'static str {
18    match source {
19        1 => "player",
20        2 => "pet",
21        3 => "enemy",
22        _ => "unknown",
23    }
24}
25
26pub(super) const fn actor_kind_str(kind: i32) -> &'static str {
27    match kind {
28        1 => "player",
29        2 => "pet",
30        3 => "enemy",
31        _ => "unknown",
32    }
33}
34
35#[inline]
36// #t(fn: rust_floating_point_eq) exact zero-check is intentional division-by-zero guard
37fn rate(numerator: f64, denominator: f64) -> Option<f64> {
38    if denominator == 0.0 {
39        None
40    } else {
41        Some(numerator / denominator)
42    }
43}
44
45#[inline]
46pub(super) fn pct(numerator: f64, denominator: f64) -> Option<f64> {
47    rate(numerator, denominator).map(|r| r * HUNDRED)
48}
49
50const HISTOGRAM_VIEW_BINS: u32 = 100;
51
52pub(super) fn decode_hdr(hist: &proto::HistogramData) -> Option<Histogram<u64>> {
53    if hist.hdr_v2.is_empty() {
54        return None;
55    }
56
57    let mut d = Deserializer::new();
58    let mut bytes = hist.hdr_v2.as_slice();
59
60    d.deserialize(&mut bytes).ok()
61}
62
63pub(super) fn histogram_view_from_hdr(hist: &proto::HistogramData) -> Option<HistogramView> {
64    let hdr = decode_hdr(hist)?;
65    let sample_count = hdr.len();
66
67    if sample_count == 0 {
68        return Some(HistogramView {
69            bin_count: 0,
70            min_dps: 0.0,
71            max_dps: 0.0,
72            counts: vec![],
73            underflow: 0,
74            overflow: 0,
75        });
76    }
77
78    let min = hdr.min();
79    let max = hdr.max();
80    let bin_count = HISTOGRAM_VIEW_BINS.max(1);
81    let mut counts = vec![0u64; bin_count as usize];
82
83    let span = max.saturating_sub(min).max(1);
84
85    for v in hdr.iter_recorded() {
86        let value = v.value_iterated_to();
87        let idx = if max == min {
88            0usize
89        } else {
90            let numerator = u128::from(value.saturating_sub(min)) * u128::from(bin_count);
91            let denominator = u128::from(span) + 1;
92            let raw = numerator / denominator;
93
94            usize::try_from(raw.min(u128::from(bin_count - 1))).unwrap_or(0)
95        };
96
97        if let Some(slot) = counts.get_mut(idx) {
98            *slot = slot.saturating_add(v.count_since_last_iteration());
99        }
100    }
101
102    Some(HistogramView {
103        bin_count,
104        min_dps: u64_to_f64(min) / PROTO_DPS_SCALE,
105        max_dps: u64_to_f64(max) / PROTO_DPS_SCALE,
106        counts,
107        underflow: 0,
108        overflow: 0,
109    })
110}
111
112pub(super) fn convert_actions(rows: &[proto::ActionRow], fight_time_s: f64) -> Vec<ActionView> {
113    let total_damage_all: f64 = rows
114        .iter()
115        .map(|r| u64_to_f64(r.total_damage_x10) / PROTO_DPS_SCALE)
116        .sum();
117
118    rows.iter()
119        .map(|r| {
120            let total_damage = u64_to_f64(r.total_damage_x10) / PROTO_DPS_SCALE;
121            let dps = rate(total_damage, fight_time_s);
122            let damage_pct = pct(total_damage, total_damage_all);
123
124            ActionView {
125                spell_id: r.spell_id,
126                target_id: r.target_id,
127                source: action_source_str(r.source).to_string(),
128                source_id: r.source_id,
129                pull_id: r.pull_id,
130                group_id: r.group_id,
131                casts: r.casts,
132                direct_hits: r.direct_hits,
133                ticks: r.ticks,
134                crits: r.crits,
135                misses: r.misses,
136                dodges: r.dodges,
137                parries: r.parries,
138                total_damage,
139                execute_time_ms: r.execute_time_ms,
140                resource_spent: u64_to_f64(r.resource_spent_x100) / PROTO_RESOURCE_SCALE,
141                resource_gained: u64_to_f64(r.resource_gained_x100) / PROTO_RESOURCE_SCALE,
142                dps,
143                damage_pct,
144            }
145        })
146        .collect()
147}
148
149pub(super) fn convert_auras(rows: &[proto::AuraRow], total_fight_time_ms: u64) -> Vec<AuraView> {
150    rows.iter()
151        .map(|r| {
152            let uptime_pct = pct(u64_to_f64(r.uptime_ms), u64_to_f64(total_fight_time_ms));
153
154            AuraView {
155                aura_id: r.aura_id,
156                target_id: r.target_id,
157                source: actor_kind_str(r.source_kind).to_string(),
158                source_id: r.source_id,
159                affected: actor_kind_str(r.affected_kind).to_string(),
160                affected_id: r.affected_id,
161                pull_id: r.pull_id,
162                group_id: r.group_id,
163                uptime_ms: r.uptime_ms,
164                applications: r.applications,
165                refreshes: r.refreshes,
166                stack_seconds: u64_to_f64(r.stack_seconds_x100) / PROTO_RESOURCE_SCALE,
167                uptime_pct,
168            }
169        })
170        .collect()
171}
172
173pub(super) fn convert_resources(
174    rows: &[proto::ResourceRow],
175    total_fight_time_ms: u64,
176) -> Vec<ResourceView> {
177    rows.iter()
178        .map(|r| {
179            let cap_pct = pct(
180                u64_to_f64(r.time_at_cap_ms),
181                u64_to_f64(total_fight_time_ms),
182            );
183            let starved_pct = pct(
184                u64_to_f64(r.starved_time_ms),
185                u64_to_f64(total_fight_time_ms),
186            );
187
188            ResourceView {
189                resource_type: r.resource_type,
190                gained: u64_to_f64(r.gained_x100) / PROTO_RESOURCE_SCALE,
191                spent: u64_to_f64(r.spent_x100) / PROTO_RESOURCE_SCALE,
192                wasted: u64_to_f64(r.wasted_x100) / PROTO_RESOURCE_SCALE,
193                time_at_cap_ms: r.time_at_cap_ms,
194                starved_time_ms: r.starved_time_ms,
195                cap_pct,
196                starved_pct,
197            }
198        })
199        .collect()
200}
201
202pub(super) fn convert_cooldowns(rows: &[proto::CooldownRow]) -> Vec<CooldownView> {
203    rows.iter()
204        .map(|r| {
205            let efficiency = if r.possible_uses == 0 {
206                None
207            } else {
208                Some(u64_to_f64(r.uses) / u64_to_f64(r.possible_uses) * HUNDRED)
209            };
210            let avg_drift_ms = if r.uses == 0 {
211                None
212            } else {
213                Some(u64_to_f64(r.drift_sum_ms) / u64_to_f64(r.uses))
214            };
215
216            CooldownView {
217                spell_id: r.spell_id,
218                uses: r.uses,
219                possible_uses: r.possible_uses,
220                drift_sum_ms: r.drift_sum_ms,
221                max_drift_ms: r.max_drift_ms,
222                efficiency,
223                avg_drift_ms,
224            }
225        })
226        .collect()
227}
228
229pub(super) fn convert_execution(ex: &proto::ExecutionData) -> ExecutionView {
230    let avg_queue_lag_ms = if ex.queue_lag_count == 0 {
231        None
232    } else {
233        Some(u64_to_f64(ex.queue_lag_sum_ms) / u64_to_f64(ex.queue_lag_count))
234    };
235
236    let len = ex
237        .action_count_per_bucket
238        .len()
239        .max(ex.action_bucket_samples.len());
240    let actions_per_bucket: Vec<f64> = (0..len)
241        .map(|i| {
242            let count = ex.action_count_per_bucket.get(i).copied().unwrap_or(0);
243            let samples = ex.action_bucket_samples.get(i).copied().unwrap_or(0);
244
245            if samples == 0 {
246                0.0
247            } else {
248                u64_to_f64(count) / u64_to_f64(samples)
249            }
250        })
251        .collect();
252
253    ExecutionView {
254        active_time_ms: ex.active_time_ms,
255        idle_time_ms: ex.idle_time_ms,
256        gcd_locked_time_ms: ex.gcd_locked_time_ms,
257        idle_gcd_count: ex.idle_gcd_count,
258        avg_queue_lag_ms,
259        actions_per_bucket,
260        bucket_samples: ex.action_bucket_samples.clone(),
261    }
262}
263
264pub(super) fn convert_damage_profile(dp: &proto::DamageProfileData) -> DamageProfileView {
265    let total_overall = u64_to_f64(dp.direct_damage_x10)
266        + u64_to_f64(dp.periodic_damage_x10)
267        + u64_to_f64(dp.pet_damage_x10);
268
269    let by_target: Vec<DamageByTargetView> = dp
270        .by_target
271        .iter()
272        .map(|t| {
273            let total_damage = u64_to_f64(t.total_damage_x10) / PROTO_DPS_SCALE;
274            // #t(rust_floating_point_eq) exact zero-check is intentional division-by-zero guard
275            let damage_pct = if total_overall == 0.0 {
276                None
277            } else {
278                Some(u64_to_f64(t.total_damage_x10) / total_overall * HUNDRED)
279            };
280
281            DamageByTargetView {
282                target_id: t.target_id,
283                total_damage,
284                damage_pct,
285            }
286        })
287        .collect();
288
289    DamageProfileView {
290        direct_damage: u64_to_f64(dp.direct_damage_x10) / PROTO_DPS_SCALE,
291        periodic_damage: u64_to_f64(dp.periodic_damage_x10) / PROTO_DPS_SCALE,
292        pet_damage: u64_to_f64(dp.pet_damage_x10) / PROTO_DPS_SCALE,
293        by_target,
294    }
295}
296
297pub(super) fn convert_dictionary(d: proto::DictionaryView) -> DictionaryMap {
298    DictionaryMap {
299        spell_ids: d.spell_ids,
300        aura_ids: d.aura_ids,
301        targets: d
302            .targets
303            .into_iter()
304            .map(|t| TargetMapEntry {
305                id: t.id,
306                label: t.label,
307                npc_id: t.npc_id,
308                group_id: t.group_id,
309                enemy_tags: t.enemy_tags,
310                group_tags: t.group_tags,
311            })
312            .collect(),
313        units: d
314            .units
315            .into_iter()
316            .map(|unit| UnitMapEntry {
317                kind: actor_kind_str(unit.kind).to_string(),
318                id: unit.id,
319                label: unit.label,
320                target_id: unit.target_id,
321            })
322            .collect(),
323    }
324}
325
326pub(super) fn convert_timeline(timeline: proto::TimelineViewV1) -> Option<TimelineView> {
327    if timeline.duration_ms == 0 && timeline.dps_per_bucket_x10.is_empty() {
328        return None;
329    }
330
331    let dps_per_bucket = timeline
332        .dps_per_bucket_x10
333        .iter()
334        .map(|&value| f64::from(value) / PROTO_DPS_SCALE)
335        .collect();
336
337    let mut absolute_time = 0u32;
338    let markers = timeline
339        .markers
340        .iter()
341        .map(|marker| {
342            absolute_time = absolute_time.saturating_add(marker.delta_time_ms);
343
344            MarkerView {
345                time_ms: absolute_time,
346                sequence: marker.sequence,
347                kind: proto::MarkerKind::try_from(marker.kind)
348                    .unwrap_or(proto::MarkerKind::Unspecified)
349                    .slug()
350                    .to_string(),
351                spell_or_aura_id: marker.spell_or_aura_id,
352                target_id: marker.target_id,
353                amount: marker.amount,
354                is_crit: marker.is_crit,
355                source: actor_kind_str(marker.source_kind).to_string(),
356                source_id: marker.source_id,
357                pull_id: marker.pull_id,
358                group_id: marker.group_id,
359            }
360        })
361        .collect();
362
363    let aura_windows = timeline
364        .aura_windows
365        .iter()
366        .map(|window| AuraWindowView {
367            aura_id: window.aura_id,
368            target_id: window.target_id,
369            start_ms: window.start_ms,
370            end_ms: window.end_ms,
371            source: actor_kind_str(window.source_kind).to_string(),
372            source_id: window.source_id,
373            affected: actor_kind_str(window.affected_kind).to_string(),
374            affected_id: window.affected_id,
375            pull_id: window.pull_id,
376            group_id: window.group_id,
377        })
378        .collect();
379
380    let cooldown_windows = timeline
381        .cooldown_windows
382        .iter()
383        .map(|window| CooldownWindowView {
384            spell_id: window.spell_id,
385            start_ms: window.start_ms,
386            duration_ms: window.duration_ms,
387        })
388        .collect();
389
390    let phase_markers = timeline
391        .phase_markers
392        .into_iter()
393        .map(|phase| PhaseMarkerView {
394            start_ms: phase.start_ms,
395            end_ms: phase.end_ms,
396            key: phase.key,
397        })
398        .collect();
399
400    let encounter_events = timeline
401        .encounter_events
402        .iter()
403        .map(|event| EncounterEventView {
404            kind: encounter_event_kind(event.kind).to_string(),
405            time_ms: event.time_ms,
406            sequence: event.sequence,
407            actor: actor_kind_str(event.actor_kind).to_string(),
408            actor_id: event.actor_id,
409            previous_target_id: event.previous_target_id,
410            target_id: event.target_id,
411            pull_id: event.pull_id,
412            group_id: event.group_id,
413            x: event.x,
414            y: event.y,
415            heading: event.heading,
416            layer_id: event.layer_id,
417        })
418        .collect();
419
420    Some(TimelineView {
421        duration_ms: timeline.duration_ms,
422        bucket_ms: timeline.bucket_ms,
423        dps_per_bucket,
424        bucket_samples: timeline.dps_bucket_samples,
425        markers,
426        aura_windows,
427        cooldown_windows,
428        phase_markers,
429        encounter_events,
430    })
431}
432
433fn encounter_event_kind(kind: i32) -> &'static str {
434    match proto::EncounterEventKind::try_from(kind) {
435        Ok(proto::EncounterEventKind::Spawn) => "spawn",
436        Ok(proto::EncounterEventKind::Movement) => "movement",
437        Ok(proto::EncounterEventKind::Death) => "death",
438        Ok(proto::EncounterEventKind::Despawn) => "despawn",
439        Ok(proto::EncounterEventKind::TargetChange) => "target_change",
440        Ok(proto::EncounterEventKind::PullTransition) => "pull_transition",
441        Ok(proto::EncounterEventKind::Unspecified) | Err(_) => "unknown",
442    }
443}
444
445pub(super) fn percentiles_from_hdr(hdr: &Histogram<u64>) -> PercentilesView {
446    let pct_val = |q: f64| -> f64 { u64_to_f64(hdr.value_at_quantile(q)) / PROTO_DPS_SCALE };
447
448    PercentilesView {
449        p01: pct_val(QUANTILE_P01),
450        p05: pct_val(QUANTILE_P05),
451        p10: pct_val(QUANTILE_P10),
452        p25: pct_val(QUANTILE_P25),
453        p50: pct_val(QUANTILE_P50),
454        p75: pct_val(QUANTILE_P75),
455        p90: pct_val(QUANTILE_P90),
456        p95: pct_val(QUANTILE_P95),
457        p99: pct_val(QUANTILE_P99),
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use googletest::prelude::*;
464    use hdrhistogram::serialization::{Serializer, V2Serializer};
465
466    use super::*;
467
468    #[gtest]
469    fn histogram_projection_handles_full_width_values() -> Result<()> {
470        let mut histogram = Histogram::<u64>::new_with_bounds(1, u64::MAX, 3).or_fail()?;
471
472        histogram.record(1).or_fail()?;
473        histogram.record(u64::MAX).or_fail()?;
474
475        let mut bytes = Vec::new();
476
477        V2Serializer::new()
478            .serialize(&histogram, &mut bytes)
479            .or_fail()?;
480        let view = histogram_view_from_hdr(&proto::HistogramData { hdr_v2: bytes }).or_fail()?;
481
482        verify_that!(view.counts.iter().sum::<u64>(), eq(2))?;
483        verify_that!(view.counts.first(), eq(Some(&1)))?;
484
485        verify_that!(view.counts.last(), eq(Some(&1)))
486    }
487}