Skip to main content

wowlab_parsers/parsers/transform/loot/
item_drop_scaling.rs

1use wowlab_engine_domain::dbc::{
2    DungeonDifficultyFlags, GemSubclass, InventoryType, ItemBonusListGroupEntryFlags,
3    ItemBonusType, ItemClass, ItemContext, RaidDifficultyFlags,
4};
5use wowlab_types::{
6    constants::CURRENT_EXPANSION_ID,
7    data::{DifficultyKey, DropSourceKind, InstanceKind, ItemDropScalingFlat},
8    sim::{FastMap, FastSet, IntMap},
9};
10
11use super::journal::transform_all_journal_instances;
12use crate::parsers::dbc::DbcData;
13
14const VAULT_BR_MIN_MPL: i32 = 10;
15const MYTHIC_PLUS_SHARED_BASE_SEQ: i32 = 2;
16const RARE_RAID_GROUP_MOD_SET_ID: i32 = 2967;
17const DEFAULT_BOSS_TIER: i32 = 1;
18const INACTIVE_WORLD_STATE_EXPRESSIONS: &[i32] = &[50187];
19
20#[rustfmt::skip]
21const MYTHIC_PLUS_BUCKETS: &[(DifficultyKey, i32)] = &[
22    // #t:aligned
23    (DifficultyKey::MythicPlusTwoThree , 2) ,
24    (DifficultyKey::MythicPlusFour     , 4) ,
25    (DifficultyKey::MythicPlusFive     , 5) ,
26    (DifficultyKey::MythicPlusSixSeven , 6) ,
27    (DifficultyKey::MythicPlusEightNine, 8) ,
28    (DifficultyKey::MythicPlusTenPlus  , 10),
29];
30
31fn list_has_type(dbc: &DbcData, list_id: i32, bonus_type: ItemBonusType) -> bool {
32    dbc.item_bonus
33        .get(&list_id)
34        .is_some_and(|rows| rows.iter().any(|r| r.Type == bonus_type as i32))
35}
36
37fn max_regular_seq_for_group(dbc: &DbcData, group: i32) -> i32 {
38    let mut max = 1;
39
40    if let Some(entries) = dbc.item_bonus_list_group_entry.get(&group) {
41        for entry in entries {
42            if entry.Flags != ItemBonusListGroupEntryFlags::REGULAR.bits() {
43                continue;
44            }
45
46            if !list_has_type(dbc, entry.ItemBonusListID, ItemBonusType::Upgrade) {
47                continue;
48            }
49
50            if entry.SequenceValue > max {
51                max = entry.SequenceValue;
52            }
53        }
54    }
55
56    max
57}
58
59fn walk_tree_nodes(
60    dbc: &DbcData,
61    tree_id: i32,
62) -> Vec<&crate::parsers::dbc::rows::ItemBonusTreeNodeRow> {
63    let mut out: Vec<&crate::parsers::dbc::rows::ItemBonusTreeNodeRow> = Vec::new();
64    let mut seen = FastSet::default();
65
66    seen.insert(tree_id);
67    let mut stack: Vec<&crate::parsers::dbc::rows::ItemBonusTreeNodeRow> = Vec::new();
68
69    if let Some(nodes) = dbc.item_bonus_tree_node.get(&tree_id) {
70        for node in nodes.iter().rev() {
71            stack.push(node);
72        }
73    }
74
75    while let Some(node) = stack.pop() {
76        out.push(node);
77        let child = node.ChildItemBonusTreeID;
78
79        if child > 0 && seen.insert(child) {
80            if let Some(child_nodes) = dbc.item_bonus_tree_node.get(&child) {
81                for child_node in child_nodes.iter().rev() {
82                    stack.push(child_node);
83                }
84            }
85        }
86    }
87
88    out
89}
90
91// #t(fn: rust_cyclomatic_complexity) one match arm per ItemContext family (raid/dungeon/M+/vault)
92fn node_difficulties_and_seqs(
93    node: &crate::parsers::dbc::rows::ItemBonusTreeNodeRow,
94    source_kind: DropSourceKind,
95    boss_tier: i32,
96    rare_seq: i32,
97) -> Vec<(DifficultyKey, i32)> {
98    let Ok(ctx) = ItemContext::try_from(node.ItemContext) else {
99        return Vec::new();
100    };
101
102    if source_kind == DropSourceKind::Raid {
103        let diff = match ctx {
104            ItemContext::RaidLfr => DifficultyKey::Lfr,
105            ItemContext::RaidNormal => DifficultyKey::Normal,
106            ItemContext::RaidHeroic => DifficultyKey::Heroic,
107            ItemContext::RaidMythic => DifficultyKey::Mythic,
108            _ => return Vec::new(),
109        };
110        let seq = if node.IblGroupPointsModSetID == RARE_RAID_GROUP_MOD_SET_ID {
111            rare_seq
112        } else {
113            boss_tier
114        };
115
116        return vec![(diff, seq)];
117    }
118
119    match ctx {
120        ItemContext::DungeonNormal => vec![(DifficultyKey::Normal, 1)],
121        ItemContext::DungeonHeroic => vec![(DifficultyKey::Heroic, 1)],
122        ItemContext::DungeonMythic => vec![(DifficultyKey::Mythic, 1)],
123        ItemContext::MythicKeystone => {
124            let min_mpl = node.MinMythicPlusLevel;
125            let hi = if node.MaxMythicPlusLevel == 0 {
126                i32::MAX
127            } else {
128                node.MaxMythicPlusLevel
129            };
130            let base_seq = if min_mpl == 0 {
131                MYTHIC_PLUS_SHARED_BASE_SEQ
132            } else {
133                1
134            };
135            let mut out = Vec::new();
136            let mut filtered_index = 0;
137
138            for &(diff, key) in MYTHIC_PLUS_BUCKETS {
139                if key >= min_mpl && key <= hi {
140                    out.push((diff, base_seq + filtered_index));
141                    filtered_index += 1;
142                }
143            }
144
145            out
146        }
147        ItemContext::MythicKeystoneVault => {
148            let min_mpl = node.MinMythicPlusLevel;
149
150            if min_mpl >= VAULT_BR_MIN_MPL || (min_mpl == 0 && node.MaxMythicPlusLevel == 0) {
151                vec![(DifficultyKey::MythicPlusVaultBr, 1)]
152            } else {
153                Vec::new()
154            }
155        }
156        _ => Vec::new(),
157    }
158}
159
160// #t(fn: rust_max_nesting) per-node candidate selection over (difficulty, sequence) pairs
161// #t(fn: rust_cyclomatic_complexity) direct/group/type branching mirrors the reference resolver
162fn resolve_bonuses(
163    dbc: &DbcData,
164    override_dests: &IntMap<i32, Vec<i32>>,
165    item_id: i32,
166    source_kind: DropSourceKind,
167    boss_tier: i32,
168) -> FastMap<DifficultyKey, (i32, i32)> {
169    let Some(roots) = dbc.item_x_bonus_tree.get(&item_id) else {
170        return FastMap::default();
171    };
172
173    if roots.is_empty() {
174        return FastMap::default();
175    }
176
177    let normal_trees: Vec<i32> = roots.iter().map(|r| r.ItemBonusTreeID).collect();
178
179    let override_count = normal_trees
180        .iter()
181        .filter_map(|tree| override_dests.get(tree))
182        .map(Vec::len)
183        .sum::<usize>();
184    let mut all_trees = Vec::with_capacity(normal_trees.len() + override_count);
185
186    for tree in &normal_trees {
187        if let Some(dests) = override_dests.get(tree) {
188            for &dest in dests {
189                if !all_trees.contains(&dest) {
190                    all_trees.push(dest);
191                }
192            }
193        }
194    }
195
196    for tree in &normal_trees {
197        if !all_trees.contains(tree) {
198            all_trees.push(*tree);
199        }
200    }
201
202    let mut upgrade_by_diff = FastMap::default();
203    let mut flair_by_diff = FastMap::default();
204
205    for tree in all_trees {
206        for node in walk_tree_nodes(dbc, tree) {
207            let direct = node.ChildItemBonusListID;
208            let group = node.ChildItemBonusListGroupID;
209            let rare_seq = if group > 0 {
210                max_regular_seq_for_group(dbc, group)
211            } else {
212                1
213            };
214
215            for (diff, seq) in node_difficulties_and_seqs(node, source_kind, boss_tier, rare_seq) {
216                let mut upgrade_candidate: Option<i32> = None;
217                let mut flair_candidate: Option<i32> = None;
218
219                if direct > 0 {
220                    if list_has_type(dbc, direct, ItemBonusType::Upgrade) {
221                        upgrade_candidate = Some(direct);
222                    }
223
224                    if list_has_type(dbc, direct, ItemBonusType::Flair) {
225                        flair_candidate = Some(direct);
226                    }
227                } else if group > 0 {
228                    if let Some(entries) = dbc.item_bonus_list_group_entry.get(&group) {
229                        if let Some(entry) = entries.iter().find(|e| {
230                            e.SequenceValue == seq
231                                && list_has_type(dbc, e.ItemBonusListID, ItemBonusType::Upgrade)
232                        }) {
233                            upgrade_candidate = Some(entry.ItemBonusListID);
234                        }
235                    }
236                } else {
237                    continue;
238                }
239
240                if let Some(upgrade) = upgrade_candidate {
241                    upgrade_by_diff.entry(diff).or_insert(upgrade);
242                }
243
244                let current_upgrade = upgrade_by_diff.get(&diff).copied();
245
246                if let Some(flair) = flair_candidate {
247                    if Some(flair) != current_upgrade && !flair_by_diff.contains_key(&diff) {
248                        flair_by_diff.insert(diff, flair);
249                    }
250                }
251            }
252        }
253    }
254
255    let mut result = FastMap::default();
256
257    result.reserve(upgrade_by_diff.len() + flair_by_diff.len());
258
259    for (&diff, &upgrade) in &upgrade_by_diff {
260        let flair = flair_by_diff.get(&diff).copied().unwrap_or(0);
261
262        result.insert(diff, (upgrade, flair));
263    }
264
265    for (&diff, &flair) in &flair_by_diff {
266        result.entry(diff).or_insert((0, flair));
267    }
268
269    result
270}
271
272fn build_override_dests(dbc: &DbcData) -> IntMap<i32, Vec<i32>> {
273    let mut map: IntMap<i32, Vec<i32>> = IntMap::default();
274
275    for row in dbc.challenge_mode_item_bonus_override.values() {
276        let src = row.SrcItemBonusTreeID;
277        let dst = row.DstItemBonusTreeID;
278
279        if src == 0 || dst == 0 {
280            continue;
281        }
282
283        let dests = map.entry(src).or_default();
284
285        if !dests.contains(&dst) {
286            dests.push(dst);
287        }
288    }
289
290    map
291}
292
293fn build_excluded_item_set(dbc: &DbcData) -> FastSet<i32> {
294    let mut excluded = FastSet::default();
295
296    for item in dbc.item.values() {
297        if item.ClassID == ItemClass::Gem as i32
298            && item.SubclassID == GemSubclass::ArtifactRelic as i32
299        {
300            excluded.insert(item.ID);
301            continue;
302        }
303
304        if item.InventoryType == InventoryType::NonEquippable as i32
305            && item.ClassID != ItemClass::Weapon as i32
306            && item.ClassID != ItemClass::Armor as i32
307            && item.ClassID != ItemClass::Miscellaneous as i32
308        {
309            excluded.insert(item.ID);
310        }
311    }
312
313    excluded
314}
315
316fn build_boss_tier_map(dbc: &DbcData) -> IntMap<i32, i32> {
317    let mut map: IntMap<i32, i32> = IntMap::default();
318
319    for row in dbc.dungeon_encounter.values() {
320        if row.boss_tier > 0 {
321            map.insert(row.ID, row.boss_tier);
322        }
323    }
324
325    map
326}
327
328fn expand_raid_mask(mask: i32) -> Vec<DifficultyKey> {
329    let flags = RaidDifficultyFlags::from_bits_retain(mask);
330    let mut out = Vec::new();
331
332    if flags.contains(RaidDifficultyFlags::LFR) {
333        out.push(DifficultyKey::Lfr);
334    }
335
336    if flags.contains(RaidDifficultyFlags::NORMAL) {
337        out.push(DifficultyKey::Normal);
338    }
339
340    if flags.contains(RaidDifficultyFlags::HEROIC) {
341        out.push(DifficultyKey::Heroic);
342    }
343
344    if flags.contains(RaidDifficultyFlags::MYTHIC) {
345        out.push(DifficultyKey::Mythic);
346    }
347
348    out
349}
350
351// Bit2 (Mythic) also unlocks every M+ bucket + Vault key (M+ brackets are runtime modifiers on Mythic).
352fn expand_dungeon_mask(mask: i32) -> Vec<DifficultyKey> {
353    let flags = DungeonDifficultyFlags::from_bits_retain(mask);
354    let mut out = Vec::new();
355
356    if flags.contains(DungeonDifficultyFlags::NORMAL) {
357        out.push(DifficultyKey::Normal);
358    }
359
360    if flags.contains(DungeonDifficultyFlags::HEROIC) {
361        out.push(DifficultyKey::Heroic);
362    }
363
364    if flags.contains(DungeonDifficultyFlags::MYTHIC) {
365        out.extend_from_slice(&[
366            DifficultyKey::Mythic,
367            DifficultyKey::MythicPlusTwoThree,
368            DifficultyKey::MythicPlusFour,
369            DifficultyKey::MythicPlusFive,
370            DifficultyKey::MythicPlusSixSeven,
371            DifficultyKey::MythicPlusEightNine,
372            DifficultyKey::MythicPlusTenPlus,
373            DifficultyKey::MythicPlusVaultBr,
374        ]);
375    }
376
377    out
378}
379
380#[cfg(test)]
381#[expect(
382    clippy::items_after_test_module,
383    reason = "private helper tests remain adjacent to the resolver helpers they exercise"
384)]
385mod tests {
386    use googletest::prelude::*;
387    use rstest::rstest;
388
389    use super::*;
390    use crate::parsers::{
391        dbc::rows::ItemBonusTreeNodeRow,
392        transform::fixtures::{challenge_override_row, dbc, item_bonus_row, item_row},
393    };
394
395    type NodeDifficultyCase = (
396        i32,
397        i32,
398        i32,
399        i32,
400        DropSourceKind,
401        i32,
402        i32,
403        Vec<(DifficultyKey, i32)>,
404    );
405
406    fn node(ctx: i32, mod_set: i32, min: i32, max: i32) -> ItemBonusTreeNodeRow {
407        ItemBonusTreeNodeRow {
408            ID: 1,
409            ItemContext: ctx,
410            ChildItemBonusTreeID: 0,
411            ChildItemBonusListID: 0,
412            ChildItemBonusListGroupID: 0,
413            IblGroupPointsModSetID: mod_set,
414            MinMythicPlusLevel: min,
415            MaxMythicPlusLevel: max,
416            ParentItemBonusTreeID: 0,
417        }
418    }
419
420    #[gtest]
421    #[rstest]
422    #[case::all_bits(-1, vec![
423        DifficultyKey::Lfr,
424        DifficultyKey::Normal,
425        DifficultyKey::Heroic,
426        DifficultyKey::Mythic,
427    ])]
428    #[case::none(0, vec![])]
429    #[case::lfr_only(1, vec![DifficultyKey::Lfr])]
430    #[case::heroic_mythic(0b1100, vec![DifficultyKey::Heroic, DifficultyKey::Mythic])]
431    fn expand_raid_mask_cases(
432        #[case] mask: i32,
433        #[case] expected: Vec<DifficultyKey>,
434    ) -> Result<()> {
435        verify_that!(expand_raid_mask(mask), eq(&expected))
436    }
437
438    #[gtest]
439    #[rstest]
440    #[case::normal(1, vec![DifficultyKey::Normal])]
441    #[case::heroic(2, vec![DifficultyKey::Heroic])]
442    #[case::mythic_expands(4, vec![
443        DifficultyKey::Mythic,
444        DifficultyKey::MythicPlusTwoThree,
445        DifficultyKey::MythicPlusFour,
446        DifficultyKey::MythicPlusFive,
447        DifficultyKey::MythicPlusSixSeven,
448        DifficultyKey::MythicPlusEightNine,
449        DifficultyKey::MythicPlusTenPlus,
450        DifficultyKey::MythicPlusVaultBr,
451    ])]
452    #[case::all_bits(-1, vec![
453        DifficultyKey::Normal,
454        DifficultyKey::Heroic,
455        DifficultyKey::Mythic,
456        DifficultyKey::MythicPlusTwoThree,
457        DifficultyKey::MythicPlusFour,
458        DifficultyKey::MythicPlusFive,
459        DifficultyKey::MythicPlusSixSeven,
460        DifficultyKey::MythicPlusEightNine,
461        DifficultyKey::MythicPlusTenPlus,
462        DifficultyKey::MythicPlusVaultBr,
463    ])]
464    fn expand_dungeon_mask_cases(
465        #[case] mask: i32,
466        #[case] expected: Vec<DifficultyKey>,
467    ) -> Result<()> {
468        verify_that!(expand_dungeon_mask(mask), eq(&expected))
469    }
470
471    #[gtest]
472    #[rstest]
473    #[case::raid_normal_boss_tier((3, 0, 0, 0, DropSourceKind::Raid, 2, 9, vec![(DifficultyKey::Normal, 2)]))]
474    #[case::raid_rare_uses_rare_seq((6, 2967, 0, 0, DropSourceKind::Raid, 2, 9, vec![(DifficultyKey::Mythic, 9)]))]
475    #[case::raid_unknown_ctx((99, 0, 0, 0, DropSourceKind::Raid, 2, 9, vec![]))]
476    #[case::dungeon_heroic((2, 0, 0, 0, DropSourceKind::Dungeon, 1, 1, vec![(DifficultyKey::Heroic, 1)]))]
477    #[case::keystone_full_range((16, 0, 0, 0, DropSourceKind::Dungeon, 1, 1, vec![
478        (DifficultyKey::MythicPlusTwoThree, 2),
479        (DifficultyKey::MythicPlusFour, 3),
480        (DifficultyKey::MythicPlusFive, 4),
481        (DifficultyKey::MythicPlusSixSeven, 5),
482        (DifficultyKey::MythicPlusEightNine, 6),
483        (DifficultyKey::MythicPlusTenPlus, 7),
484    ]))]
485    #[case::keystone_min_five((16, 0, 5, 0, DropSourceKind::Dungeon, 1, 1, vec![
486        (DifficultyKey::MythicPlusFive, 1),
487        (DifficultyKey::MythicPlusSixSeven, 2),
488        (DifficultyKey::MythicPlusEightNine, 3),
489        (DifficultyKey::MythicPlusTenPlus, 4),
490    ]))]
491    #[case::keystone_capped((16, 0, 0, 5, DropSourceKind::Dungeon, 1, 1, vec![
492        (DifficultyKey::MythicPlusTwoThree, 2),
493        (DifficultyKey::MythicPlusFour, 3),
494        (DifficultyKey::MythicPlusFive, 4),
495    ]))]
496    #[case::vault_high((35, 0, 10, 0, DropSourceKind::Dungeon, 1, 1, vec![(DifficultyKey::MythicPlusVaultBr, 1)]))]
497    #[case::vault_zero_zero((35, 0, 0, 0, DropSourceKind::Dungeon, 1, 1, vec![(DifficultyKey::MythicPlusVaultBr, 1)]))]
498    #[case::vault_low((35, 0, 4, 8, DropSourceKind::Dungeon, 1, 1, vec![]))]
499    #[case::unknown_ctx((7, 0, 0, 0, DropSourceKind::Dungeon, 1, 1, vec![]))]
500    fn node_difficulties_and_seqs_cases(#[case] case: NodeDifficultyCase) -> Result<()> {
501        let (ctx, mod_set, min, max, source, boss_tier, rare_seq, expected) = case;
502        let node = node(ctx, mod_set, min, max);
503
504        verify_that!(
505            node_difficulties_and_seqs(&node, source, boss_tier, rare_seq),
506            eq(&expected)
507        )
508    }
509
510    #[gtest]
511    fn list_has_type_matches_only_present_type() -> Result<()> {
512        let mut dbc = dbc();
513
514        dbc.item_bonus
515            .insert(5, vec![item_bonus_row(5, ItemBonusType::Upgrade as i32)]);
516
517        verify_that!(list_has_type(&dbc, 5, ItemBonusType::Upgrade), eq(true))?;
518        verify_that!(list_has_type(&dbc, 5, ItemBonusType::Flair), eq(false))?;
519
520        verify_that!(list_has_type(&dbc, 99, ItemBonusType::Upgrade), eq(false))
521    }
522
523    #[gtest]
524    fn build_override_dests_skips_and_dedups() -> Result<()> {
525        let mut dbc = dbc();
526
527        dbc.challenge_mode_item_bonus_override
528            .insert(1, challenge_override_row(1, 5, 7));
529        dbc.challenge_mode_item_bonus_override
530            .insert(2, challenge_override_row(2, 0, 9));
531        dbc.challenge_mode_item_bonus_override
532            .insert(3, challenge_override_row(3, 5, 7));
533
534        let dests = build_override_dests(&dbc);
535
536        verify_that!(dests.get(&5), some(eq(&vec![7])))?;
537
538        verify_that!(dests.get(&0), none())
539    }
540
541    #[gtest]
542    fn build_excluded_item_set_classifies_items() -> Result<()> {
543        let mut dbc = dbc();
544
545        dbc.item.insert(
546            1,
547            item_row(
548                1,
549                ItemClass::Gem as i32,
550                GemSubclass::ArtifactRelic as i32,
551                InventoryType::NonEquippable as i32,
552            ),
553        );
554        dbc.item.insert(
555            2,
556            item_row(
557                2,
558                ItemClass::Weapon as i32,
559                0,
560                InventoryType::NonEquippable as i32,
561            ),
562        );
563        dbc.item.insert(
564            3,
565            item_row(
566                3,
567                ItemClass::TradeGoods as i32,
568                0,
569                InventoryType::NonEquippable as i32,
570            ),
571        );
572        dbc.item.insert(
573            4,
574            item_row(4, ItemClass::Weapon as i32, 0, InventoryType::Head as i32),
575        );
576
577        let excluded = build_excluded_item_set(&dbc);
578
579        verify_that!(excluded.contains(&1), eq(true))?;
580        verify_that!(excluded.contains(&2), eq(false))?;
581        verify_that!(excluded.contains(&3), eq(true))?;
582
583        verify_that!(excluded.contains(&4), eq(false))
584    }
585}
586
587/// Resolve per-difficulty Upgrade/Flair `BonusIDs` per journal-dropped item, one row per `(item_id, source_kind, difficulty_key)`.
588// #t(fn: rust_max_nesting) instance -> encounter -> item -> difficulty iteration is inherent to the journal shape
589// #t(fn: rust_cyclomatic_complexity) per-row filtering (world state, exclusion, legacy strip, baseline) lives here
590#[must_use]
591pub fn transform_all_item_drop_scaling(dbc: &DbcData) -> Vec<ItemDropScalingFlat> {
592    type DiffScalingMap = FastMap<DifficultyKey, (i32, i32)>;
593
594    let override_dests = build_override_dests(dbc);
595    let excluded = build_excluded_item_set(dbc);
596    let boss_tier_map = build_boss_tier_map(dbc);
597
598    let mut items_by_encounter: IntMap<
599        i32,
600        Vec<&crate::parsers::dbc::rows::JournalEncounterItemRow>,
601    > = IntMap::default();
602
603    for items in dbc.journal_encounter_item.values() {
604        for item in items {
605            items_by_encounter
606                .entry(item.JournalEncounterID)
607                .or_default()
608                .push(item);
609        }
610    }
611
612    let instances = transform_all_journal_instances(dbc);
613
614    let mut by_item: FastMap<(i32, DropSourceKind), DiffScalingMap> = FastMap::default();
615
616    for instance in &instances {
617        let source_kind = match instance.kind {
618            InstanceKind::Raid => DropSourceKind::Raid,
619            InstanceKind::Dungeon => DropSourceKind::Dungeon,
620            _ => continue,
621        };
622        let expansion = dbc.map.get(&instance.map_id).map_or(0, |m| m.ExpansionID);
623        let strip_legacy = source_kind == DropSourceKind::Dungeon
624            && i64::from(expansion) < i64::from(CURRENT_EXPANSION_ID);
625
626        for encounter in &instance.encounters {
627            let boss_tier = boss_tier_map
628                .get(&encounter.dungeon_encounter_id)
629                .copied()
630                .unwrap_or(DEFAULT_BOSS_TIER);
631            let Some(items) = items_by_encounter.get(&encounter.id) else {
632                continue;
633            };
634
635            for item in items {
636                if INACTIVE_WORLD_STATE_EXPRESSIONS.contains(&item.WorldStateExpressionID) {
637                    continue;
638                }
639
640                let item_id = item.ItemID;
641
642                if item_id <= 0 || excluded.contains(&item_id) {
643                    continue;
644                }
645
646                let mut expected = match source_kind {
647                    DropSourceKind::Raid => expand_raid_mask(item.DifficultyMask),
648                    DropSourceKind::Dungeon => expand_dungeon_mask(item.DifficultyMask),
649                    _ => continue,
650                };
651
652                if strip_legacy {
653                    expected.retain(|diff| {
654                        *diff != DifficultyKey::Normal && *diff != DifficultyKey::Heroic
655                    });
656                }
657
658                if expected.is_empty() {
659                    continue;
660                }
661
662                let resolved =
663                    resolve_bonuses(dbc, &override_dests, item_id, source_kind, boss_tier);
664                let entry = by_item.entry((item_id, source_kind)).or_default();
665
666                for diff in expected {
667                    let mut pair = if source_kind == DropSourceKind::Dungeon
668                        && diff == DifficultyKey::Normal
669                    {
670                        (0, 0)
671                    } else {
672                        resolved.get(&diff).copied().unwrap_or((0, 0))
673                    };
674
675                    if pair.0 == 0 {
676                        pair.1 = 0;
677                    }
678
679                    match entry.get(&diff) {
680                        None => {
681                            entry.insert(diff, pair);
682                        }
683                        Some(&(cur_upgrade, cur_flair)) => {
684                            let cur_empty = cur_upgrade == 0 && cur_flair == 0;
685                            let new_content = pair.0 != 0 || pair.1 != 0;
686
687                            if cur_empty && new_content {
688                                entry.insert(diff, pair);
689                            }
690                        }
691                    }
692                }
693            }
694        }
695    }
696
697    let mut entries: Vec<((i32, DropSourceKind), DiffScalingMap)> = by_item.into_iter().collect();
698
699    entries.sort_by(|a, b| {
700        a.0.0
701            .cmp(&b.0.0)
702            .then_with(|| a.0.1.as_str().cmp(b.0.1.as_str()))
703    });
704
705    let mut out: Vec<ItemDropScalingFlat> = Vec::new();
706
707    for ((item_id, source_kind), diffs) in entries {
708        if !diffs.values().any(|(upgrade, _)| *upgrade != 0) {
709            continue;
710        }
711
712        let mut diff_list: Vec<(DifficultyKey, (i32, i32))> = diffs.into_iter().collect();
713
714        diff_list.sort_by_key(|(diff, _)| diff.order());
715
716        for (difficulty_key, (upgrade_id, flair_id)) in diff_list {
717            out.push(ItemDropScalingFlat {
718                id: 0,
719                item_id,
720                source_kind,
721                difficulty_key,
722                upgrade_id,
723                flair_id,
724            });
725        }
726    }
727
728    out
729}