Skip to main content

wowlab_engine_sim/telemetry/
encode.rs

1//! Protobuf encoding of accumulated telemetry into a `ChunkTelemetry` message.
2
3use 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    /// Encode accumulated telemetry as protobuf bytes.
24    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
134// #t(fn: rust_cyclomatic_complexity) flat exhaustive match over the ResourceType discriminants.
135fn 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}