1mod encode;
4mod merge;
5mod representative;
6
7use hdrhistogram::Histogram;
8use itertools::Itertools;
9use representative::RepresentativeTimeline;
10use wowlab_engine_telemetry::{
11 AuraEvent, AuraEventKind, AuraTelemetryScope, ResourceEventKind, TargetMetadata,
12 TelemetryScope, TelemetrySink,
13};
14use wowlab_types::{
15 constants::MS_PER_SECOND,
16 numeric::{f64_to_u32_saturating_trunc, f64_to_u64_saturating_round},
17 sim::{ActorId, EnemyIdx, FastMap, IntMap, TargetIdx},
18};
19
20pub(crate) const TIMELINE_BUCKET_MS: u32 = 1_000;
21
22pub(crate) const HISTOGRAM_SIGNIFICANT_DIGITS: u8 = 3;
23pub(crate) const HISTOGRAM_DPS_SCALE: f64 = 10.0;
24
25const HISTOGRAM_MIN_SAMPLE: u64 = 1;
26const ENEMY_ACTOR_SORT_ORDER: u8 = 3;
27const PET_ACTOR_SORT_ORDER: u8 = 2;
28
29#[expect(
30 clippy::cast_possible_truncation,
31 reason = "representative previews deliberately trade precision for compact f32 storage"
32)]
33pub(super) const fn telemetry_preview_f32(value: f64) -> f32 {
34 value as f32
36}
37
38pub(crate) const fn actor_sort_key(actor: ActorId) -> (u8, u16) {
39 match actor {
40 ActorId::Player => (0, 0),
41 ActorId::External => (1, 0),
42 ActorId::Pet(pet) => (PET_ACTOR_SORT_ORDER, pet.0),
43 ActorId::Enemy(enemy) => (ENEMY_ACTOR_SORT_ORDER, enemy.0),
44 }
45}
46
47#[inline]
48pub(crate) fn dps_histogram_sample(dps: f64) -> u64 {
49 let x10 = (dps * HISTOGRAM_DPS_SCALE).round();
50
51 if x10 < 1.0 {
52 HISTOGRAM_MIN_SAMPLE
53 } else {
54 f64_to_u64_saturating_round(x10)
55 }
56}
57
58pub(crate) fn new_dps_histogram() -> Histogram<u64> {
59 Histogram::new(HISTOGRAM_SIGNIFICANT_DIGITS)
60 .expect("HISTOGRAM_SIGNIFICANT_DIGITS is within the valid 0..=5 range")
61}
62
63pub(crate) fn collect_representative_aura_intervals(
64 events: &[AuraEvent],
65 mut emit: impl FnMut(&AuraEvent, u32, Option<u32>),
66) {
67 let mut starts: FastMap<AuraAggregateKey, RepresentativeAuraIntervalStart<'_>> =
68 FastMap::default();
69
70 for event in events {
71 let key = AuraAggregateKey::from(event);
72
73 match event.kind {
74 AuraEventKind::Apply { .. } | AuraEventKind::Refresh { .. } => {
75 starts
76 .entry(key)
77 .or_insert(RepresentativeAuraIntervalStart {
78 time_ms: event.time_ms,
79 event,
80 });
81 }
82 AuraEventKind::Expire => {
83 if let Some(start) = starts.remove(&key) {
84 emit(event, start.time_ms, Some(event.time_ms));
85 }
86 }
87 }
88 }
89
90 for (_, start) in starts.drain().sorted_by_key(|(key, start)| {
91 (
92 key.aura_id,
93 actor_sort_key(key.scope.affected),
94 actor_sort_key(key.scope.source),
95 key.scope.pull,
96 key.scope.group,
97 start.time_ms,
98 )
99 }) {
100 emit(start.event, start.time_ms, None);
101 }
102}
103
104struct RepresentativeAuraIntervalStart<'a> {
105 time_ms: u32,
106 event: &'a AuraEvent,
107}
108
109#[derive(Debug, Default)]
110pub(crate) struct ResourceSourceAggregate {
111 pub(crate) gained: f64,
112 pub(crate) wasted: f64,
113}
114
115#[derive(Debug, Default)]
116pub(crate) struct ResourceAggregate {
117 pub(crate) total_gained: f64,
118 pub(crate) total_spent: f64,
119 pub(crate) total_wasted: f64,
120 pub(crate) gained_by_source: IntMap<u32, ResourceSourceAggregate>,
122}
123
124#[derive(Clone, Debug, Default)]
125pub(crate) struct SpellAggregate {
126 pub(crate) damage: f64,
127 pub(crate) casts: u32,
128 pub(crate) crits: u32,
129 pub(crate) hits: u32,
130 pub(crate) ticks: u32,
131}
132
133#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
134pub(crate) struct ActionAggregateKey {
135 pub(crate) spell_id: u32,
136 pub(crate) scope: TelemetryScope,
137}
138
139impl ActionAggregateKey {
140 const fn from_scope(spell_id: u32, scope: TelemetryScope) -> Self {
141 Self { spell_id, scope }
142 }
143}
144
145#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
146pub(crate) struct AuraAggregateKey {
147 pub(crate) aura_id: u32,
148 pub(crate) scope: AuraTelemetryScope,
149}
150
151impl From<&AuraEvent> for AuraAggregateKey {
152 fn from(event: &AuraEvent) -> Self {
153 Self {
154 aura_id: event.aura_id,
155 scope: AuraTelemetryScope {
156 source: event.source,
157 affected: event.affected,
158 pull: event.pull,
159 group: event.group,
160 },
161 }
162 }
163}
164
165#[derive(Clone, Debug, Default)]
166pub(crate) struct AuraAggregate {
167 pub(crate) uptime_ms: f64,
168 pub(crate) applications: u64,
169 pub(crate) refreshes: u64,
170}
171
172#[derive(Debug, Default)]
173pub(crate) struct CooldownAggregate {
174 pub(crate) total_uses: u32,
175}
176
177#[derive(Clone, Debug)]
179pub struct RunningDpsStats {
180 pub mean_dps: f64,
181 pub std_dps: f64,
183 pub min_dps: f64,
184 pub max_dps: f64,
185}
186
187#[derive(Clone, Copy, Debug, PartialEq)]
189pub struct RepresentativeIteration {
190 pub seed_prefix: u64,
191 pub iteration: u32,
192 pub dps: f64,
193}
194
195#[derive(Debug)]
197pub struct TelemetryAccumulator {
199 pub(crate) iteration_count: u32,
200 pub(crate) dps_sum: f64,
201 pub(crate) dps_min: f64,
202 pub(crate) dps_max: f64,
203 pub(crate) spell_totals: FastMap<ActionAggregateKey, SpellAggregate>,
204 pub(crate) dps_welford_mean: f64,
205 pub(crate) dps_m2: f64,
206 pub(crate) dps_histogram: Histogram<u64>,
207 pub(crate) representative_dps: Option<f64>,
208 pub(crate) representative_candidates: Vec<RepresentativeIteration>,
209 pub(crate) aura_totals: FastMap<AuraAggregateKey, AuraAggregate>,
210 aura_interval_starts: FastMap<AuraAggregateKey, u32>,
211 pub(crate) resource_totals: IntMap<u8, ResourceAggregate>,
212 pub(crate) cooldown_totals: IntMap<u32, CooldownAggregate>,
213 pub(crate) total_duration_ms: f64,
214 pub(crate) gcd_locked_ms_total: f64,
215 pub(crate) representative_sink: Option<RepresentativeTimeline>,
216 pub(crate) direct_damage_total: f64,
217 pub(crate) periodic_damage_total: f64,
218 pub(crate) pet_damage_total: f64,
219 pub(crate) damage_by_target: FastMap<EnemyIdx, f64>,
220 pub(crate) targets: FastMap<TargetIdx, TargetMetadata>,
221 pub(crate) bucket_sums: Vec<f64>,
222 pub(crate) bucket_samples: Vec<u64>,
224 pub(crate) trace_extras_enabled: bool,
225}
226impl TelemetryAccumulator {
229 #[must_use]
231 pub fn new() -> Self {
232 Self {
233 iteration_count: 0,
234 dps_sum: 0.0,
235 dps_min: f64::MAX,
236 dps_max: f64::MIN,
237 spell_totals: FastMap::default(),
238 dps_welford_mean: 0.0,
239 dps_m2: 0.0,
240 dps_histogram: new_dps_histogram(),
241 representative_dps: None,
242 representative_candidates: Vec::new(),
243 aura_totals: FastMap::default(),
244 aura_interval_starts: FastMap::default(),
245 resource_totals: IntMap::default(),
246 cooldown_totals: IntMap::default(),
247 total_duration_ms: 0.0,
248 gcd_locked_ms_total: 0.0,
249 representative_sink: None,
250 direct_damage_total: 0.0,
251 periodic_damage_total: 0.0,
252 pet_damage_total: 0.0,
253 damage_by_target: FastMap::default(),
254 targets: FastMap::default(),
255 bucket_sums: Vec::new(),
256 bucket_samples: Vec::new(),
257 trace_extras_enabled: false,
258 }
259 }
260
261 pub fn enable_trace_extras(&mut self) {
263 self.trace_extras_enabled = true;
264 }
265
266 #[must_use]
268 pub fn running_stats(&self) -> Option<RunningDpsStats> {
269 if self.iteration_count == 0 {
270 return None;
271 }
272
273 let n = f64::from(self.iteration_count);
274 let mean = self.dps_sum / n;
275 let variance = self.dps_m2 / n;
276 let std = if variance > 0.0 { variance.sqrt() } else { 0.0 };
277
278 Some(RunningDpsStats {
279 mean_dps: mean,
280 std_dps: std,
281 min_dps: self.dps_min,
282 max_dps: self.dps_max,
283 })
284 }
285
286 #[inline]
288 pub fn record_iteration(&mut self, dps: f64) {
289 let iteration = self.iteration_count;
290
291 self.record_iteration_for(0, iteration, dps);
292 }
293
294 #[must_use]
296 pub fn representative_iteration(&self) -> Option<RepresentativeIteration> {
297 let mean = self.running_stats()?.mean_dps;
298
299 self.representative_candidates
301 .iter()
302 .copied()
303 .min_by(|a, b| {
304 let distance_order = (a.dps - mean).abs().total_cmp(&(b.dps - mean).abs());
305 distance_order
306 .then_with(|| a.seed_prefix.cmp(&b.seed_prefix))
307 .then_with(|| a.iteration.cmp(&b.iteration))
308 })
309 }
311
312 #[must_use]
314 pub const fn representative_dps(&self) -> Option<f64> {
315 self.representative_dps
316 }
317
318 pub(crate) fn record_sink_events(&mut self, sink: &TelemetrySink, duration_secs: f64) {
320 let duration_ms = duration_secs * MS_PER_SECOND;
321
322 self.total_duration_ms += duration_ms;
323 let duration_ms_u32 = f64_to_u32_saturating_trunc(duration_ms);
324
325 let num_buckets = duration_ms_u32.div_ceil(TIMELINE_BUCKET_MS) as usize;
326
327 if self.bucket_sums.len() < num_buckets {
328 self.bucket_sums.resize(num_buckets, 0.0);
329 }
330
331 if self.bucket_samples.len() < num_buckets {
332 self.bucket_samples.resize(num_buckets, 0);
333 }
334
335 for slot in self.bucket_samples.iter_mut().take(num_buckets) {
336 *slot += 1;
337 }
338
339 self.record_damage_events(sink, duration_ms_u32);
340 self.record_aura_events(sink, duration_ms_u32);
341 self.record_resource_events(sink);
342
343 for event in &sink.cooldowns {
344 let entry = self.cooldown_totals.entry(event.spell_id).or_default();
345
346 entry.total_uses += 1;
347 }
348
349 self.record_action_events(sink);
350
351 if self.targets.is_empty() {
352 self.targets.extend(
353 sink.targets
354 .iter()
355 .cloned()
356 .map(|target| (target.id, target)),
357 );
358 }
359 }
360
361 #[inline]
362 pub(crate) fn record_iteration_for(&mut self, seed_prefix: u64, iteration: u32, dps: f64) {
363 self.iteration_count += 1;
364 self.dps_sum += dps;
365
366 if dps < self.dps_min {
367 self.dps_min = dps;
368 }
369
370 if dps > self.dps_max {
371 self.dps_max = dps;
372 }
373
374 let delta = dps - self.dps_welford_mean;
375
376 self.dps_welford_mean += delta / f64::from(self.iteration_count);
377 let delta2 = dps - self.dps_welford_mean;
378
379 self.dps_m2 += delta * delta2;
380
381 let _ = self.dps_histogram.record(dps_histogram_sample(dps));
382
383 self.representative_candidates
384 .push(RepresentativeIteration {
385 seed_prefix,
386 iteration,
387 dps,
388 });
389 }
390
391 pub(crate) fn install_representative(
392 &mut self,
393 dps: f64,
394 sink: &TelemetrySink,
395 duration_secs: f64,
396 ) {
397 self.representative_dps = Some(dps);
398 let duration_ms = f64_to_u32_saturating_trunc(duration_secs * MS_PER_SECOND);
399
400 self.capture_representative(sink, duration_ms);
401 }
402
403 fn record_damage_events(&mut self, sink: &TelemetrySink, duration_ms: u32) {
404 for event in &sink.damage {
405 let amount = event.amount;
406
407 if event.is_pet {
408 self.pet_damage_total += amount;
409 } else if event.is_periodic {
410 self.periodic_damage_total += amount;
411 } else {
412 self.direct_damage_total += amount;
413 }
414
415 *self
416 .damage_by_target
417 .entry(event.scope.target)
418 .or_insert(0.0) += amount;
419
420 let timeline_ms = if event.time_ms == duration_ms && duration_ms > 0 {
422 event.time_ms - 1
423 } else {
424 event.time_ms
425 };
426 let bucket = (timeline_ms / TIMELINE_BUCKET_MS) as usize;
427
428 if let Some(slot) = self.bucket_sums.get_mut(bucket) {
429 *slot += amount;
430 }
431 }
432 }
433
434 fn record_aura_events(&mut self, sink: &TelemetrySink, duration_ms: u32) {
435 self.aura_interval_starts.clear();
436
437 for event in &sink.auras {
438 let key = AuraAggregateKey::from(event);
439 let aggregate = self.aura_totals.entry(key).or_default();
440
441 match event.kind {
442 AuraEventKind::Apply { .. } => {
443 aggregate.applications += 1;
444 self.aura_interval_starts
445 .entry(key)
446 .or_insert(event.time_ms);
447 }
448 AuraEventKind::Refresh { .. } => {
449 aggregate.refreshes += 1;
450 self.aura_interval_starts
451 .entry(key)
452 .or_insert(event.time_ms);
453 }
454 AuraEventKind::Expire => {
455 if let Some(start) = self.aura_interval_starts.remove(&key) {
456 aggregate.uptime_ms += f64::from(event.time_ms.saturating_sub(start));
457 }
458 }
459 }
460 }
461
462 for (key, start) in self.aura_interval_starts.drain() {
463 self.aura_totals.entry(key).or_default().uptime_ms +=
464 f64::from(duration_ms.saturating_sub(start));
465 }
466 }
467
468 fn record_resource_events(&mut self, sink: &TelemetrySink) {
469 for event in &sink.resources {
470 let entry = self.resource_totals.entry(event.resource_type).or_default();
471
472 match event.kind {
473 ResourceEventKind::Spend => entry.total_spent += event.amount,
474 ResourceEventKind::Gain { wasted } => {
475 entry.total_gained += event.amount;
476 entry.total_wasted += wasted;
477 let source = entry
478 .gained_by_source
479 .entry(event.source_spell_id)
480 .or_default();
481
482 source.gained += event.amount;
483 source.wasted += wasted;
484 }
485 }
486 }
487 }
488
489 fn record_action_events(&mut self, sink: &TelemetrySink) {
490 for cast in &sink.casts {
491 let entry = self
492 .spell_totals
493 .entry(ActionAggregateKey::from_scope(cast.spell_id, cast.scope))
494 .or_default();
495
496 entry.casts += 1;
497 self.gcd_locked_ms_total += f64::from(cast.gcd_ms);
498 }
499
500 for damage in &sink.damage {
501 let entry = self
502 .spell_totals
503 .entry(ActionAggregateKey::from_scope(
504 damage.spell_id,
505 damage.scope,
506 ))
507 .or_default();
508
509 entry.damage += damage.amount;
510 entry.ticks += u32::from(damage.is_periodic);
513 entry.crits += u32::from(damage.is_crit);
514 entry.hits += u32::from(!damage.is_periodic);
515 }
516 }
517}
518
519impl Default for TelemetryAccumulator {
520 fn default() -> Self {
521 Self::new()
522 }
523}
524
525#[cfg(test)]
526#[path = "tests.rs"]
527mod tests;