Skip to main content

forge/
render.rs

1// #t(file: rust_alloc_in_loop) CLI binary, allocations are fine for readability.
2// #t(file: rust_clone_in_loop) CLI binary, cloning strings in display loops is fine.
3
4//! Comparison rendering: DPS table, spell breakdown, and timeline.
5
6use std::collections::BTreeMap;
7
8#[cfg(test)]
9use googletest::{Result as GtestResult, prelude::*};
10use tabled::Tabled;
11use wowlab_common::output;
12use wowlab_types::constants::HUNDRED;
13
14use crate::provider::{CastEntry, SimOutput, SpellResult};
15
16const MAX_SPELL_NAME_LEN: usize = 24;
17const TIMELINE_MATCH_TOLERANCE: f64 = 0.05;
18const TIMELINE_ORDER_SLACK: f64 = 0.001;
19
20#[derive(Tabled)]
21struct DpsRow {
22    #[tabled(rename = "Source")]
23    source: String,
24    #[tabled(rename = "Mean DPS")]
25    mean_dps: String,
26}
27
28#[derive(Tabled)]
29struct SpellRow {
30    #[tabled(rename = "Spell")]
31    name: String,
32    #[tabled(rename = "wl DPS")]
33    wl_dps: String,
34    #[tabled(rename = "wl casts")]
35    wl_casts: String,
36    #[tabled(rename = "wl hits")]
37    wl_hits: String,
38    #[tabled(rename = "wl %")]
39    wl_pct: String,
40    #[tabled(rename = "sc DPS")]
41    sc_dps: String,
42    #[tabled(rename = "sc casts")]
43    sc_casts: String,
44    #[tabled(rename = "sc hits")]
45    sc_hits: String,
46    #[tabled(rename = "sc %")]
47    sc_pct: String,
48    #[tabled(rename = "Delta")]
49    delta: String,
50    #[tabled(rename = "Δ%")]
51    delta_pct: String,
52    #[tabled(rename = "dpc Δ%")]
53    dpc_delta_pct: String,
54    #[tabled(rename = "dph Δ%")]
55    dph_delta_pct: String,
56}
57
58#[derive(Tabled)]
59struct TimelineRow {
60    #[tabled(rename = "Time")]
61    time: String,
62    #[tabled(rename = "wowlab")]
63    wl_spell: String,
64    #[tabled(rename = "SimC")]
65    sc_spell: String,
66}
67
68pub(crate) fn print_banner(slug: &str, parameters: crate::run::RunParameters) {
69    print_encounter_banner(slug, parameters, "Patchwerk");
70}
71
72pub(crate) fn print_encounter_banner(
73    slug: &str,
74    parameters: crate::run::RunParameters,
75    encounter: &str,
76) {
77    let iterations = parameters.iterations();
78    let duration = parameters.fight_duration_secs();
79
80    output::blank();
81    output::header(&format!(
82        "{slug} -- {iterations} iter, {duration}s {encounter}"
83    ));
84}
85
86pub(crate) fn print_dps_comparison(wl: &SimOutput, sc: &SimOutput, wl_name: &str, sc_name: &str) {
87    let diff = wl.dps - sc.dps;
88    let pct = if sc.dps > 0.0 {
89        diff / sc.dps * HUNDRED
90    } else {
91        0.0
92    };
93
94    output::blank();
95    output::subheader("DPS Comparison");
96
97    let rows = vec![
98        DpsRow {
99            source: wl_name.to_string(),
100            mean_dps: format!("{:.1}", wl.dps),
101        },
102        DpsRow {
103            source: sc_name.to_string(),
104            mean_dps: format!("{:.1}", sc.dps),
105        },
106        DpsRow {
107            source: "Delta".to_string(),
108            mean_dps: format!("{diff:+.1} ({pct:+.1}%)"),
109        },
110    ];
111
112    output::table(rows);
113    output::blank();
114}
115
116pub(crate) fn print_spell_comparison(wl: &SimOutput, sc: &SimOutput) {
117    let wl_spells = attribution_normalized_spells(wl);
118    let sc_spells = attribution_normalized_spells(sc);
119    let mut all_names: Vec<String> = wl_spells.keys().chain(sc_spells.keys()).cloned().collect();
120
121    all_names.sort();
122    all_names.dedup();
123
124    all_names.sort_by(|a, b| {
125        let a_dps = wl_spells
126            .get(a)
127            .map_or(0.0, |s| s.dps)
128            .max(sc_spells.get(a).map_or(0.0, |s| s.dps));
129        let b_dps = wl_spells
130            .get(b)
131            .map_or(0.0, |s| s.dps)
132            .max(sc_spells.get(b).map_or(0.0, |s| s.dps));
133
134        b_dps
135            .partial_cmp(&a_dps)
136            .unwrap_or(std::cmp::Ordering::Equal)
137    });
138
139    output::subheader("Spell Breakdown");
140
141    let rows: Vec<SpellRow> = all_names
142        .iter()
143        .map(|name| {
144            let wl_s = wl_spells.get(name);
145            let sc_s = sc_spells.get(name);
146            let wl_dps = wl_s.map_or(0.0, |s| s.dps);
147            let sc_dps = sc_s.map_or(0.0, |s| s.dps);
148            let wl_casts = wl_s.map_or(0, |s| s.casts);
149            let sc_casts = sc_s.map_or(0, |s| s.casts);
150            let wl_hits = wl_s.map_or(0, |s| s.hits);
151            let sc_hits = sc_s.map_or(0, |s| s.hits);
152            let wl_pct = wl_s.map_or(0.0, |s| s.pct);
153            let sc_pct = sc_s.map_or(0.0, |s| s.pct);
154            let diff = wl_dps - sc_dps;
155            let diff_pct = if sc_dps > 0.0 {
156                diff / sc_dps * HUNDRED
157            } else if wl_dps > 0.0 {
158                f64::INFINITY
159            } else {
160                0.0
161            };
162
163            let delta_pct = if diff_pct.is_finite() {
164                format!("{diff_pct:+.1}%")
165            } else {
166                "  -- ".to_string()
167            };
168
169            // Proc and passive rows have no meaningful damage-per-cast delta.
170            let per_cast_delta_pct = per_event_delta_pct(wl_dps, wl_casts, sc_dps, sc_casts);
171            // Damage per landed hit is defined where dpc is not: pet, guardian, proc and DoT rows.
172            let per_hit_delta_pct = per_event_delta_pct(wl_dps, wl_hits, sc_dps, sc_hits);
173
174            let display_name: String = name.chars().take(MAX_SPELL_NAME_LEN).collect();
175
176            SpellRow {
177                name: display_name,
178                wl_dps: format!("{wl_dps:.1}"),
179                wl_casts: wl_casts.to_string(),
180                wl_hits: wl_hits.to_string(),
181                wl_pct: format!("{:.1}%", wl_pct * HUNDRED),
182                sc_dps: format!("{sc_dps:.1}"),
183                sc_casts: sc_casts.to_string(),
184                sc_hits: sc_hits.to_string(),
185                sc_pct: format!("{:.1}%", sc_pct * HUNDRED),
186                delta: format!("{diff:+.1}"),
187                delta_pct,
188                dpc_delta_pct: per_cast_delta_pct,
189                dph_delta_pct: per_hit_delta_pct,
190            }
191        })
192        .collect();
193
194    output::table(rows);
195
196    let wl_only: Vec<&String> = wl_spells
197        .keys()
198        .filter(|k| !sc_spells.contains_key(*k))
199        .collect();
200    let sc_only: Vec<&String> = sc_spells
201        .keys()
202        .filter(|k| !wl_spells.contains_key(*k))
203        .collect();
204
205    if !wl_only.is_empty() {
206        let names: Vec<&str> = wl_only.iter().map(|s| s.as_str()).collect();
207
208        output::detail(&format!("wowlab only: {}", names.join(", ")));
209    }
210
211    if !sc_only.is_empty() {
212        let names: Vec<&str> = sc_only.iter().map(|s| s.as_str()).collect();
213
214        output::detail(&format!("SimC only: {}", names.join(", ")));
215    }
216
217    output::blank();
218}
219
220/// `(wowlab / SimC - 1)` damage per event, or `--` when either side has no events.
221fn per_event_delta_pct(wl_dps: f64, wl_events: u32, sc_dps: f64, sc_events: u32) -> String {
222    if wl_events == 0 || sc_events == 0 || sc_dps <= 0.0 {
223        return "  -- ".to_string();
224    }
225
226    let wowlab_per_event = wl_dps / f64::from(wl_events);
227    let simc_per_event = sc_dps / f64::from(sc_events);
228
229    format!(
230        "{:+.1}%",
231        (wowlab_per_event / simc_per_event - 1.0) * HUNDRED
232    )
233}
234
235fn attribution_normalized_spells(output: &SimOutput) -> BTreeMap<String, SpellResult> {
236    let mut normalized = BTreeMap::new();
237
238    for (name, result) in &output.spells {
239        let entry = normalized
240            .entry(canonical_spell_name(name).to_string())
241            .or_insert_with(SpellResult::default);
242
243        entry.dps += result.dps;
244        entry.casts = entry.casts.max(result.casts);
245        entry.hits += result.hits;
246    }
247
248    let mut timeline_casts = BTreeMap::new();
249
250    for cast in &output.timeline {
251        *timeline_casts
252            .entry(canonical_spell_name(&cast.spell_name))
253            .or_insert(0) += 1;
254    }
255
256    for (name, casts) in timeline_casts {
257        normalized.entry(name.to_string()).or_default().casts = casts;
258    }
259
260    for result in normalized.values_mut() {
261        result.pct = if output.dps > 0.0 {
262            result.dps / output.dps
263        } else {
264            0.0
265        };
266    }
267
268    normalized
269}
270
271// #t(fn: rust_const_fn_candidate) string pattern matching is not const-stable on this toolchain.
272// #t(fn: rust_cyclomatic_complexity) canonical attribution aliases are a flat lookup table, not branching report logic
273fn canonical_spell_name(name: &str) -> &str {
274    match name {
275        "amplifying_poison_debuff" => "amplifying_poison",
276        "auto_attack_mh" | "auto_attack_oh" => "auto_attack",
277        "barbed_shot_dot" => "barbed_shot",
278        "bladestorm_mh" | "bladestorm_offhand" | "bladestorm_oh" => "bladestorm",
279        "bloodbath_bladestorm_unhinged" => "bloodbath",
280        "bloodshed_dot" => "bloodshed",
281        "bloodthirst_bladestorm_unhinged" => "bloodthirst",
282        "chain_lightning_ll_rtl" | "chain_lightning_ss_rtl" => "chain_lightning",
283        "execute_mainhand" | "execute_offhand" => "execute",
284        "instant_poison_damage" => "instant_poison",
285        "kill_command_pet" | "wildspeaker_kill_command" => "kill_command",
286        "lightning_bolt_ps" | "lightning_bolt_ti" => "lightning_bolt",
287        "lightning_rod_damage" => "lightning_rod",
288        "mutilate_mh" | "mutilate_oh" => "mutilate",
289        "pet_attack" | "pet_auto_attack_mh" | "pet_auto_attack_oh" | "infernal_melee"
290        | "dreadstalker_melee" => "melee",
291        "pet_bestial_wrath" => "bestial_wrath",
292        "phoenix_pyroblast" | "pyroblast_pyromaniac" => "pyroblast",
293        "odyns_fury_dot" | "odyns_fury_mh" | "odyns_fury_offhand" | "odyns_fury_oh" => "odyns_fury",
294        "raging_blow_mh" | "raging_blow_oh" => "raging_blow",
295        "rampage1" | "rampage2" | "rampage3" | "rampage4" => "rampage",
296        "rend_dot" => "rend",
297        "rune_of_unleashed_fire_lingering" => "rune_of_lingering",
298        "stormblast_stormstrike_mh" | "stormblast_stormstrike_offhand" => "stormblast",
299        "thundering_hooves" => "stomp",
300        "tremor_es" => "tremor",
301        "voltaic_blaze_damage" => "voltaic_blaze",
302        "whirlwind_mh_first"
303        | "whirlwind_mh_others"
304        | "whirlwind_oh_first"
305        | "whirlwind_oh_others" => "whirlwind",
306        _ => name,
307    }
308}
309
310pub(crate) fn print_resource_comparison(wl: &SimOutput, sc: &SimOutput) {
311    #[derive(Tabled)]
312    struct ResourceRow {
313        #[tabled(rename = "Source")]
314        name: String,
315        #[tabled(rename = "wl gain")]
316        wl_gain: String,
317        #[tabled(rename = "wl waste")]
318        wl_waste: String,
319        #[tabled(rename = "sc gain")]
320        sc_gain: String,
321        #[tabled(rename = "sc waste")]
322        sc_waste: String,
323        #[tabled(rename = "Δ gain")]
324        delta: String,
325    }
326
327    let mut names: Vec<&String> = wl
328        .resource_gains
329        .keys()
330        .chain(sc.resource_gains.keys())
331        .collect();
332
333    names.sort();
334    names.dedup();
335    let mut rows: Vec<(f64, ResourceRow)> = names
336        .into_iter()
337        .map(|name| {
338            let w = wl.resource_gains.get(name).copied().unwrap_or_default();
339            let c = sc.resource_gains.get(name).copied().unwrap_or_default();
340            let key = w.gained.max(c.gained);
341
342            (
343                key,
344                ResourceRow {
345                    name: name.clone(),
346                    wl_gain: format!("{:.0}", w.gained),
347                    wl_waste: format!("{:.0}", w.wasted),
348                    sc_gain: format!("{:.0}", c.gained),
349                    sc_waste: format!("{:.0}", c.wasted),
350                    delta: format!("{:+.0}", w.gained - c.gained),
351                },
352            )
353        })
354        .collect();
355
356    rows.sort_by(|(a, _), (b, _)| b.total_cmp(a));
357
358    let wl_total: f64 = wl.resource_gains.values().map(|g| g.gained).sum();
359    let sc_total: f64 = sc.resource_gains.values().map(|g| g.gained).sum();
360    let wl_waste: f64 = wl.resource_gains.values().map(|g| g.wasted).sum();
361    let sc_waste: f64 = sc.resource_gains.values().map(|g| g.wasted).sum();
362
363    rows.push((
364        -1.0,
365        ResourceRow {
366            name: "TOTAL".to_string(),
367            wl_gain: format!("{wl_total:.0}"),
368            wl_waste: format!("{wl_waste:.0}"),
369            sc_gain: format!("{sc_total:.0}"),
370            sc_waste: format!("{sc_waste:.0}"),
371            delta: format!("{:+.0}", wl_total - sc_total),
372        },
373    ));
374
375    output::blank();
376    output::subheader("Resource Income (per iteration)");
377    output::table(rows.into_iter().map(|(_, row)| row).collect::<Vec<_>>());
378}
379
380pub(crate) fn print_timeline(wl: &SimOutput, sc: &SimOutput, max_s: f64) {
381    let merged = merge_timelines(&wl.timeline, &sc.timeline, max_s);
382
383    output::subheader(&format!("Side-by-Side Cast Timeline (first {max_s:.0}s)"));
384
385    let rows: Vec<TimelineRow> = merged
386        .iter()
387        .map(|(time, wl_name, sc_name)| TimelineRow {
388            time: format!("{time:.3}"),
389            wl_spell: wl_name.clone(),
390            sc_spell: sc_name.clone(),
391        })
392        .collect();
393
394    output::table(rows);
395
396    let wl_count = wl.timeline.iter().filter(|e| e.time_secs <= max_s).count();
397    let sc_count = sc.timeline.iter().filter(|e| e.time_secs <= max_s).count();
398
399    output::detail(&format!(
400        "wowlab: {wl_count} casts, SimC: {sc_count} casts in first {max_s:.0}s",
401    ));
402    output::blank();
403}
404
405fn merge_timelines(wl: &[CastEntry], sc: &[CastEntry], max_s: f64) -> Vec<(f64, String, String)> {
406    let wl: Vec<_> = wl.iter().filter(|e| e.time_secs <= max_s).collect();
407    let sc: Vec<_> = sc.iter().filter(|e| e.time_secs <= max_s).collect();
408
409    let mut rows = Vec::new();
410    let mut wi = 0;
411    let mut si = 0;
412
413    while wi < wl.len() || si < sc.len() {
414        let wt = if wi < wl.len() {
415            // BOUNDS: wi < wl.len() is checked in the enclosing condition
416            wl[wi].time_secs
417        } else {
418            f64::INFINITY
419        };
420        let st = if si < sc.len() {
421            // BOUNDS: si < sc.len() is checked in the enclosing condition
422            sc[si].time_secs
423        } else {
424            f64::INFINITY
425        };
426
427        if wt <= st + TIMELINE_ORDER_SLACK && wi < wl.len() {
428            if si < sc.len() && (st - wt).abs() < TIMELINE_MATCH_TOLERANCE {
429                // BOUNDS: wi < wl.len() and si < sc.len() checked in enclosing conditions
430                rows.push((wt, wl[wi].spell_name.clone(), sc[si].spell_name.clone()));
431                wi += 1;
432                si += 1;
433            } else {
434                // BOUNDS: wi < wl.len() checked in enclosing if condition
435                rows.push((wt, wl[wi].spell_name.clone(), String::new()));
436                wi += 1;
437            }
438        } else if si < sc.len() {
439            // BOUNDS: si < sc.len() checked in enclosing else-if condition
440            rows.push((st, String::new(), sc[si].spell_name.clone()));
441            si += 1;
442        } else {
443            break;
444        }
445    }
446
447    rows
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[gtest]
455    fn attribution_normalization_folds_child_damage_into_parent_spell() -> GtestResult<()> {
456        let output = SimOutput::new(
457            100.0,
458            vec![
459                (
460                    "auto_attack_mh".to_string(),
461                    SpellResult {
462                        dps: 20.0,
463                        casts: 10,
464                        hits: 10,
465                        pct: 0.2,
466                    },
467                ),
468                (
469                    "auto_attack_oh".to_string(),
470                    SpellResult {
471                        dps: 10.0,
472                        casts: 8,
473                        hits: 8,
474                        pct: 0.1,
475                    },
476                ),
477                (
478                    "rampage4".to_string(),
479                    SpellResult {
480                        dps: 15.0,
481                        casts: 0,
482                        hits: 12,
483                        pct: 0.15,
484                    },
485                ),
486            ],
487            Vec::new(),
488        );
489
490        let spells = attribution_normalized_spells(&output);
491
492        verify_that!(spells.len(), eq(2))?;
493        let auto_attack = spells.get("auto_attack").or_fail()?;
494
495        verify_that!(
496            auto_attack,
497            matches_pattern!(SpellResult {
498                casts: eq(&10),
499                // hits add across the merged mainhand and offhand rows; casts do not.
500                hits: eq(&18),
501                dps: near(30.0, f64::EPSILON),
502                pct: near(0.3, f64::EPSILON),
503            })
504        )?;
505        verify_true!(spells.contains_key("rampage"))?;
506
507        Ok(())
508    }
509
510    #[gtest]
511    fn attribution_normalization_uses_canonical_foreground_timeline_casts() -> GtestResult<()> {
512        let output = SimOutput::new(
513            100.0,
514            vec![
515                (
516                    "odyns_fury_mh".to_string(),
517                    SpellResult {
518                        dps: 20.0,
519                        casts: 12,
520                        hits: 24,
521                        pct: 0.2,
522                    },
523                ),
524                (
525                    "odyns_fury_mh".to_string(),
526                    SpellResult {
527                        dps: 10.0,
528                        casts: 12,
529                        hits: 24,
530                        pct: 0.1,
531                    },
532                ),
533                (
534                    "odyns_fury_dot".to_string(),
535                    SpellResult {
536                        dps: 30.0,
537                        casts: 0,
538                        hits: 48,
539                        pct: 0.3,
540                    },
541                ),
542            ],
543            vec![
544                CastEntry {
545                    time_secs: 0.0,
546                    spell_name: "odyns_fury".to_string(),
547                },
548                CastEntry {
549                    time_secs: 45.0,
550                    spell_name: "odyns_fury".to_string(),
551                },
552            ],
553        );
554
555        let spells = attribution_normalized_spells(&output);
556        let odyns_fury = spells.get("odyns_fury").or_fail()?;
557
558        verify_that!(
559            odyns_fury,
560            matches_pattern!(SpellResult {
561                casts: eq(&2),
562                hits: eq(&96),
563                dps: near(60.0, f64::EPSILON),
564                pct: near(0.6, f64::EPSILON),
565            })
566        )?;
567
568        Ok(())
569    }
570
571    #[gtest]
572    fn simc_omnium_lingering_name_matches_engine_attribution() -> GtestResult<()> {
573        verify_that!(
574            canonical_spell_name("rune_of_unleashed_fire_lingering"),
575            eq("rune_of_lingering")
576        )?;
577
578        Ok(())
579    }
580
581    #[gtest]
582    fn enhancement_trigger_variants_match_engine_attribution() -> GtestResult<()> {
583        verify_that!(
584            canonical_spell_name("chain_lightning_ss_rtl"),
585            eq("chain_lightning")
586        )?;
587        verify_that!(
588            canonical_spell_name("chain_lightning_ll_rtl"),
589            eq("chain_lightning")
590        )?;
591        verify_that!(
592            canonical_spell_name("lightning_bolt_ps"),
593            eq("lightning_bolt")
594        )?;
595        verify_that!(
596            canonical_spell_name("lightning_bolt_ti"),
597            eq("lightning_bolt")
598        )?;
599        verify_that!(
600            canonical_spell_name("stormblast_stormstrike_mh"),
601            eq("stormblast")
602        )?;
603        verify_that!(
604            canonical_spell_name("stormblast_stormstrike_offhand"),
605            eq("stormblast")
606        )?;
607        verify_that!(canonical_spell_name("tremor_es"), eq("tremor"))?;
608
609        verify_that!(
610            canonical_spell_name("voltaic_blaze_damage"),
611            eq("voltaic_blaze")
612        )
613    }
614
615    #[gtest]
616    fn beast_mastery_pet_payloads_match_simc_parent_attribution() -> GtestResult<()> {
617        verify_that!(canonical_spell_name("barbed_shot_dot"), eq("barbed_shot"))?;
618        verify_that!(canonical_spell_name("bloodshed_dot"), eq("bloodshed"))?;
619        verify_that!(canonical_spell_name("kill_command_pet"), eq("kill_command"))?;
620        verify_that!(
621            canonical_spell_name("wildspeaker_kill_command"),
622            eq("kill_command")
623        )?;
624        verify_that!(
625            canonical_spell_name("pet_bestial_wrath"),
626            eq("bestial_wrath")
627        )?;
628
629        verify_that!(canonical_spell_name("thundering_hooves"), eq("stomp"))
630    }
631
632    #[gtest]
633    fn pet_and_owner_auto_attacks_keep_distinct_attribution() -> GtestResult<()> {
634        verify_that!(canonical_spell_name("auto_attack_mh"), eq("auto_attack"))?;
635        verify_that!(canonical_spell_name("auto_attack_oh"), eq("auto_attack"))?;
636        verify_that!(canonical_spell_name("pet_auto_attack_mh"), eq("melee"))?;
637
638        verify_that!(canonical_spell_name("pet_auto_attack_oh"), eq("melee"))
639    }
640}