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]
36fn 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 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}