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        if let Some(modified_appearance) = dbc.item_modified_appearance.get(&item_id) {
88            if let Some(appearance) = dbc
89                .item_appearance
90                .get(&modified_appearance.ItemAppearanceID)
91            {
92                if appearance.DefaultIconFileDataID > 0 {
93                    icon_file_data_id = appearance.DefaultIconFileDataID;
94                }
95            }
96        }
97    }
98
99    let file_name = super::resolve_icon_file_name(dbc, icon_file_data_id);
100
101    let effect_links = dbc.item_x_item_effect.get(&item_id);
102    let effects: Vec<ItemEffect> = effect_links
103        .map(|links| {
104            links
105                .iter()
106                .filter_map(|link| {
107                    dbc.item_effect
108                        .get(&link.ItemEffectID)
109                        .map(|effect| ItemEffect {
110                            spell_id: effect.SpellID,
111                            trigger_type: effect.TriggerType,
112                            charges: effect.Charges,
113                            cooldown: effect.CoolDownMSec,
114                            category_cooldown: effect.CategoryCoolDownMSec,
115                        })
116                })
117                .collect()
118        })
119        .unwrap_or_default();
120
121    let stats: Vec<ItemStat> = sparse
122        .map(|s| {
123            let stat_types = array_fields!(s.StatModifierBonusStat, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
124            let stat_values = array_fields!(s.StatPercentEditor, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
125
126            stat_types
127                .iter()
128                .zip(stat_values.iter())
129                .filter(|&(&t, _)| t != -1 && t != 0)
130                .map(|(&t, &v)| ItemStat {
131                    stat_type: t,
132                    value: v,
133                })
134                .collect()
135        })
136        .unwrap_or_default();
137
138    let expansion_id = sparse.map_or(0, |s| s.ExpansionID);
139    let item_class_row = dbc.item_class_by_class_id.get(&item.ClassID);
140    let item_sub_classes = dbc.item_sub_class_by_class_id.get(&item.ClassID);
141    let item_sub_class_row =
142        item_sub_classes.and_then(|scs| scs.iter().find(|sc| sc.SubClassID == item.SubclassID));
143
144    let classification = Some(ItemClassification {
145        class_id: item.ClassID,
146        class_name: item_class_row
147            .and_then(|c| c.ClassName_lang.clone())
148            .unwrap_or_else(|| "Unknown".to_string())
149            .into(),
150        subclass_id: item.SubclassID,
151        subclass_name: item_sub_class_row
152            .and_then(|sc| sc.DisplayName_lang.clone())
153            .unwrap_or_else(|| "Unknown".to_string())
154            .into(),
155        inventory_type: item.InventoryType,
156        inventory_type_name: get_inventory_type_name(item.InventoryType).into(),
157        expansion_id,
158        expansion_name: get_expansion_name(expansion_id).into(),
159    });
160
161    let sockets: Vec<i32> = sparse
162        .map(|s| {
163            array_fields!(s.SocketType, [0, 1, 2])
164                .into_iter()
165                .filter(|&t| t > 0)
166                .collect()
167        })
168        .unwrap_or_default();
169
170    let flags: Vec<i32> = sparse
171        .map(|s| array_fields!(vec s.Flags, [0, 1, 2, 3, 4]))
172        .unwrap_or_default();
173
174    let item_set_id = sparse.map_or(0, |s| s.ItemSet);
175    let set_info = if item_set_id > 0 {
176        dbc.item_set.get(&item_set_id).map(|item_set| {
177            let item_ids: Vec<i32> = array_fields!(
178                item_set.ItemID,
179                [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
180            )
181            .into_iter()
182            .filter(|&id| id > 0)
183            .collect();
184
185            let bonuses: Vec<ItemSetBonus> = dbc
186                .item_set_spell
187                .get(&item_set_id)
188                .map(|spells| {
189                    spells
190                        .iter()
191                        .map(|spell| ItemSetBonus {
192                            threshold: spell.Threshold,
193                            spell_id: spell.SpellID,
194                            spec_id: spell.ChrSpecID,
195                        })
196                        .collect()
197                })
198                .unwrap_or_default();
199
200            ItemSetInfo {
201                set_id: item_set_id,
202                set_name: item_set
203                    .Name_lang
204                    .clone()
205                    .unwrap_or_else(|| "Unknown Set".to_string())
206                    .into(),
207                item_ids,
208                bonuses,
209            }
210        })
211    } else {
212        None
213    };
214
215    let drop_sources: Vec<ItemDropSource> = dbc
216        .journal_encounter_item
217        .get(&item_id)
218        .map(|encounter_items| {
219            encounter_items
220                .iter()
221                .filter_map(|encounter_item| {
222                    let encounter = dbc
223                        .journal_encounter
224                        .get(&encounter_item.JournalEncounterID)?;
225                    let instance = dbc.journal_instance.get(&encounter.JournalInstanceID);
226
227                    Some(ItemDropSource {
228                        instance_id: instance.map_or(0, |i| i.ID),
229                        instance_name: instance
230                            .and_then(|i| i.Name_lang.clone())
231                            .unwrap_or_else(|| "Unknown".to_string())
232                            .into(),
233                        encounter_id: encounter.ID,
234                        encounter_name: encounter
235                            .Name_lang
236                            .clone()
237                            .unwrap_or_else(|| "Unknown".to_string())
238                            .into(),
239                        difficulty_ids: decode_difficulty_mask(encounter_item.DifficultyMask),
240                    })
241                })
242                .collect()
243        })
244        .unwrap_or_default();
245
246    Ok(ItemDataFlat {
247        id: item_id,
248        name: sparse
249            .and_then(|s| s.Display_lang.clone())
250            .unwrap_or_default()
251            .into(),
252        description: sparse
253            .and_then(|s| s.Description_lang.clone())
254            .unwrap_or_default(),
255        file_name: file_name.into(),
256        item_level: sparse.map_or(0, |s| s.ItemLevel),
257        quality: sparse.map_or(0, |s| s.OverallQualityID),
258        required_level: sparse.map_or(0, |s| s.RequiredLevel),
259        binding: sparse.map_or(0, |s| s.Bonding),
260        buy_price: sparse.map_or(0, |s| s.BuyPrice),
261        sell_price: sparse.map_or(0, |s| s.SellPrice),
262        max_count: sparse.map_or(0, |s| s.MaxCount),
263        stackable: sparse.map_or(1, |s| s.Stackable),
264        speed: sparse.map_or(0, |s| s.ItemDelay),
265        class_id: item.ClassID,
266        subclass_id: item.SubclassID,
267        inventory_type: item.InventoryType,
268        classification,
269        stats,
270        effects,
271        sockets,
272        socket_bonus_enchant_id: sparse.map_or(0, |s| s.SocketMatchEnchantmentID),
273        flags,
274        allowable_class: sparse.map_or(-1, |s| s.AllowableClass),
275        allowable_race: sparse.map_or(-1, |s| {
276            combine_allowable_race(s.AllowableRaces_0, s.AllowableRaces_1)
277        }),
278        expansion_id,
279        item_set_id,
280        set_info,
281        drop_sources,
282        dmg_variance: sparse.map_or(0.0, |s| s.DmgVariance),
283        gem_properties: sparse.map_or(0, |s| s.GemProperties),
284        modified_crafting_reagent_item_id: item.ModifiedCraftingReagentItemID,
285    })
286}
287
288/// Transform every item row into flat `ItemDataFlat` records.
289#[must_use]
290pub fn transform_all_items(dbc: &DbcData) -> Vec<ItemDataFlat> {
291    super::transform_all(dbc.item.keys().copied(), "item", |item_id| {
292        transform_item(dbc, item_id)
293    })
294}
295
296#[cfg(test)]
297mod tests {
298    use googletest::prelude::*;
299    use rstest::rstest;
300
301    use super::*;
302
303    #[gtest]
304    #[rstest]
305    #[case::both_none(None, None, -1)]
306    #[case::low_zero_high_zero(Some(0), Some(0), 0)]
307    #[case::low_only(Some(5), Some(0), 5)]
308    #[case::high_only(Some(0), Some(1), 4_294_967_296)]
309    #[case::low_negative_masked(Some(-1), Some(0), 4_294_967_295)]
310    #[case::none_low_some_high(None, Some(0), 4_294_967_295)]
311    fn combine_allowable_race_cases(
312        #[case] low: Option<i64>,
313        #[case] high: Option<i64>,
314        #[case] expected: i64,
315    ) -> Result<()> {
316        verify_that!(combine_allowable_race(low, high), eq(expected))
317    }
318
319    #[gtest]
320    #[rstest]
321    #[case::zero(0, vec![])]
322    #[case::minus_one(-1, vec![])]
323    #[case::bit0(0b1, vec![1])]
324    #[case::bit0_and_2(0b101, vec![1, 3])]
325    #[case::high_bit(1 << 30, vec![31])]
326    #[case::mixed(0b1010, vec![2, 4])]
327    fn decode_difficulty_mask_cases(#[case] mask: i32, #[case] expected: Vec<i32>) -> Result<()> {
328        verify_that!(decode_difficulty_mask(mask), eq(&expected))
329    }
330
331    #[gtest]
332    #[rstest]
333    #[case::classic(0, "Classic")]
334    #[case::tww(10, "The War Within")]
335    #[case::unknown(99, "Unknown")]
336    #[case::negative(-1, "Unknown")]
337    fn get_expansion_name_cases(#[case] id: i32, #[case] expected: &str) -> Result<()> {
338        verify_that!(get_expansion_name(id), eq(expected))
339    }
340
341    #[gtest]
342    #[rstest]
343    #[case::head(1, "Head")]
344    #[case::two_hand(17, "Two-Hand")]
345    #[case::relic(28, "Relic")]
346    #[case::gap_27(27, "Unknown")]
347    #[case::unknown(99, "Unknown")]
348    fn get_inventory_type_name_cases(#[case] id: i32, #[case] expected: &str) -> Result<()> {
349        verify_that!(get_inventory_type_name(id), eq(expected))
350    }
351}