Skip to main content

wowlab_parsers/parsers/transform/
item.rs

1use wowlab_engine_domain::dbc::InventoryType;
2use wowlab_types::data::{
3    ItemClassification, ItemDataFlat, ItemDropSource, ItemEffect, ItemSetBonus, ItemSetInfo,
4    ItemStat,
5};
6
7#[cfg(feature = "dbc")]
8use super::{super::dbc::DbcData, super::errors::TransformError};
9
10macro_rules! array_fields {
11    ($base:ident . $prefix:ident, [ $($n:literal),+ $(,)? ]) => {
12        [ $( $crate::_private::paste::paste!( $base.[<$prefix _ $n>] ) ),+ ]
13    };
14    (vec $base:ident . $prefix:ident, [ $($n:literal),+ $(,)? ]) => {
15        ::std::vec![ $( $crate::_private::paste::paste!( $base.[<$prefix _ $n>] ) ),+ ]
16    };
17}
18
19#[rustfmt::skip]
20const EXPANSION_NAMES: &[(i32, &str)] = &[
21    // #t:aligned
22    (0 , "Classic")               ,
23    (1 , "The Burning Crusade")   ,
24    (2 , "Wrath of the Lich King"),
25    (3 , "Cataclysm")             ,
26    (4 , "Mists of Pandaria")     ,
27    (5 , "Warlords of Draenor")   ,
28    (6 , "Legion")                ,
29    (7 , "Battle for Azeroth")    ,
30    (8 , "Shadowlands")           ,
31    (9 , "Dragonflight")          ,
32    (10, "The War Within")        ,
33];
34
35fn combine_allowable_race(low: Option<i64>, high: Option<i64>) -> i64 {
36    const WORD_BITS: u32 = 32;
37    let [byte_0, byte_1, byte_2, byte_3, ..] = low.unwrap_or(-1).to_le_bytes();
38    let low = i64::from(u32::from_le_bytes([byte_0, byte_1, byte_2, byte_3]));
39    let high = high.unwrap_or(-1);
40
41    (high << WORD_BITS) | low
42}
43
44// Bit N (0-indexed) maps to `Difficulty.ID == N + 1`.
45fn decode_difficulty_mask(mask: i32) -> Vec<i32> {
46    const MAX_DIFFICULTY_ID: i32 = 31;
47
48    if mask == -1 || mask == 0 {
49        return Vec::new();
50    }
51
52    (1..=MAX_DIFFICULTY_ID)
53        .filter(|id| mask & (1 << (id - 1)) != 0)
54        .collect()
55}
56
57fn get_expansion_name(id: i32) -> &'static str {
58    EXPANSION_NAMES
59        .iter()
60        .find(|(i, _)| *i == id)
61        .map_or("Unknown", |(_, name)| *name)
62}
63
64fn get_inventory_type_name(id: i32) -> &'static str {
65    InventoryType::try_from(id)
66        .ok()
67        .map_or("Unknown", InventoryType::display_name)
68}
69
70/// Assemble a flat item record from the Item, `ItemSparse`, set, source, and appearance DBC tables.
71///
72/// # Errors
73///
74/// Returns [`TransformError`] when `item_id` is absent from the DBC data.
75// #t(rust_max_fn_lines) item transform gathers 15+ DBC tables and computes set info, drop sources, etc.
76pub fn transform_item(dbc: &DbcData, item_id: i32) -> Result<ItemDataFlat, TransformError> {
77    let item = dbc
78        .item
79        .get(&item_id)
80        .ok_or_else(|| TransformError::item_not_found(item_id))?;
81
82    let sparse = dbc.item_sparse.get(&item_id);
83
84    let mut icon_file_data_id = item.IconFileDataID;
85
86    if icon_file_data_id == 0
87        && let Some(modified_appearance) = dbc.item_modified_appearance.get(&item_id)
88        && let Some(appearance) = dbc
89            .item_appearance
90            .get(&modified_appearance.ItemAppearanceID)
91        && appearance.DefaultIconFileDataID > 0
92    {
93        icon_file_data_id = appearance.DefaultIconFileDataID;
94    }
95
96    let file_name = super::resolve_icon_file_name(dbc, icon_file_data_id);
97
98    let effect_links = dbc.item_x_item_effect.get(&item_id);
99    let effects: Vec<ItemEffect> = effect_links
100        .map(|links| {
101            links
102                .iter()
103                .filter_map(|link| {
104                    dbc.item_effect
105                        .get(&link.ItemEffectID)
106                        .map(|effect| ItemEffect {
107                            spell_id: effect.SpellID,
108                            trigger_type: effect.TriggerType,
109                            charges: effect.Charges,
110                            cooldown: effect.CoolDownMSec,
111                            category_cooldown: effect.CategoryCoolDownMSec,
112                        })
113                })
114                .collect()
115        })
116        .unwrap_or_default();
117
118    let stats: Vec<ItemStat> = sparse
119        .map(|s| {
120            let stat_types = array_fields!(s.StatModifierBonusStat, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
121            let stat_values = array_fields!(s.StatPercentEditor, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
122
123            stat_types
124                .iter()
125                .zip(stat_values.iter())
126                .filter(|&(&t, _)| t != -1 && t != 0)
127                .map(|(&t, &v)| ItemStat {
128                    stat_type: t,
129                    value: v,
130                })
131                .collect()
132        })
133        .unwrap_or_default();
134
135    let expansion_id = sparse.map_or(0, |s| s.ExpansionID);
136    let item_class_row = dbc.item_class_by_class_id.get(&item.ClassID);
137    let item_sub_classes = dbc.item_sub_class_by_class_id.get(&item.ClassID);
138    let item_sub_class_row =
139        item_sub_classes.and_then(|scs| scs.iter().find(|sc| sc.SubClassID == item.SubclassID));
140
141    let classification = Some(ItemClassification {
142        class_id: item.ClassID,
143        class_name: item_class_row
144            .and_then(|c| c.ClassName_lang.clone())
145            .unwrap_or_else(|| "Unknown".to_string())
146            .into(),
147        subclass_id: item.SubclassID,
148        subclass_name: item_sub_class_row
149            .and_then(|sc| sc.DisplayName_lang.clone())
150            .unwrap_or_else(|| "Unknown".to_string())
151            .into(),
152        inventory_type: item.InventoryType,
153        inventory_type_name: get_inventory_type_name(item.InventoryType).into(),
154        expansion_id,
155        expansion_name: get_expansion_name(expansion_id).into(),
156    });
157
158    let sockets: Vec<i32> = sparse
159        .map(|s| {
160            array_fields!(s.SocketType, [0, 1, 2])
161                .into_iter()
162                .filter(|&t| t > 0)
163                .collect()
164        })
165        .unwrap_or_default();
166
167    let flags: Vec<i32> = sparse
168        .map(|s| array_fields!(vec s.Flags, [0, 1, 2, 3, 4]))
169        .unwrap_or_default();
170
171    let item_set_id = sparse.map_or(0, |s| s.ItemSet);
172    let set_info = if item_set_id > 0 {
173        dbc.item_set.get(&item_set_id).map(|item_set| {
174            let item_ids: Vec<i32> = array_fields!(
175                item_set.ItemID,
176                [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
177            )
178            .into_iter()
179            .filter(|&id| id > 0)
180            .collect();
181
182            let bonuses: Vec<ItemSetBonus> = dbc
183                .item_set_spell
184                .get(&item_set_id)
185                .map(|spells| {
186                    spells
187                        .iter()
188                        .map(|spell| ItemSetBonus {
189                            threshold: spell.Threshold,
190                            spell_id: spell.SpellID,
191                            spec_id: spell.ChrSpecID,
192                        })
193                        .collect()
194                })
195                .unwrap_or_default();
196
197            ItemSetInfo {
198                set_id: item_set_id,
199                set_name: item_set
200                    .Name_lang
201                    .clone()
202                    .unwrap_or_else(|| "Unknown Set".to_string())
203                    .into(),
204                item_ids,
205                bonuses,
206            }
207        })
208    } else {
209        None
210    };
211
212    let drop_sources: Vec<ItemDropSource> = dbc
213        .journal_encounter_item
214        .get(&item_id)
215        .map(|encounter_items| {
216            encounter_items
217                .iter()
218                .filter_map(|encounter_item| {
219                    let encounter = dbc
220                        .journal_encounter
221                        .get(&encounter_item.JournalEncounterID)?;
222                    let instance = dbc.journal_instance.get(&encounter.JournalInstanceID);
223
224                    Some(ItemDropSource {
225                        instance_id: instance.map_or(0, |i| i.ID),
226                        instance_name: instance
227                            .and_then(|i| i.Name_lang.clone())
228                            .unwrap_or_else(|| "Unknown".to_string())
229                            .into(),
230                        encounter_id: encounter.ID,
231                        encounter_name: encounter
232                            .Name_lang
233                            .clone()
234                            .unwrap_or_else(|| "Unknown".to_string())
235                            .into(),
236                        difficulty_ids: decode_difficulty_mask(encounter_item.DifficultyMask),
237                    })
238                })
239                .collect()
240        })
241        .unwrap_or_default();
242
243    Ok(ItemDataFlat {
244        id: item_id,
245        name: sparse
246            .and_then(|s| s.Display_lang.clone())
247            .unwrap_or_default()
248            .into(),
249        description: sparse
250            .and_then(|s| s.Description_lang.clone())
251            .unwrap_or_default(),
252        file_name: file_name.into(),
253        item_level: sparse.map_or(0, |s| s.ItemLevel),
254        quality: sparse.map_or(0, |s| s.OverallQualityID),
255        required_level: sparse.map_or(0, |s| s.RequiredLevel),
256        binding: sparse.map_or(0, |s| s.Bonding),
257        buy_price: sparse.map_or(0, |s| s.BuyPrice),
258        sell_price: sparse.map_or(0, |s| s.SellPrice),
259        max_count: sparse.map_or(0, |s| s.MaxCount),
260        stackable: sparse.map_or(1, |s| s.Stackable),
261        speed: sparse.map_or(0, |s| s.ItemDelay),
262        class_id: item.ClassID,
263        subclass_id: item.SubclassID,
264        inventory_type: item.InventoryType,
265        classification,
266        stats,
267        effects,
268        sockets,
269        socket_bonus_enchant_id: sparse.map_or(0, |s| s.SocketMatchEnchantmentID),
270        flags,
271        allowable_class: sparse.map_or(-1, |s| s.AllowableClass),
272        allowable_race: sparse.map_or(-1, |s| {
273            combine_allowable_race(s.AllowableRaces_0, s.AllowableRaces_1)
274        }),
275        expansion_id,
276        item_set_id,
277        set_info,
278        drop_sources,
279        dmg_variance: sparse.map_or(0.0, |s| s.DmgVariance),
280        gem_properties: sparse.map_or(0, |s| s.GemProperties),
281        modified_crafting_reagent_item_id: item.ModifiedCraftingReagentItemID,
282    })
283}
284
285/// Transform every item row into flat `ItemDataFlat` records.
286#[must_use]
287pub fn transform_all_items(dbc: &DbcData) -> Vec<ItemDataFlat> {
288    super::transform_all(dbc.item.keys().copied(), "item", |item_id| {
289        transform_item(dbc, item_id)
290    })
291}
292
293#[cfg(test)]
294mod tests {
295    use googletest::prelude::*;
296    use rstest::rstest;
297
298    use super::*;
299
300    #[gtest]
301    #[rstest]
302    #[case::both_none(None, None, -1)]
303    #[case::low_zero_high_zero(Some(0), Some(0), 0)]
304    #[case::low_only(Some(5), Some(0), 5)]
305    #[case::high_only(Some(0), Some(1), 4_294_967_296)]
306    #[case::low_negative_masked(Some(-1), Some(0), 4_294_967_295)]
307    #[case::none_low_some_high(None, Some(0), 4_294_967_295)]
308    fn combine_allowable_race_cases(
309        #[case] low: Option<i64>,
310        #[case] high: Option<i64>,
311        #[case] expected: i64,
312    ) -> Result<()> {
313        verify_that!(combine_allowable_race(low, high), eq(expected))
314    }
315
316    #[gtest]
317    #[rstest]
318    #[case::zero(0, vec![])]
319    #[case::minus_one(-1, vec![])]
320    #[case::bit0(0b1, vec![1])]
321    #[case::bit0_and_2(0b101, vec![1, 3])]
322    #[case::high_bit(1 << 30, vec![31])]
323    #[case::mixed(0b1010, vec![2, 4])]
324    fn decode_difficulty_mask_cases(#[case] mask: i32, #[case] expected: Vec<i32>) -> Result<()> {
325        verify_that!(decode_difficulty_mask(mask), eq(&expected))
326    }
327
328    #[gtest]
329    #[rstest]
330    #[case::classic(0, "Classic")]
331    #[case::tww(10, "The War Within")]
332    #[case::unknown(99, "Unknown")]
333    #[case::negative(-1, "Unknown")]
334    fn get_expansion_name_cases(#[case] id: i32, #[case] expected: &str) -> Result<()> {
335        verify_that!(get_expansion_name(id), eq(expected))
336    }
337
338    #[gtest]
339    #[rstest]
340    #[case::head(1, "Head")]
341    #[case::two_hand(17, "Two-Hand")]
342    #[case::relic(28, "Relic")]
343    #[case::gap_27(27, "Unknown")]
344    #[case::unknown(99, "Unknown")]
345    fn get_inventory_type_name_cases(#[case] id: i32, #[case] expected: &str) -> Result<()> {
346        verify_that!(get_inventory_type_name(id), eq(expected))
347    }
348}