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