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_aura_intervals(
64 events: &[AuraEvent],
65 mut emit: impl FnMut(&AuraEvent, u32, Option<u32>),
66) {
67 let mut starts: FastMap<AuraAggregateKey, AuraIntervalStart<'_>> = FastMap::default();
68
69 for event in events {
70 let key = AuraAggregateKey::from(event);
71
72 match event.kind {
73 AuraEventKind::Apply { .. } | AuraEventKind::Refresh { .. } => {
74 starts.entry(key).or_insert(AuraIntervalStart {
75 time_ms: event.time_ms,
76 event,
77 });
78 }
79 AuraEventKind::Expire => {
80 if let Some(start) = starts.remove(&key) {
81 emit(event, start.time_ms, Some(event.time_ms));
82 }
83 }
84 }
85 }
86
87 for (_, start) in starts.drain().sorted_by_key(|(key, start)| {
88 (
89 key.aura_id,
90 actor_sort_key(key.scope.affected),
91 actor_sort_key(key.scope.source),
92 key.scope.pull,
93 key.scope.group,
94 start.time_ms,
95 )
96 }) {
97 emit(start.event, start.time_ms, None);
98 }
99}
100
101struct AuraIntervalStart<'a> {
102 time_ms: u32,
103 event: &'a AuraEvent,
104}
105
106#[derive(Debug, Default)]
107pub(crate) struct ResourceSourceAggregate {
108 pub(crate) gained: f64,
109 pub(crate) wasted: f64,
110}
111
112#[derive(Debug, Default)]
113pub(crate) struct ResourceAggregate {
114 pub(crate) total_gained: f64,
115 pub(crate) total_spent: f64,
116 pub(crate) total_wasted: f64,
117 pub(crate) gained_by_source: IntMap<u32, ResourceSourceAggregate>,
119}
120
121#[derive(Clone, Debug, Default)]
122pub(crate) struct SpellAggregate {
123 pub(crate) damage: f64,
124 pub(crate) casts: u32,
125 pub(crate) crits: u32,
126 pub(crate) hits: u32,
127 pub(crate) ticks: u32,
128}
129
130#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
131pub(crate) struct ActionAggregateKey {
132 pub(crate) spell_id: u32,
133 pub(crate) scope: TelemetryScope,
134}
135
136impl ActionAggregateKey {
137 const fn from_scope(spell_id: u32, scope: TelemetryScope) -> Self {
138 Self { spell_id, scope }
139 }
140}
141
142#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
143pub(crate) struct AuraAggregateKey {
144 pub(crate) aura_id: u32,
145 pub(crate) scope: AuraTelemetryScope,
146}
147
148impl From<&AuraEvent> for AuraAggregateKey {
149 fn from(event: &AuraEvent) -> Self {
150 Self {
151 aura_id: event.aura_id,
152 scope: AuraTelemetryScope {
153 source: event.source,
154 affected: event.affected,
155 pull: event.pull,
156 group: event.group,
157 },
158 }
159 }
160}
161
162#[derive(Clone, Debug, Default)]
163pub(crate) struct AuraAggregate {
164 pub(crate) uptime_ms: f64,
165 pub(crate) applications: u64,
166 pub(crate) refreshes: u64,
167}
168
169#[derive(Debug, Default)]
170pub(crate) struct CooldownAggregate {
171 pub(crate) total_uses: u32,
172}
173
174#[derive(Clone, Debug)]
176pub struct RunningDpsStats {
177 pub mean_dps: f64,
178 pub std_dps: f64,
180 pub min_dps: f64,
181 pub max_dps: f64,
182}
183
184#[derive(Clone, Copy, Debug, PartialEq)]
186pub struct RepresentativeIteration {
187 pub seed_prefix: u64,
188 pub iteration: u32,
189 pub dps: f64,
190}
191
192#[derive(Debug)]
194pub struct TelemetryAccumulator {
196 pub(crate) iteration_count: u32,
197 pub(crate) dps_sum: f64,
198 pub(crate) dps_min: f64,
199 pub(crate) dps_max: f64,
200 pub(crate) spell_totals: FastMap<ActionAggregateKey, SpellAggregate>,
201 pub(crate) dps_welford_mean: f64,
202 pub(crate) dps_m2: f64,
203 pub(crate) dps_histogram: Histogram<u64>,
204 pub(crate) representative_dps: Option<f64>,
205 pub(crate) representative_candidates: Vec<RepresentativeIteration>,
206 pub(crate) aura_totals: FastMap<AuraAggregateKey, AuraAggregate>,
207 pub(crate) resource_totals: IntMap<u8, ResourceAggregate>,
208 pub(crate) cooldown_totals: IntMap<u32, CooldownAggregate>,
209 pub(crate) total_duration_ms: f64,
210 pub(crate) gcd_locked_ms_total: f64,
211 pub(crate) representative_sink: Option<RepresentativeTimeline>,
212 pub(crate) direct_damage_total: f64,
213 pub(crate) periodic_damage_total: f64,
214 pub(crate) pet_damage_total: f64,
215 pub(crate) damage_by_target: FastMap<EnemyIdx, f64>,
216 pub(crate) targets: FastMap<TargetIdx, TargetMetadata>,
217 pub(crate) bucket_sums: Vec<f64>,
218 pub(crate) bucket_samples: Vec<u64>,
220 pub(crate) trace_extras_enabled: bool,
221}
222impl TelemetryAccumulator {
225 #[must_use]
227 pub fn new() -> Self {
228 Self {
229 iteration_count: 0,
230 dps_sum: 0.0,
231 dps_min: f64::MAX,
232 dps_max: f64::MIN,
233 spell_totals: FastMap::default(),
234 dps_welford_mean: 0.0,
235 dps_m2: 0.0,
236 dps_histogram: new_dps_histogram(),
237 representative_dps: None,
238 representative_candidates: Vec::new(),
239 aura_totals: FastMap::default(),
240 resource_totals: IntMap::default(),
241 cooldown_totals: IntMap::default(),
242 total_duration_ms: 0.0,
243 gcd_locked_ms_total: 0.0,
244 representative_sink: None,
245 direct_damage_total: 0.0,
246 periodic_damage_total: 0.0,
247 pet_damage_total: 0.0,
248 damage_by_target: FastMap::default(),
249 targets: FastMap::default(),
250 bucket_sums: Vec::new(),
251 bucket_samples: Vec::new(),
252 trace_extras_enabled: false,
253 }
254 }
255
256 pub fn enable_trace_extras(&mut self) {
258 self.trace_extras_enabled = true;
259 }
260
261 #[must_use]
263 pub fn running_stats(&self) -> Option<RunningDpsStats> {
264 if self.iteration_count == 0 {
265 return None;
266 }
267
268 let n = f64::from(self.iteration_count);
269 let mean = self.dps_sum / n;
270 let variance = self.dps_m2 / n;
271 let std = if variance > 0.0 { variance.sqrt() } else { 0.0 };
272
273 Some(RunningDpsStats {
274 mean_dps: mean,
275 std_dps: std,
276 min_dps: self.dps_min,
277 max_dps: self.dps_max,
278 })
279 }
280
281 #[inline]
283 pub fn record_iteration(&mut self, dps: f64) {
284 let iteration = self.iteration_count;
285
286 self.record_iteration_for(0, iteration, dps);
287 }
288
289 #[must_use]
291 pub fn representative_iteration(&self) -> Option<RepresentativeIteration> {
292 let mean = self.running_stats()?.mean_dps;
293
294 self.representative_candidates
296 .iter()
297 .copied()
298 .min_by(|a, b| {
299 let distance_order = (a.dps - mean).abs().total_cmp(&(b.dps - mean).abs());
300 distance_order
301 .then_with(|| a.seed_prefix.cmp(&b.seed_prefix))
302 .then_with(|| a.iteration.cmp(&b.iteration))
303 })
304 }
306
307 #[must_use]
309 pub const fn representative_dps(&self) -> Option<f64> {
310 self.representative_dps
311 }
312
313 pub(crate) fn record_sink_events(&mut self, sink: &TelemetrySink, duration_secs: f64) {
315 let duration_ms = duration_secs * MS_PER_SECOND;
316
317 self.total_duration_ms += duration_ms;
318 let duration_ms_u32 = f64_to_u32_saturating_trunc(duration_ms);
319
320 let num_buckets = duration_ms_u32.div_ceil(TIMELINE_BUCKET_MS) as usize;
321
322 if self.bucket_sums.len() < num_buckets {
323 self.bucket_sums.resize(num_buckets, 0.0);
324 }
325
326 if self.bucket_samples.len() < num_buckets {
327 self.bucket_samples.resize(num_buckets, 0);
328 }
329
330 for slot in self.bucket_samples.iter_mut().take(num_buckets) {
331 *slot += 1;
332 }
333
334 self.record_damage_events(sink, duration_ms_u32);
335 self.record_aura_events(sink, duration_ms_u32);
336 self.record_resource_events(sink);
337
338 for event in &sink.cooldowns {
339 let entry = self.cooldown_totals.entry(event.spell_id).or_default();
340
341 entry.total_uses += 1;
342 }
343
344 self.record_action_events(sink);
345 self.targets.extend(
346 sink.targets
347 .iter()
348 .cloned()
349 .map(|target| (target.id, target)),
350 );
351 }
352
353 #[inline]
354 pub(crate) fn record_iteration_for(&mut self, seed_prefix: u64, iteration: u32, dps: f64) {
355 self.iteration_count += 1;
356 self.dps_sum += dps;
357
358 if dps < self.dps_min {
359 self.dps_min = dps;
360 }
361
362 if dps > self.dps_max {
363 self.dps_max = dps;
364 }
365
366 let delta = dps - self.dps_welford_mean;
367
368 self.dps_welford_mean += delta / f64::from(self.iteration_count);
369 let delta2 = dps - self.dps_welford_mean;
370
371 self.dps_m2 += delta * delta2;
372
373 let _ = self.dps_histogram.record(dps_histogram_sample(dps));
374
375 self.representative_candidates
376 .push(RepresentativeIteration {
377 seed_prefix,
378 iteration,
379 dps,
380 });
381 }
382
383 pub(crate) fn install_representative(
384 &mut self,
385 dps: f64,
386 sink: &TelemetrySink,
387 duration_secs: f64,
388 ) {
389 self.representative_dps = Some(dps);
390 let duration_ms = f64_to_u32_saturating_trunc(duration_secs * MS_PER_SECOND);
391
392 self.capture_representative(sink, duration_ms);
393 }
394
395 fn record_damage_events(&mut self, sink: &TelemetrySink, duration_ms: u32) {
396 for event in &sink.damage {
397 let amount = event.amount;
398
399 if event.is_pet {
400 self.pet_damage_total += amount;
401 } else if event.is_periodic {
402 self.periodic_damage_total += amount;
403 } else {
404 self.direct_damage_total += amount;
405 }
406
407 *self
408 .damage_by_target
409 .entry(event.scope.target)
410 .or_insert(0.0) += amount;
411
412 let timeline_ms = if event.time_ms == duration_ms && duration_ms > 0 {
414 event.time_ms - 1
415 } else {
416 event.time_ms
417 };
418 let bucket = (timeline_ms / TIMELINE_BUCKET_MS) as usize;
419
420 if let Some(slot) = self.bucket_sums.get_mut(bucket) {
421 *slot += amount;
422 }
423 }
424 }
425
426 fn record_aura_events(&mut self, sink: &TelemetrySink, duration_ms: u32) {
427 collect_aura_intervals(&sink.auras, |event, start, end| {
428 let uptime = f64::from(end.unwrap_or(duration_ms).saturating_sub(start));
429
430 self.aura_totals
431 .entry(AuraAggregateKey::from(event))
432 .or_default()
433 .uptime_ms += uptime;
434 });
435
436 for event in &sink.auras {
437 let aggregate = self
438 .aura_totals
439 .entry(AuraAggregateKey::from(event))
440 .or_default();
441
442 match event.kind {
443 AuraEventKind::Apply { .. } => aggregate.applications += 1,
444 AuraEventKind::Refresh { .. } => aggregate.refreshes += 1,
445 AuraEventKind::Expire => {}
446 }
447 }
448 }
449
450 fn record_resource_events(&mut self, sink: &TelemetrySink) {
451 for event in &sink.resources {
452 let entry = self.resource_totals.entry(event.resource_type).or_default();
453
454 match event.kind {
455 ResourceEventKind::Spend => entry.total_spent += event.amount,
456 ResourceEventKind::Gain { wasted } => {
457 entry.total_gained += event.amount;
458 entry.total_wasted += wasted;
459 let source = entry
460 .gained_by_source
461 .entry(event.source_spell_id)
462 .or_default();
463
464 source.gained += event.amount;
465 source.wasted += wasted;
466 }
467 }
468 }
469 }
470
471 fn record_action_events(&mut self, sink: &TelemetrySink) {
472 for cast in &sink.casts {
473 let entry = self
474 .spell_totals
475 .entry(ActionAggregateKey::from_scope(cast.spell_id, cast.scope))
476 .or_default();
477
478 entry.casts += 1;
479 self.gcd_locked_ms_total += f64::from(cast.gcd_ms);
480 }
481
482 for damage in &sink.damage {
483 let entry = self
484 .spell_totals
485 .entry(ActionAggregateKey::from_scope(
486 damage.spell_id,
487 damage.scope,
488 ))
489 .or_default();
490
491 entry.damage += damage.amount;
492 entry.ticks += u32::from(damage.is_periodic);
495 entry.crits += u32::from(damage.is_crit);
496 entry.hits += u32::from(!damage.is_periodic);
497 }
498 }
499}
500
501impl Default for TelemetryAccumulator {
502 fn default() -> Self {
503 Self::new()
504 }
505}
506
507#[cfg(test)]
508#[path = "tests.rs"]
509mod tests;