1use hdrhistogram::{
4 Histogram,
5 serialization::{Serializer, V2Serializer},
6};
7use itertools::Itertools;
8use prost::Message;
9use wowlab_types::{
10 constants::{PROTO_DPS_SCALE, PROTO_RESOURCE_SCALE},
11 numeric::{f64_to_u32_saturating_round, f64_to_u64_saturating_round},
12 proto,
13 sim::{ActorId, FastMap, IntMap, TargetIdx},
14};
15
16use super::{
17 ActionAggregateKey, AuraAggregate, AuraAggregateKey, CooldownAggregate, ResourceAggregate,
18 SpellAggregate, TIMELINE_BUCKET_MS, TelemetryAccumulator, actor_sort_key,
19 representative::RepresentativeTimeline,
20};
21
22impl TelemetryAccumulator {
23 pub fn encode(self, chunk_index: u32, permutation_index: Option<u64>) -> Vec<u8> {
25 let n = self.iteration_count;
26
27 if n == 0 {
28 return proto::ChunkTelemetry {
29 chunk_index,
30 permutation_index,
31 ..Default::default()
32 }
33 .encode_to_vec();
34 }
35
36 let nf = f64::from(n);
37 let mean_dps = self.dps_sum / nf;
38 let mean_dps_x10 = dps_to_x10(mean_dps);
39 let min_dps_x10 = dps_to_x10(self.dps_min);
40 let max_dps_x10 = dps_to_x10(self.dps_max);
41
42 let m2_dps_bits = self.dps_m2.to_bits();
43
44 let total_fight_time_ms = f64_to_u64_saturating_round(self.total_duration_ms);
45 let histogram = serialize_histogram(&self.dps_histogram);
46
47 let actions = build_actions(&self.spell_totals);
48 let auras = build_auras(&self.aura_totals);
49 let resources = build_resources(&self.resource_totals);
50 let cooldowns = build_cooldowns(&self.cooldown_totals);
51
52 let execution = Some(proto::ExecutionData {
53 active_time_ms: 0,
54 idle_time_ms: 0,
55 gcd_locked_time_ms: f64_to_u64_saturating_round(self.gcd_locked_ms_total),
56 idle_gcd_count: 0,
57 queue_lag_sum_ms: 0,
58 queue_lag_count: 0,
59 action_count_per_bucket: vec![],
60 action_bucket_samples: vec![],
61 });
62
63 let damage_profile = Some(build_damage_profile(
64 self.direct_damage_total,
65 self.periodic_damage_total,
66 self.pet_damage_total,
67 &self.damage_by_target,
68 ));
69
70 let dps_bucket_sums_x10: Vec<u64> = self
71 .bucket_sums
72 .iter()
73 .map(|&v| f64_to_u64_saturating_round(v * PROTO_DPS_SCALE))
74 .collect();
75 let dps_bucket_samples = self.bucket_samples;
76
77 let representative_dps_x10 = self.representative_dps.map_or(0, dps_to_x10);
78
79 let representative = self.representative_sink.as_ref().map(build_representative);
80 let dictionary = Some(build_dictionary(
81 &actions,
82 &auras,
83 representative.as_ref(),
84 &self.targets,
85 ));
86
87 let instrumentation_coverage = Some(proto::InstrumentationCoverage {
88 has_resource_cap_time: false,
89 has_resource_starvation_time: false,
90 has_cooldown_drift: false,
91 has_phase_markers: false,
92 has_proc_aggregates: false,
93 });
94
95 let telemetry = proto::ChunkTelemetry {
96 chunk_index,
97 iterations: n,
98 total_fight_time_ms,
99 mean_dps_x10,
100 m2_dps_bits,
101 min_dps_x10,
102 max_dps_x10,
103 histogram: Some(histogram),
104 actions,
105 auras,
106 resources,
107 cooldowns,
108 execution,
109 damage_profile,
110 dps_bucket_sums_x10,
111 dps_bucket_samples,
112 bucket_ms: TIMELINE_BUCKET_MS,
113 representative,
114 representative_dps_x10,
115 dictionary,
116 instrumentation_coverage,
117 permutation_index,
118 };
119
120 telemetry.encode_to_vec()
121 }
122}
123
124fn dps_to_x10(dps: f64) -> u32 {
125 if dps.is_nan() || dps <= 0.0 {
126 0
127 } else if dps * PROTO_DPS_SCALE >= f64::from(u32::MAX) {
128 u32::MAX
129 } else {
130 f64_to_u32_saturating_round(dps * PROTO_DPS_SCALE)
131 }
132}
133
134fn resource_type_slug(rt: u8) -> &'static str {
136 use wowlab_types::combat::ResourceType as R;
137
138 match R::try_from(rt) {
139 Ok(R::Mana) => "mana",
140 Ok(R::Rage) => "rage",
141 Ok(R::Focus) => "focus",
142 Ok(R::Energy) => "energy",
143 Ok(R::ComboPoints) => "combo_points",
144 Ok(R::Runes) => "runes",
145 Ok(R::RunicPower) => "runic_power",
146 Ok(R::SoulShards) => "soul_shards",
147 Ok(R::LunarPower) => "astral_power",
148 Ok(R::HolyPower) => "holy_power",
149 Ok(R::Maelstrom) => "maelstrom",
150 Ok(R::Chi) => "chi",
151 Ok(R::Insanity) => "insanity",
152 Ok(R::ArcaneCharges) => "arcane_charges",
153 Ok(R::Fury) => "fury",
154 Ok(R::Pain) => "pain",
155 Ok(R::Essence) => "essence",
156 Ok(R::Alternate) => "alternate",
157 Ok(R::AlternateQuest) => "alternate_quest",
158 Ok(R::AlternateEncounter) => "alternate_encounter",
159 Ok(R::AlternateMount) => "alternate_mount",
160 Err(_) => "",
161 }
162}
163
164pub(crate) fn serialize_histogram(hdr: &Histogram<u64>) -> proto::HistogramData {
165 let mut out = Vec::new();
166 let mut ser = V2Serializer::new();
167 let hdr_v2 = match ser.serialize(hdr, &mut out) {
168 Ok(_) => out,
169 Err(e) => {
170 tracing::warn!(error = %e, "histogram serialization failed, returning empty");
171
172 vec![]
173 }
174 };
175
176 proto::HistogramData { hdr_v2 }
177}
178
179#[derive(Clone, Copy)]
180struct ProtoActorIdentity {
181 kind: i32,
182 id: u32,
183}
184
185fn actor_proto_identity(actor: ActorId) -> ProtoActorIdentity {
186 match actor {
187 ActorId::Player | ActorId::External => ProtoActorIdentity {
188 kind: proto::ActorKind::Player as i32,
189 id: 0,
190 },
191 ActorId::Pet(pet) => ProtoActorIdentity {
192 kind: proto::ActorKind::Pet as i32,
193 id: u32::from(pet.0),
194 },
195 ActorId::Enemy(enemy) => ProtoActorIdentity {
196 kind: proto::ActorKind::Enemy as i32,
197 id: u32::from(enemy.0),
198 },
199 }
200}
201
202const fn action_source(actor: ActorId) -> i32 {
203 match actor {
204 ActorId::Player | ActorId::External => proto::ActionSource::Player as i32,
205 ActorId::Pet(_) => proto::ActionSource::Pet as i32,
206 ActorId::Enemy(_) => proto::ActionSource::Enemy as i32,
207 }
208}
209
210fn build_actions(
211 spell_totals: &FastMap<ActionAggregateKey, SpellAggregate>,
212) -> Vec<proto::ActionRow> {
213 spell_totals
214 .iter()
215 .sorted_by_key(|(key, _)| {
216 (
217 key.spell_id,
218 key.scope.target,
219 actor_sort_key(key.scope.source),
220 key.scope.pull,
221 key.scope.group,
222 )
223 })
224 .map(|(key, agg)| {
225 let source = actor_proto_identity(key.scope.source);
226
227 proto::ActionRow {
228 spell_id: key.spell_id,
229 target_id: u32::from(key.scope.target.0),
230 source: action_source(key.scope.source),
231 source_kind: source.kind,
232 source_id: source.id,
233 pull_id: Some(u32::from(key.scope.pull.0)),
234 group_id: Some(u32::from(key.scope.group.0)),
235 casts: u64::from(agg.casts),
236 direct_hits: u64::from(agg.hits),
237 ticks: u64::from(agg.ticks),
238 crits: u64::from(agg.crits),
239 misses: 0,
240 dodges: 0,
241 parries: 0,
242 total_damage_x10: f64_to_u64_saturating_round(agg.damage * PROTO_DPS_SCALE),
243 execute_time_ms: 0,
244 resource_spent_x100: 0,
245 resource_gained_x100: 0,
246 }
247 })
248 .collect()
249}
250
251fn build_auras(aura_totals: &FastMap<AuraAggregateKey, AuraAggregate>) -> Vec<proto::AuraRow> {
252 aura_totals
253 .iter()
254 .sorted_by_key(|(key, _)| {
255 (
256 key.aura_id,
257 actor_sort_key(key.scope.affected),
258 actor_sort_key(key.scope.source),
259 key.scope.pull,
260 key.scope.group,
261 )
262 })
263 .map(|(key, aggregate)| {
264 let source = actor_proto_identity(key.scope.source);
265 let affected = actor_proto_identity(key.scope.affected);
266
267 proto::AuraRow {
268 aura_id: key.aura_id,
269 uptime_ms: f64_to_u64_saturating_round(aggregate.uptime_ms),
270 applications: aggregate.applications,
271 refreshes: aggregate.refreshes,
272 stack_seconds_x100: 0,
273 target_id: match key.scope.affected {
274 ActorId::Enemy(enemy) => u32::from(enemy.0),
275 ActorId::Player | ActorId::External | ActorId::Pet(_) => 0,
276 },
277 source_kind: source.kind,
278 source_id: source.id,
279 affected_kind: affected.kind,
280 affected_id: affected.id,
281 pull_id: Some(u32::from(key.scope.pull.0)),
282 group_id: key.scope.group.map(|group| u32::from(group.0)),
283 }
284 })
285 .collect()
286}
287
288fn build_resources(resource_totals: &IntMap<u8, ResourceAggregate>) -> Vec<proto::ResourceRow> {
289 resource_totals
290 .iter()
291 .sorted_by_key(|&(&resource_type, _)| resource_type)
292 .map(|(&resource_type, agg)| proto::ResourceRow {
293 resource_type: u32::from(resource_type),
294 gained_x100: f64_to_u64_saturating_round(agg.total_gained * PROTO_RESOURCE_SCALE),
295 spent_x100: f64_to_u64_saturating_round(agg.total_spent * PROTO_RESOURCE_SCALE),
296 wasted_x100: f64_to_u64_saturating_round(
297 agg.total_wasted.max(0.0) * PROTO_RESOURCE_SCALE,
298 ),
299 time_at_cap_ms: 0,
300 starved_time_ms: 0,
301 by_source: agg
302 .gained_by_source
303 .iter()
304 .sorted_by_key(|&(&source, _)| source)
305 .map(|(&source_spell_id, aggregate)| proto::ResourceSourceRow {
306 source_spell_id,
307 gained_x100: f64_to_u64_saturating_round(
308 aggregate.gained * PROTO_RESOURCE_SCALE,
309 ),
310 wasted_x100: f64_to_u64_saturating_round(
311 aggregate.wasted.max(0.0) * PROTO_RESOURCE_SCALE,
312 ),
313 })
314 .collect(),
315 })
316 .collect()
317}
318
319fn build_cooldowns(cooldown_totals: &IntMap<u32, CooldownAggregate>) -> Vec<proto::CooldownRow> {
320 cooldown_totals
321 .iter()
322 .sorted_by_key(|&(&spell_id, _)| spell_id)
323 .map(|(&spell_id, agg)| proto::CooldownRow {
324 spell_id,
325 uses: u64::from(agg.total_uses),
326 possible_uses: 0,
327 drift_sum_ms: 0,
328 max_drift_ms: 0,
329 })
330 .collect()
331}
332
333fn build_damage_profile(
334 direct: f64,
335 periodic: f64,
336 pet: f64,
337 damage_by_target: &FastMap<wowlab_types::sim::EnemyIdx, f64>,
338) -> proto::DamageProfileData {
339 let by_target = damage_by_target
340 .iter()
341 .sorted_by_key(|(target, _)| **target)
342 .map(|(target, damage)| proto::DamageByTarget {
343 target_id: u32::from(target.0),
344 total_damage_x10: f64_to_u64_saturating_round(damage * PROTO_DPS_SCALE),
345 })
346 .collect();
347
348 if direct + periodic + pet > 0.0 {
349 proto::DamageProfileData {
350 direct_damage_x10: f64_to_u64_saturating_round(direct * PROTO_DPS_SCALE),
351 periodic_damage_x10: f64_to_u64_saturating_round(periodic * PROTO_DPS_SCALE),
352 pet_damage_x10: f64_to_u64_saturating_round(pet * PROTO_DPS_SCALE),
353 by_target,
354 }
355 } else {
356 proto::DamageProfileData::default()
357 }
358}
359
360fn build_representative(rep: &RepresentativeTimeline) -> proto::TimelineSnapshot {
361 let mut prev_ms: u32 = 0;
362 let markers: Vec<proto::TimelineMarker> = rep
363 .markers
364 .iter()
365 .map(|m| {
366 let delta = m.time_ms.saturating_sub(prev_ms);
367
368 prev_ms = m.time_ms;
369
370 proto::TimelineMarker {
371 delta_time_ms: delta,
372 sequence: m.sequence,
373 kind: m.kind as i32,
374 spell_or_aura_id: m.spell_or_aura_id,
375 target_id: m.target,
376 amount: m.amount,
377 is_crit: m.is_crit,
378 source_kind: m
379 .source
380 .map_or(proto::ActorKind::Unspecified as i32, |source| {
381 actor_proto_identity(source).kind
382 }),
383 source_id: m.source.map_or(0, |source| actor_proto_identity(source).id),
384 pull_id: m.pull.map(|pull| u32::from(pull.0)),
385 group_id: m.group.map(|group| u32::from(group.0)),
386 }
387 })
388 .collect();
389
390 let aura_windows: Vec<proto::AuraWindow> = rep
391 .aura_windows
392 .iter()
393 .map(|w| proto::AuraWindow {
394 aura_id: w.aura_id,
395 target_id: w.target,
396 start_ms: w.start_ms,
397 end_ms: w.end_ms,
398 source_kind: w
399 .source
400 .map_or(proto::ActorKind::Unspecified as i32, |source| {
401 actor_proto_identity(source).kind
402 }),
403 source_id: w.source.map_or(0, |source| actor_proto_identity(source).id),
404 affected_kind: w
405 .affected
406 .map_or(proto::ActorKind::Unspecified as i32, |affected| {
407 actor_proto_identity(affected).kind
408 }),
409 affected_id: w
410 .affected
411 .map_or(0, |affected| actor_proto_identity(affected).id),
412 pull_id: w.pull.map(|pull| u32::from(pull.0)),
413 group_id: w.group.map(|group| u32::from(group.0)),
414 })
415 .collect();
416
417 let cooldown_windows: Vec<proto::CooldownWindow> = rep
418 .cooldown_windows
419 .iter()
420 .map(|w| proto::CooldownWindow {
421 spell_id: w.spell_id,
422 start_ms: w.start_ms,
423 duration_ms: w.duration_ms,
424 })
425 .collect();
426
427 let resource_samples: Vec<proto::ResourceSampleProto> = rep
428 .resource_samples
429 .iter()
430 .map(|s| proto::ResourceSampleProto {
431 time_ms: s.time_ms,
432 resource_name: resource_type_slug(s.resource_type).to_string(),
433 current: s.current,
434 max: s.max,
435 gain: s.gain,
436 loss: s.loss,
437 })
438 .collect();
439
440 let aura_intervals: Vec<proto::AuraIntervalProto> = rep
441 .aura_intervals
442 .iter()
443 .map(|i| proto::AuraIntervalProto {
444 start_ms: i.start_ms,
445 end_ms: i.end_ms,
446 open_ended: i.open_ended,
447 aura_slug: i.aura_id.to_string(),
448 source: match i.source {
449 ActorId::Player => "player",
450 ActorId::External => "external",
451 ActorId::Pet(_) => "pet",
452 ActorId::Enemy(_) => "enemy",
453 }
454 .to_string(),
455 target_id: match i.affected {
456 ActorId::Enemy(enemy) => u32::from(enemy.0),
457 ActorId::Player | ActorId::External | ActorId::Pet(_) => 0,
458 },
459 source_kind: actor_proto_identity(i.source).kind,
460 source_id: actor_proto_identity(i.source).id,
461 affected_kind: actor_proto_identity(i.affected).kind,
462 affected_id: actor_proto_identity(i.affected).id,
463 pull_id: Some(u32::from(i.pull.0)),
464 group_id: i.group.map(|group| u32::from(group.0)),
465 })
466 .collect();
467
468 let encounter_events = build_encounter_events(&rep.encounter_events);
469
470 proto::TimelineSnapshot {
471 markers,
472 aura_windows,
473 cooldown_windows,
474 phase_markers: vec![],
475 resource_samples,
476 aura_intervals,
477 encounter_events,
478 }
479}
480
481fn build_encounter_events(
482 events: &[super::representative::RepresentativeEncounterEvent],
483) -> Vec<proto::EncounterTimelineEvent> {
484 events
485 .iter()
486 .map(|captured| {
487 let event = &captured.event;
488 let actor = actor_proto_identity(event.actor);
489 let (x, y, heading, layer_id) =
490 event
491 .transform
492 .map_or((None, None, None, None), |transform| {
493 (
494 Some(transform.position.x),
495 Some(transform.position.y),
496 Some(transform.heading),
497 Some(u32::from(transform.layer.0)),
498 )
499 });
500
501 proto::EncounterTimelineEvent {
502 kind: match event.kind {
503 wowlab_engine_telemetry::EncounterTelemetryEventKind::Spawn => {
504 proto::EncounterEventKind::Spawn as i32
505 }
506 wowlab_engine_telemetry::EncounterTelemetryEventKind::Movement => {
507 proto::EncounterEventKind::Movement as i32
508 }
509 wowlab_engine_telemetry::EncounterTelemetryEventKind::Death => {
510 proto::EncounterEventKind::Death as i32
511 }
512 wowlab_engine_telemetry::EncounterTelemetryEventKind::Despawn => {
513 proto::EncounterEventKind::Despawn as i32
514 }
515 wowlab_engine_telemetry::EncounterTelemetryEventKind::TargetChange => {
516 proto::EncounterEventKind::TargetChange as i32
517 }
518 wowlab_engine_telemetry::EncounterTelemetryEventKind::PullTransition => {
519 proto::EncounterEventKind::PullTransition as i32
520 }
521 },
522 time_ms: event.time_ms,
523 actor_kind: actor.kind,
524 actor_id: actor.id,
525 previous_target_id: event.previous_target.map(|target| u32::from(target.0)),
526 target_id: event.target.map(|target| u32::from(target.0)),
527 pull_id: u32::from(event.pull.0),
528 group_id: event.group.map(|group| u32::from(group.0)),
529 x,
530 y,
531 heading,
532 layer_id,
533 sequence: captured.sequence,
534 }
535 })
536 .collect()
537}
538
539fn build_dictionary(
540 actions: &[proto::ActionRow],
541 auras: &[proto::AuraRow],
542 representative: Option<&proto::TimelineSnapshot>,
543 targets: &FastMap<TargetIdx, wowlab_engine_telemetry::TargetMetadata>,
544) -> proto::DictionaryView {
545 let mut spell_ids = std::collections::BTreeSet::new();
546 let mut aura_ids = std::collections::BTreeSet::new();
547
548 for row in actions {
549 if row.spell_id != 0 {
550 spell_ids.insert(row.spell_id);
551 }
552 }
553
554 for row in auras {
555 if row.aura_id != 0 {
556 aura_ids.insert(row.aura_id);
557 }
558 }
559
560 if let Some(snap) = representative {
561 for m in &snap.markers {
562 spell_ids.insert(m.spell_or_aura_id);
563 }
564
565 for w in &snap.aura_windows {
566 aura_ids.insert(w.aura_id);
567 }
568
569 for w in &snap.cooldown_windows {
570 spell_ids.insert(w.spell_id);
571 }
572 }
573
574 let target_entries: Vec<_> = targets
575 .iter()
576 .sorted_by_key(|(id, _)| **id)
577 .map(|(id, target)| proto::TargetEntry {
578 id: id.0.into(),
579 label: target.display_name.clone(),
580 npc_id: target.npc_id,
581 group_id: Some(u32::from(target.group.0)),
582 enemy_tags: target.enemy_tags.iter().map(ToString::to_string).collect(),
583 group_tags: target.group_tags.iter().map(ToString::to_string).collect(),
584 })
585 .collect();
586 let mut units = std::collections::BTreeSet::new();
587
588 units.insert((proto::ActorKind::Player as i32, 0));
589
590 for target in targets.keys() {
591 units.insert((proto::ActorKind::Enemy as i32, u32::from(target.0)));
592 }
593
594 for action in actions {
595 units.insert((action.source_kind, action.source_id));
596 }
597
598 for aura in auras {
599 units.insert((aura.source_kind, aura.source_id));
600 units.insert((aura.affected_kind, aura.affected_id));
601 }
602
603 let unit_entries = units
604 .into_iter()
605 .filter(|(kind, _)| *kind != proto::ActorKind::Unspecified as i32)
606 .map(|(kind, id)| {
607 let target = (kind == proto::ActorKind::Enemy as i32)
608 .then(|| {
609 u16::try_from(id)
610 .ok()
611 .and_then(|id| targets.get(&TargetIdx(id)))
612 })
613 .flatten();
614
615 proto::UnitEntry {
616 kind,
617 id,
618 label: target.map_or_else(
619 || match proto::ActorKind::try_from(kind) {
620 Ok(proto::ActorKind::Player) => "Player".to_string(),
621 Ok(proto::ActorKind::Pet) => format!("Pet {id}"),
622 Ok(proto::ActorKind::Enemy) => format!("Enemy {id}"),
623 Ok(proto::ActorKind::Unspecified) | Err(_) => "Unknown".to_string(),
624 },
625 |target| target.display_name.clone(),
626 ),
627 target_id: (kind == proto::ActorKind::Enemy as i32).then_some(id),
628 }
629 })
630 .collect();
631
632 proto::DictionaryView {
633 spell_ids: spell_ids.into_iter().collect(),
634 aura_ids: aura_ids.into_iter().collect(),
635 targets: target_entries,
636 units: unit_entries,
637 }
638}