From db163e98523438cd699b1afc7f5c6108f25269b1 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Thu, 3 Sep 2026 19:40:58 +0200 Subject: [PATCH 1/3] refactor(importer): split the three longest ETL functions `build_dump_plan` (1,387 lines), `map_action` (929) and `build_spell_rows` (712) each held a whole pipeline in one body. Each already carried its own section markers, so the split follows them. `build_dump_plan` now reads as its numbered sections: the World Import Scope's creature spawns, the entry set the EventAI fixpoint grows, the template rows, the cast rows, the packed spawn payload, the waypoints, the gameobject import, the trainer offerings and the loot families. The per-family clear+reload blocks push in the same order as before, off one borrowed view of the mapped rows. `map_action` keeps the match as the dispatcher and sends each family to its own mapper: speech, combat, phase, summons, movement, unit state, encounter and quest. Every ACTION id is distinct, so grouping the arms keeps each kind's own arm order, guards and fallbacks intact. `build_spell_rows` splits into the DBC open, the header row, the effect rows and the trainer offerings, with the effect kind and target resolution named apart. Behaviour is unchanged: every section body moved verbatim. A throwaway fingerprint over each fixture's whole `DumpPlan` (statements, batches, manifest, stamps) across all eleven families hashes identically before and after. Co-Authored-By: Claude Fable 5.1 --- importer/src/eventai.rs | 473 +++++++++----- importer/src/main.rs | 668 ++++++++++++++----- importer/src/spell.rs | 1351 ++++++++++++++++++++++----------------- 3 files changed, 1573 insertions(+), 919 deletions(-) diff --git a/importer/src/eventai.rs b/importer/src/eventai.rs index 0a19cd3e8..aab610b86 100644 --- a/importer/src/eventai.rs +++ b/importer/src/eventai.rs @@ -4570,6 +4570,135 @@ fn map_action( normalizations: mapped.normalizations, }); } + match kind { + ACTION_TEXT | ACTION_TEXT_NEW => { + map_speech_action(action, kind, rule_id, slot, broadcasts, relays) + } + ACTION_EMOTE => Ok(NativeAction { + encoded: format!("emote:{}:self", action[1]), + raw_kind: kind, + raw_target: None, + raw_cast_flags: None, + threat_percent: None, + dependencies: Vec::new(), + texts: Vec::new(), + summon_entry: None, + summon_location: None, + normalizations: Vec::new(), + }), + ACTION_RANDOM_EMOTE => Ok(NativeAction { + encoded: format!( + "random-emote:{}.{}.{}", + action[1] as i32, action[2] as i32, action[3] as i32 + ), + raw_kind: kind, + raw_target: None, + raw_cast_flags: None, + threat_percent: None, + dependencies: Vec::new(), + texts: Vec::new(), + summon_entry: None, + summon_location: None, + normalizations: Vec::new(), + }), + ACTION_CAST | ACTION_THREAT_SINGLE | ACTION_THREAT_ALL_PCT | ACTION_REMOVE_AURA => { + map_combat_action(action, kind, rule_id, slot, relays) + } + ACTION_SET_PHASE + | ACTION_INCREMENT_PHASE + | ACTION_RANDOM_PHASE + | ACTION_RANDOM_PHASE_RANGE => map_phase_action(action, kind), + ACTION_FLEE_FOR_ASSIST => Ok(NativeAction { + encoded: "flee".to_string(), + raw_kind: kind, + raw_target: None, + raw_cast_flags: None, + threat_percent: None, + dependencies: Vec::new(), + texts: Vec::new(), + summon_entry: None, + summon_location: None, + normalizations: Vec::new(), + }), + ACTION_CALL_FOR_HELP => Ok(NativeAction { + encoded: format!("help:{}", action[1]), + raw_kind: kind, + raw_target: None, + raw_cast_flags: None, + threat_percent: None, + dependencies: Vec::new(), + texts: Vec::new(), + summon_entry: None, + summon_location: None, + normalizations: Vec::new(), + }), + ACTION_SPAWN | ACTION_SUMMON_ID => map_summon_action( + action, + kind, + rule_id, + slot, + summon_locations, + importable_templates, + ), + ACTION_COMBAT_MOVEMENT + | ACTION_EVADE + | ACTION_RANGED_MOVEMENT + | ACTION_CHANGE_MOVEMENT + | ACTION_PAUSE_WAYPOINTS + | ACTION_SET_RANGED_MODE + | ACTION_SET_WALK + | ACTION_SET_FACING + | ACTION_SET_IMMOBILIZED + | ACTION_SET_FOLLOW_MOVEMENT + | ACTION_RETREAT => map_movement_action(action, kind), + ACTION_DIE + | ACTION_FORCE_DESPAWN + | ACTION_THROW_AI_EVENT + | ACTION_SET_STAND_STATE + | ACTION_SET_REACT_STATE + | ACTION_DESPAWN_GUARDIANS + | ACTION_SET_DEATH_PREVENTION => { + map_state_action(action, kind, rule_id, slot, importable_templates) + } + ACTION_SET_INSTANCE_DATA | ACTION_START_RELAY => map_encounter_action( + action, + kind, + raw_subject, + rule_id, + slot, + source_maps, + relays, + ), + ACTION_QUEST_EVENT | ACTION_KILLED_MONSTER => map_quest_action( + action, + kind, + rule_id, + slot, + quest_entries, + importable_templates, + ), + value => unsupported_action(value), + } +} + +/// The source action fell through every parameter shape this loader accepts. +fn unsupported_action(kind: u32) -> Result> { + Err(vec![MappingFailure::source( + "action", + u64::from(kind), + "unsupported_action", + )]) +} + +/// Speech: the legacy multi-text action and its newer single-text/template twin. +fn map_speech_action( + action: [u32; 4], + kind: u32, + rule_id: u64, + slot: usize, + broadcasts: &BTreeMap, + relays: &RelaySource, +) -> Result> { match kind { ACTION_TEXT => { let texts = action[1..] @@ -4729,33 +4858,19 @@ fn map_action( normalizations: Vec::new(), }) } - ACTION_EMOTE => Ok(NativeAction { - encoded: format!("emote:{}:self", action[1]), - raw_kind: kind, - raw_target: None, - raw_cast_flags: None, - threat_percent: None, - dependencies: Vec::new(), - texts: Vec::new(), - summon_entry: None, - summon_location: None, - normalizations: Vec::new(), - }), - ACTION_RANDOM_EMOTE => Ok(NativeAction { - encoded: format!( - "random-emote:{}.{}.{}", - action[1] as i32, action[2] as i32, action[3] as i32 - ), - raw_kind: kind, - raw_target: None, - raw_cast_flags: None, - threat_percent: None, - dependencies: Vec::new(), - texts: Vec::new(), - summon_entry: None, - summon_location: None, - normalizations: Vec::new(), - }), + value => unsupported_action(value), + } +} + +/// Combat: casting a spell, moving threat, and stripping an aura. +fn map_combat_action( + action: [u32; 4], + kind: u32, + rule_id: u64, + slot: usize, + relays: &RelaySource, +) -> Result> { + match kind { ACTION_CAST => { if action[1] == 0 { return Err(vec![MappingFailure::source( @@ -4832,6 +4947,45 @@ fn map_action( normalizations: Vec::new(), }) } + ACTION_REMOVE_AURA if action[2] != 0 && action[3] == 0 => { + let target = map_target(action[1]).map_err(|failure| vec![failure])?; + if !relays.spell_ids.contains(&action[2]) { + return Err(vec![MappingFailure::dependency( + "spell_template", + u64::from(action[2]), + "missing", + format!( + "rule:{rule_id} -> action:{slot} -> spell_template:{}", + action[2] + ), + )]); + } + Ok(NativeAction { + encoded: format!("remove-aura:{}:{target}", action[2]), + raw_kind: kind, + raw_target: Some(action[1]), + raw_cast_flags: None, + threat_percent: None, + dependencies: vec![Dependency { + kind: "spell_template", + path: format!( + "rule:{rule_id} -> action:{slot} -> spell_template:{}", + action[2] + ), + }], + texts: Vec::new(), + summon_entry: None, + summon_location: None, + normalizations: Vec::new(), + }) + } + value => unsupported_action(value), + } +} + +/// Phase: the four ways a rule moves its subject's phase mask. +fn map_phase_action(action: [u32; 4], kind: u32) -> Result> { + match kind { ACTION_SET_PHASE if action[1] < 32 => Ok(NativeAction { encoded: format!("phase:{}", action[1]), raw_kind: kind, @@ -4895,30 +5049,28 @@ fn map_action( normalizations, }) } - ACTION_FLEE_FOR_ASSIST => Ok(NativeAction { - encoded: "flee".to_string(), - raw_kind: kind, - raw_target: None, - raw_cast_flags: None, - threat_percent: None, - dependencies: Vec::new(), - texts: Vec::new(), - summon_entry: None, - summon_location: None, - normalizations: Vec::new(), - }), - ACTION_CALL_FOR_HELP => Ok(NativeAction { - encoded: format!("help:{}", action[1]), - raw_kind: kind, - raw_target: None, - raw_cast_flags: None, - threat_percent: None, - dependencies: Vec::new(), - texts: Vec::new(), - summon_entry: None, - summon_location: None, - normalizations: Vec::new(), - }), + ACTION_SET_PHASE + | ACTION_INCREMENT_PHASE + | ACTION_RANDOM_PHASE + | ACTION_RANDOM_PHASE_RANGE => Err(vec![MappingFailure::source( + "action", + u64::from(action[1]), + format!("invalid_phase_{}", action[1]), + )]), + value => unsupported_action(value), + } +} + +/// Summons: the placement-free spawn and the `creature_ai_summons` location-keyed summon. +fn map_summon_action( + action: [u32; 4], + kind: u32, + rule_id: u64, + slot: usize, + summon_locations: &BTreeMap, + importable_templates: &HashSet, +) -> Result> { + match kind { ACTION_SPAWN => { let entry = u64::from(action[1]); let mut failures = Vec::new(); @@ -4968,38 +5120,77 @@ fn map_action( normalizations: Vec::new(), }) } - ACTION_REMOVE_AURA if action[2] != 0 && action[3] == 0 => { - let target = map_target(action[1]).map_err(|failure| vec![failure])?; - if !relays.spell_ids.contains(&action[2]) { - return Err(vec![MappingFailure::dependency( - "spell_template", - u64::from(action[2]), + ACTION_SUMMON_ID => { + let entry = action[1] as u64; + let mut failures = Vec::new(); + if !importable_templates.contains(&entry) { + failures.push(MappingFailure::dependency( + "summon_creature", + entry, + "missing", + format!("rule:{rule_id} -> action:{slot} -> creature_template:{entry}"), + )); + } + if !summon_locations.contains_key(&action[3]) { + failures.push(MappingFailure::dependency( + "summon_location", + u64::from(action[3]), "missing", format!( - "rule:{rule_id} -> action:{slot} -> spell_template:{}", - action[2] + "rule:{rule_id} -> action:{slot} -> creature_ai_summons:{}", + action[3] ), - )]); + )); + } + let target = match map_target(action[2]) { + Ok(target) => Some(target), + Err(failure) => { + failures.push(failure); + None + } + }; + if !failures.is_empty() { + return Err(failures); } Ok(NativeAction { - encoded: format!("remove-aura:{}:{target}", action[2]), + encoded: format!( + "summon:{}:{}:{}", + action[1], + action[3], + target.expect("a summon without mapping failures has a target") + ), raw_kind: kind, - raw_target: Some(action[1]), + raw_target: Some(action[2]), raw_cast_flags: None, threat_percent: None, - dependencies: vec![Dependency { - kind: "spell_template", - path: format!( - "rule:{rule_id} -> action:{slot} -> spell_template:{}", - action[2] - ), - }], texts: Vec::new(), - summon_entry: None, - summon_location: None, + summon_entry: Some(entry), + summon_location: Some(action[3]), + dependencies: vec![ + Dependency { + kind: "summon_creature", + path: format!( + "rule:{rule_id} -> action:{slot} -> creature_template:{entry}" + ), + }, + Dependency { + kind: "summon_location", + path: format!( + "rule:{rule_id} -> action:{slot} -> creature_ai_summons:{}", + action[3] + ), + }, + ], normalizations: Vec::new(), }) } + value => unsupported_action(value), + } +} + +/// Movement: every action that changes how the subject moves or faces. +fn map_movement_action(action: [u32; 4], kind: u32) -> Result> { + match kind { ACTION_COMBAT_MOVEMENT if action[2] == 0 && action[3] == 0 => Ok(movement_action( kind, format!("combat-movement:{}", action[1] != 0), @@ -5095,70 +5286,19 @@ fn map_action( u64::from(kind), "unsupported_retreat", )]), - ACTION_SUMMON_ID => { - let entry = action[1] as u64; - let mut failures = Vec::new(); - if !importable_templates.contains(&entry) { - failures.push(MappingFailure::dependency( - "summon_creature", - entry, - "missing", - format!("rule:{rule_id} -> action:{slot} -> creature_template:{entry}"), - )); - } - if !summon_locations.contains_key(&action[3]) { - failures.push(MappingFailure::dependency( - "summon_location", - u64::from(action[3]), - "missing", - format!( - "rule:{rule_id} -> action:{slot} -> creature_ai_summons:{}", - action[3] - ), - )); - } - let target = match map_target(action[2]) { - Ok(target) => Some(target), - Err(failure) => { - failures.push(failure); - None - } - }; - if !failures.is_empty() { - return Err(failures); - } - Ok(NativeAction { - encoded: format!( - "summon:{}:{}:{}", - action[1], - action[3], - target.expect("a summon without mapping failures has a target") - ), - raw_kind: kind, - raw_target: Some(action[2]), - raw_cast_flags: None, - threat_percent: None, - texts: Vec::new(), - summon_entry: Some(entry), - summon_location: Some(action[3]), - dependencies: vec![ - Dependency { - kind: "summon_creature", - path: format!( - "rule:{rule_id} -> action:{slot} -> creature_template:{entry}" - ), - }, - Dependency { - kind: "summon_location", - path: format!( - "rule:{rule_id} -> action:{slot} -> creature_ai_summons:{}", - action[3] - ), - }, - ], - normalizations: Vec::new(), - }) - } + value => unsupported_action(value), + } +} + +/// Unit state: dying, despawning, stand/react state, and the AI event throw. +fn map_state_action( + action: [u32; 4], + kind: u32, + rule_id: u64, + slot: usize, + importable_templates: &HashSet, +) -> Result> { + match kind { ACTION_DIE if action[1..] == [0, 0, 0] => Ok(NativeAction { encoded: "force-death".to_string(), raw_kind: kind, @@ -5281,6 +5421,31 @@ fn map_action( normalizations: Vec::new(), }) } + ACTION_DIE => Err(vec![MappingFailure::source( + "action", + ACTION_DIE as u64, + "invalid_force_death_parameters", + )]), + ACTION_SET_DEATH_PREVENTION => Err(vec![MappingFailure::source( + "death_prevention_state", + u64::from(action[1]), + "invalid_death_prevention_state", + )]), + value => unsupported_action(value), + } +} + +/// Encounter Signals and Relay Definitions: the two actions that cross an Encounter Binding. +fn map_encounter_action( + action: [u32; 4], + kind: u32, + raw_subject: i32, + rule_id: u64, + slot: usize, + source_maps: &HashMap>, + relays: &RelaySource, +) -> Result> { + match kind { ACTION_SET_INSTANCE_DATA => { let entry = u32::try_from(raw_subject).map_err(|_| { vec![MappingFailure::dependency( @@ -5384,16 +5549,20 @@ fn map_action( normalizations, }) } - ACTION_DIE => Err(vec![MappingFailure::source( - "action", - ACTION_DIE as u64, - "invalid_force_death_parameters", - )]), - ACTION_SET_DEATH_PREVENTION => Err(vec![MappingFailure::source( - "death_prevention_state", - u64::from(action[1]), - "invalid_death_prevention_state", - )]), + value => unsupported_action(value), + } +} + +/// Quest credit: the explicit quest event and the kill credit that stands in for one. +fn map_quest_action( + action: [u32; 4], + kind: u32, + rule_id: u64, + slot: usize, + quest_entries: &HashSet, + importable_templates: &HashSet, +) -> Result> { + match kind { ACTION_QUEST_EVENT => { let recipient: &'static str = match (action[2], action[3]) { (TARGET_ACTION_INVOKER, 0) => Ok("selected-character"), @@ -5454,19 +5623,7 @@ fn map_action( } kill_credit_action(action, rule_id, slot, importable_templates) } - ACTION_SET_PHASE - | ACTION_INCREMENT_PHASE - | ACTION_RANDOM_PHASE - | ACTION_RANDOM_PHASE_RANGE => Err(vec![MappingFailure::source( - "action", - u64::from(action[1]), - format!("invalid_phase_{}", action[1]), - )]), - value => Err(vec![MappingFailure::source( - "action", - value as u64, - "unsupported_action", - )]), + value => unsupported_action(value), } } diff --git a/importer/src/main.rs b/importer/src/main.rs index afed5b578..4bc82ccef 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -3711,8 +3711,6 @@ fn template_is_importable(row: &[String]) -> bool { field(row, ct::UNIT_FLAGS).parse::().unwrap_or(0) & UNIT_FLAG_NOT_SELECTABLE == 0 } -// ETL row shape: one `game_creature_spawn` row per tuple, in the packed-payload column order. -#[allow(clippy::type_complexity)] /// Build the full `--dump` ETL plan (work-item 216's `--family` split lives entirely in here, via /// `family_active` gates around each family's DELETE+INSERT block): parses the cmangos dump, filters /// the content slice, maps every family's rows, and returns the ready-to-apply plan. Pure w.r.t. the @@ -3740,6 +3738,256 @@ fn build_dump_plan( eprintln!(" whole map: {map}"); } + let spawns = collect_creature_spawns(dump, &scope); + let ScopeEntries { + entries, + eventai, + eventai_manifest, + } = resolve_scope_entries(dump, args, &scope, &spawns)?; + let CreatureTemplates { + templates, + creature_loot_ids, + creature_pickpocket_ids, + creature_skin_ids, + excluded_entries, + class_trainer_count, + } = build_creature_templates(dump, &entries, display_scales); + let (creature_casts, creature_rotation_rows) = build_creature_cast_rows(dump, &entries); + let SpawnPayload { + guid_of, + packed_spawns, + excluded_count, + } = build_packed_spawn_payload(&spawns, &excluded_entries); + let waypoints = build_waypoint_rows(dump, &spawns, &guid_of); + + eprintln!( + "mapped: {} templates ({class_trainer_count} class-gated trainers), {} spawns ({} excluded), {} waypoints", + templates.len(), + spawns.len() - excluded_count, + excluded_count, + waypoints.len() + ); + + let go_spawns = collect_gameobject_spawns(dump, &scope); + let (go_template_rows, go_trap_rows, chest_loot_ids_used) = + gameobject_template_rows(&go_spawns); + let trainer_rows = build_trainer_spell_rows(dump, &entries, profession_tier_values); + + // Quests: map quest_template + the giver-relation tables (creature AND gameobject) for quests given + // by an in-slice giver. Built BEFORE the item ETL so the reward item entries can be folded into the + // item-template load (a turn-in's reward grant joins game_item_template). + // Obtainability gate (ZERO-STUCK-QUESTS): the set of items that drop from in-slice creatures, so + // build_quests only emits a COLLECT objective for a ReqItem that is obtainable (drops OR is the + // quest's SrcItem) — else the quest auto-completes instead of soft-locking. + // 042: obtainable = creature drops ∪ in-slice GO (chest) loot ∪ in-slice vendor stock. The old + // creature-drops-only gate skipped GO/vendor-sourced ReqItems entirely → an EMPTY objective set → + // quest_is_complete() == true on accept → free turn-in reward (q3904 grapes / q5545 wood via GO + // loot, q3861 kodo feed via vendor — all three obtainable in-slice now, verified live). + let mut obtainable_items = creature_drop_item_set(dump, &creature_loot_ids); + obtainable_items.extend(gameobject_loot_item_set(dump, &go_spawns.used_go)); + obtainable_items.extend(vendor_stock_item_set(dump, &entries)); + let quests = build_quests( + dump, + &entries, + &go_spawns.used_go, + &go_spawns.goober_entries, + &obtainable_items, + ); + eprintln!( + "mapped: {} quests, {} text, {} objectives, {} cast objectives, {} reward items, {} choice rewards, \ + {} creature giver relations, {} gameobject giver relations, {} chained (next_quest_id>0) [V], \ + {} timed (limit_time>0) [V]", + quests.templates.len(), quests.texts.len(), quests.objectives.len(), + quests.cast_objectives.len(), quests.reward_items.len(), quests.reward_choices.len(), quests.relations.len(), + quests.go_relations.len(), quests.chained_count, quests.timed_count, + ); + + // P4: real item templates + creature loot for the imported creatures (clear+reload, like above). + // game_item_template is wiped+reloaded with the loot-referenced set ∪ the starter loadout ∪ quest + // reward items ∪ quest COLLECT (ReqItem) items (so the login grant keeps a real weapon, turn-ins can + // deliver, AND every collect-quest item has a template); game_creature_loot is fully importer-owned. + let extra_item_entries: std::collections::HashSet = quests + .reward_item_entries + .iter() + .chain(quests.req_item_entries.iter()) + .copied() + .collect(); + let (item_rows, loot_rows, vendor_rows) = + build_items_and_loot(dump, &creature_loot_ids, &entries, &extra_item_entries); + eprintln!( + "mapped: {} item_templates, {} creature_loot rows, {} npc_vendor rows ({} creatures with loot)", + item_rows.len(), loot_rows.len(), vendor_rows.len(), creature_loot_ids.len() + ); + let LootFamilyRows { + pickpocket_rows, + skinning_rows, + gameobject_loot_rows, + fishing_rows, + } = build_loot_family_rows( + dump, + &creature_pickpocket_ids, + &creature_skin_ids, + &chest_loot_ids_used, + ); + // Gossip menus + NPC text + menu options (work-item 217): map each in-box gossip NPC's + // creature_entry to the npc_text id shown in SMSG_GOSSIP_MESSAGE, import the resolved greeting + // strings (all 8 weighted slots, not just the first), and import its clickable menu options + // (vendor/innkeeper/trainer/plain-gossip, quest-status conditions folded). Four-table clear+reload. + let gossip = build_gossip_sql(dump, &entries); + + let content = MappedContent { + templates: &templates, + waypoints: &waypoints, + item_rows: &item_rows, + loot_rows: &loot_rows, + vendor_rows: &vendor_rows, + pickpocket_rows: &pickpocket_rows, + skinning_rows: &skinning_rows, + gameobject_loot_rows: &gameobject_loot_rows, + fishing_rows: &fishing_rows, + gossip: &gossip, + quests: &quests, + go_template_rows: &go_template_rows, + go_trap_rows: &go_trap_rows, + trainer_rows: &trainer_rows, + creature_casts: &creature_casts, + creature_rotation_rows: &creature_rotation_rows, + eventai: &eventai, + }; + // 5) static content (no Timestamp columns) → plain SQL clear+reload, family by family, in the + // order the tables must land. The spawns + live-entity reset are NOT here — they go through + // the `import_creature_spawns` reducer (step 6), which clears every CREATURE entity + spawn + // and loads the new spawns with a valid `ctx.timestamp`. + let mut stmts: Vec = Vec::new(); + push_world_content_statements(args, content, &mut stmts); + push_quest_and_gameobject_statements(args, content, &mut stmts); + push_creature_behaviour_statements(args, content, &mut stmts); + let (globals_row_count, spellmeta_row_count) = push_global_statements(args, dump, &mut stmts)?; + + // The spawn payload can hold ~2k rows for a full zone — more than one `spacetime call` string arg + // takes — so split it into batches. Batch 0 goes to `import_creature_spawns` (clears the live + // roster + threat ONCE, then loads); every later batch goes to `import_creature_spawns_append` + // (load only), so the clear happens exactly once and every batch survives. + let spawn_rows: Vec<&str> = packed_spawns.split(';').filter(|r| !r.is_empty()).collect(); + let batches: Vec = spawn_rows + .chunks(SPAWN_BATCH) + .map(|c| c.join(";")) + .collect(); + let go_batches: Vec = go_spawns + .go_packed_rows + .chunks(SPAWN_BATCH) + .map(|c| c.join(";")) + .collect(); + + let stamps = family_stamps(args, content, globals_row_count, spellmeta_row_count); + + let eventai_definition_count = eventai.definition_count(); + let eventai_instruction_count = eventai.instruction_count(); + let eventai_definition_batches = eventai.definition_batches; + let eventai_relay_definition_count = eventai.relay_definition_rows.len() as u64; + let eventai_relay_definition_batches = eventai.relay_definition_batches; + + Ok(DumpPlan { + stmts, + spawn_row_count: spawn_rows.len(), + go_row_count: go_spawns.go_packed_rows.len(), + spawn_batches: batches, + go_batches, + eventai_definition_batches, + eventai_relay_definition_batches, + eventai_definition_count, + eventai_relay_definition_count, + eventai_instruction_count, + eventai_manifest, + stamps, + }) +} + +/// ETL row shape: one `game_creature_spawn` row per tuple, in the packed-payload column order — +/// db_guid, entry, x, y, z, orientation, movement_type, respawn_secs, map. +type CreatureSpawnRow = (u64, u64, f64, f64, f64, f64, u8, u32, i64); + +/// The creature template entries this run imports, with the EventAI plan that grew them. +struct ScopeEntries { + entries: std::collections::HashSet, + eventai: eventai::EventAiPlan, + /// The rendered Compatibility Manifest — `None` when the creature-ai family is not part of the run. + eventai_manifest: Option, +} + +/// The `game_creature_template` rows plus the per-entry loot ids and exclusions every later family reads. +struct CreatureTemplates { + templates: Vec, + creature_loot_ids: std::collections::HashMap, + creature_pickpocket_ids: std::collections::HashMap, + creature_skin_ids: std::collections::HashMap, + excluded_entries: std::collections::HashSet, + class_trainer_count: usize, +} + +/// The packed `import_creature_spawns` payload and the db_guid → world guid map the waypoints need. +struct SpawnPayload { + guid_of: std::collections::HashMap, + packed_spawns: String, + excluded_count: usize, +} + +/// One imported `gameobject_template`, reduced to the columns the per-type row builder needs. +struct GoMeta { + stored_type: u8, + disp: u32, + name: String, + data0: u32, + data1: u32, + trap_spell_id: u32, + trap_cooldown_secs: u32, + size: f32, +} + +/// The gameobject spawns the World Import Scope keeps, with the template metadata they resolved against. +struct GameobjectSpawns { + go_meta: std::collections::HashMap, + dropped_type25: Vec, + goober_entries: std::collections::HashSet, + go_packed_rows: Vec, + used_go: std::collections::HashSet, +} + +/// The four loot families that hang off an imported creature or chest. +struct LootFamilyRows { + pickpocket_rows: Vec, + skinning_rows: Vec, + gameobject_loot_rows: Vec, + fishing_rows: Vec, +} + +/// Every mapped row set the static-content SQL blocks and the provenance stamps read. Borrowed, so +/// the push helpers below stay a view over `build_dump_plan`'s own values. +#[derive(Clone, Copy)] +struct MappedContent<'a> { + templates: &'a [String], + waypoints: &'a [String], + item_rows: &'a [String], + loot_rows: &'a [String], + vendor_rows: &'a [String], + pickpocket_rows: &'a [String], + skinning_rows: &'a [String], + gameobject_loot_rows: &'a [String], + fishing_rows: &'a [String], + gossip: &'a GossipEtl, + quests: &'a QuestEtl, + go_template_rows: &'a [String], + go_trap_rows: &'a [String], + trainer_rows: &'a [String], + creature_casts: &'a [String], + creature_rotation_rows: &'a [String], + eventai: &'a eventai::EventAiPlan, +} + +/// Every creature spawn the World Import Scope keeps: the cmangos `creature` rows inside the scope, +/// with id=0 pool slots resolved through `creature_spawn_entry` and `spawn_group` MaxCount caps +/// applied. +fn collect_creature_spawns(dump: &str, scope: &WorldImportScope) -> Vec { // 0b) creature_spawn_entry: resolves id=0 pool slots to concrete creature_template entries. // cmangos places id=0 in `creature` for spawn-pool points; the real entry is in creature_spawn_entry. // Each guid may map to multiple entries (random pick at runtime). We pick the first for determinism. @@ -3772,7 +4020,7 @@ fn build_dump_plan( let raw_entry: u64 = field(&row, cr::ID).parse().unwrap_or(0); // A forced entry bypasses bounded geometry but not the scope's map fence. Keeping the spawn // makes its entry flow into every creature-scoped family below. - if !creature_row_kept_in_scope(&scope, map, x, y, z, raw_entry) { + if !creature_row_kept_in_scope(scope, map, x, y, z, raw_entry) { continue; } // cmangos uses id=0 for random spawn-POOL slots; the concrete entry is in creature_spawn_entry. @@ -3871,6 +4119,18 @@ fn build_dump_plan( } } + spawns +} + +/// The creature template entries this run imports, and the EventAI plan built against them. Accepted +/// EventAI summons force their summoned templates into the entry set, to a fixpoint, so a summoned-only +/// creature still gets a template. +fn resolve_scope_entries( + dump: &str, + args: &Args, + scope: &WorldImportScope, + spawns: &[CreatureSpawnRow], +) -> Result { let mut entries: std::collections::HashSet = spawns.iter().map(|s| s.1).collect(); // PET/SUMMON templates (Tier 3b): a summoned pet (Warlock's Imp = entry 416) has NO world spawn, so it // is never in the geographic slice above — yet its game_creature_template row MUST exist for the @@ -3953,6 +4213,20 @@ fn build_dump_plan( entries.len() ); + Ok(ScopeEntries { + entries, + eventai, + eventai_manifest, + }) +} + +/// The `game_creature_template` rows for the scope's entries. Also collects the loot ids and the +/// excluded (`UNIT_FLAG_NOT_SELECTABLE`) entries the spawn payload and the loot families read. +fn build_creature_templates( + dump: &str, + entries: &std::collections::HashSet, + display_scales: &Option>, +) -> CreatureTemplates { // 2) templates for those entries. let mut templates: Vec = Vec::new(); // entry → LootId for the imported creatures; drives the item + loot ETL below (0 = no loot table). @@ -4021,7 +4295,7 @@ fn build_dump_plan( } }; let faction_template: u32 = field(&row, ct::FACTION).parse().unwrap_or(0); - // Per-creature melee damage (parity #7): cmangos MinMeleeDmg/MaxMeleeDmg are floats; round to int + // Per-creature melee damage: cmangos MinMeleeDmg/MaxMeleeDmg are floats; round to int // (vanilla creature swings are whole-number ranges in practice). 0 stays 0 → the module's // swing_range_ctx falls back to the flat CREATURE_MELEE range for that creature. let dmg_min: u32 = field(&row, ct::MIN_MELEE_DMG) @@ -4075,6 +4349,21 @@ fn build_dump_plan( )); } + CreatureTemplates { + templates, + creature_loot_ids, + creature_pickpocket_ids, + creature_skin_ids, + excluded_entries, + class_trainer_count, + } +} + +/// The legacy single-spell cast rows and the multi-spell rotation rows for the scope's casters. +fn build_creature_cast_rows( + dump: &str, + entries: &std::collections::HashSet, +) -> (Vec, Vec) { // 2b) caster-mob cast rows (game_creature_cast, LEGACY) + rotation rows (game_creature_spell, rank 20). // // LEGACY (game_creature_cast): one spell per entry (spell1), used by pass_cast as a fallback ONLY when @@ -4161,6 +4450,14 @@ fn build_dump_plan( .count(), ); + (creature_casts, creature_rotation_rows) +} + +/// The packed spawn payload the `import_creature_spawns` reducer loads. +fn build_packed_spawn_payload( + spawns: &[CreatureSpawnRow], + excluded_entries: &std::collections::HashSet, +) -> SpawnPayload { // 3) spawn payload for the `import_creature_spawns` reducer — NOT SQL. `game_creature_spawn` has // Timestamp columns (respawn_at/despawn_at) and SpacetimeDB 2.5 SQL has no Timestamp literal, // so spawns load through a reducer (which stamps `ctx.timestamp`). Rows `;`-separated, fields @@ -4200,6 +4497,20 @@ fn build_dump_plan( .collect::>() .join(";"); + SpawnPayload { + guid_of, + packed_spawns, + excluded_count, + } +} + +/// The `game_creature_waypoint` rows for the imported spawns: guid-keyed routes first, then the +/// entry-keyed template routes expanded onto every waypoint-typed spawn without one. +fn build_waypoint_rows( + dump: &str, + spawns: &[CreatureSpawnRow], + guid_of: &std::collections::HashMap, +) -> Vec { // 4) waypoints for the imported spawns (id assigned by a counter — clear+reload is idempotent). let mut waypoints: Vec = Vec::new(); let mut wp_id: u64 = 1; @@ -4255,7 +4566,7 @@ fn build_dump_plan( } let mut tmpl_spawns = 0usize; if !tmpl_paths.is_empty() { - for (db_guid, entry, _x, _y, _z, _o, mt, _rs, _map) in &spawns { + for (db_guid, entry, _x, _y, _z, _o, mt, _rs, _map) in spawns { if *mt != 2 || direct_wp_guids.contains(db_guid) { continue; // not waypoint-typed, or already routed by direct rows } @@ -4278,35 +4589,12 @@ fn build_dump_plan( } } - eprintln!( - "mapped: {} templates ({class_trainer_count} class-gated trainers), {} spawns ({} excluded), {} waypoints", - templates.len(), - spawns.len() - excluded_count, - excluded_count, - waypoints.len() - ); - - // 5) static content (no Timestamp columns) → plain SQL clear+reload. The spawns + live-entity - // reset are NOT here — they go through the `import_creature_spawns` reducer (step 6), which - // clears every CREATURE entity + spawn and loads the new spawns with a valid `ctx.timestamp`. - // So we only clear/reload templates + waypoints; the reducer owns the rest. - // family gate (work-item 216): `--family creatures` reloads this block (+ the spawn/waypoint - // reducer batches below) alone; every other family's block skips it. `--family` absent → every - // `family_active` call below is unconditionally true, so the full run is byte-identical to - // pre-216 behavior — see the full-run parity fixture test. - let mut stmts: Vec = Vec::new(); - if family_active(args, "creatures") { - stmts.push("DELETE FROM game_creature_waypoint WHERE id > 0".into()); - stmts.push("DELETE FROM game_creature_template WHERE entry > 0".into()); - push_insert(&mut stmts, "game_creature_template", "entry,name,subname,display_id,level,health,faction_template,npc_flags,unit_flags,creature_type,creature_family,type_flags,rank,scale,base_attack_time_ms,money_min,money_max,max_level,max_level_health,aggro_range,damage_min,damage_max,armor,pickpocket_loot_id,skin_loot_id,trainer_type,trainer_class", &templates); - push_insert( - &mut stmts, - "game_creature_waypoint", - "id,creature_guid,x,y,z", - &waypoints, - ); - } + waypoints +} +/// The gameobject spawns the World Import Scope keeps, packed for the `import_gameobjects` reducer, +/// with the classified template metadata they resolved against. +fn collect_gameobject_spawns(dump: &str, scope: &WorldImportScope) -> GameobjectSpawns { // Gameobjects (work-item 211 widened import): EVERY in-scope cmangos `gameobject_template` type // now imports (template + spawn) — previously only an allowlisted subset (GOOBER/curated GATHER/ // curated CHEST/QUESTGIVER) did. `classify_go_type` decides the stored `type_id` (or drops the row @@ -4314,16 +4602,6 @@ fn build_dump_plan( // load via the import_gameobjects reducer (game_gameobject has a Timestamp); the packed spawn row // carries an `initial_state` field (work-item 211) so a DOOR/BUTTON can spawn already-open. let go_tmpls = parse_table(dump, "gameobject_template"); - struct GoMeta { - stored_type: u8, - disp: u32, - name: String, - data0: u32, - data1: u32, - trap_spell_id: u32, - trap_cooldown_secs: u32, - size: f32, - } let mut dropped_type25: Vec = Vec::new(); let go_meta: std::collections::HashMap = go_tmpls .iter() @@ -4400,7 +4678,7 @@ fn build_dump_plan( } let db_guid: u64 = field(&row, go::GUID).parse().unwrap_or(0); let o: f64 = field(&row, go::O).parse().unwrap_or(0.0); - // The spawn quaternion (#515) — carried verbatim; 0,0,0,0 (a row with no rotation columns, + // The spawn quaternion — carried verbatim; 0,0,0,0 (a row with no rotation columns, // or a genuinely-identity spawn) is a valid packed value, not a parse failure, so the // fallback is `0.0` exactly like every other numeric field this loop parses. let rot0: f64 = field(&row, go::ROT0).parse().unwrap_or(0.0); @@ -4434,6 +4712,26 @@ fn build_dump_plan( let count = go_whole_map_counts.get(&map).copied().unwrap_or(0); eprintln!("filter: whole map {map} → {count} gameobject spawns"); } + + GameobjectSpawns { + go_meta, + dropped_type25, + goober_entries, + go_packed_rows, + used_go, + } +} + +/// The `game_gameobject_template` + `game_gameobject_trap` rows for the spawned gameobjects, and the +/// CHEST loot ids they reference. +fn gameobject_template_rows(spawns: &GameobjectSpawns) -> (Vec, Vec, Vec) { + let GameobjectSpawns { + go_meta, + dropped_type25, + go_packed_rows, + used_go, + .. + } = spawns; // Template rows for the GOs actually spawned, split by type (`go_template_row`) — a SQL INSERT must // name EVERY column (#[default] is NOT applied on INSERT, only on migration — slice-5 data-loss // lesson). Also builds the per-type coverage histogram + the CHEST lootId set (widened to EVERY @@ -4508,6 +4806,16 @@ fn build_dump_plan( ); } + (go_template_rows, go_trap_rows, chest_loot_ids_used) +} + +/// The `game_trainer_spell` rows for the scope's trainers: every cmangos offering, plus the +/// synthesized profession learn-rows. +fn build_trainer_spell_rows( + dump: &str, + entries: &std::collections::HashSet, + profession_tier_values: &Option>, +) -> Vec { // Trainer spell lists: npc_trainer rows for the SPAWNED trainer creatures → game_trainer_spell // (cmangos npc_trainer is all DIRECT entries — no template indirection). Lights up the trainer system. // Two kinds of row are emitted: @@ -4583,64 +4891,17 @@ fn build_dump_plan( trainer_rows.len(), trainer_lines.len(), ); - // Quests: map quest_template + the giver-relation tables (creature AND gameobject) for quests given - // by an in-slice giver. Built BEFORE the item ETL so the reward item entries can be folded into the - // item-template load (a turn-in's reward grant joins game_item_template). - // Obtainability gate (ZERO-STUCK-QUESTS): the set of items that drop from in-slice creatures, so - // build_quests only emits a COLLECT objective for a ReqItem that is obtainable (drops OR is the - // quest's SrcItem) — else the quest auto-completes instead of soft-locking. - // 042: obtainable = creature drops ∪ in-slice GO (chest) loot ∪ in-slice vendor stock. The old - // creature-drops-only gate skipped GO/vendor-sourced ReqItems entirely → an EMPTY objective set → - // quest_is_complete() == true on accept → free turn-in reward (q3904 grapes / q5545 wood via GO - // loot, q3861 kodo feed via vendor — all three obtainable in-slice now, verified live). - let mut obtainable_items = creature_drop_item_set(dump, &creature_loot_ids); - obtainable_items.extend(gameobject_loot_item_set(dump, &used_go)); - obtainable_items.extend(vendor_stock_item_set(dump, &entries)); - let quests = build_quests(dump, &entries, &used_go, &goober_entries, &obtainable_items); - eprintln!( - "mapped: {} quests, {} text, {} objectives, {} cast objectives, {} reward items, {} choice rewards, \ - {} creature giver relations, {} gameobject giver relations, {} chained (next_quest_id>0) [V], \ - {} timed (limit_time>0) [V]", - quests.templates.len(), quests.texts.len(), quests.objectives.len(), - quests.cast_objectives.len(), quests.reward_items.len(), quests.reward_choices.len(), quests.relations.len(), - quests.go_relations.len(), quests.chained_count, quests.timed_count, - ); - - // P4: real item templates + creature loot for the imported creatures (clear+reload, like above). - // game_item_template is wiped+reloaded with the loot-referenced set ∪ the starter loadout ∪ quest - // reward items ∪ quest COLLECT (ReqItem) items (so the login grant keeps a real weapon, turn-ins can - // deliver, AND every collect-quest item has a template); game_creature_loot is fully importer-owned. - let extra_item_entries: std::collections::HashSet = quests - .reward_item_entries - .iter() - .chain(quests.req_item_entries.iter()) - .copied() - .collect(); - let (item_rows, loot_rows, vendor_rows) = - build_items_and_loot(dump, &creature_loot_ids, &entries, &extra_item_entries); - eprintln!( - "mapped: {} item_templates, {} creature_loot rows, {} npc_vendor rows ({} creatures with loot)", - item_rows.len(), loot_rows.len(), vendor_rows.len(), creature_loot_ids.len() - ); - if family_active(args, "items") { - stmts.push("DELETE FROM game_item_template WHERE entry > 0".into()); - stmts.push("DELETE FROM game_creature_loot WHERE id > 0".into()); - stmts.push("DELETE FROM game_npc_vendor WHERE id > 0".into()); - push_insert(&mut stmts, "game_item_template", "entry,class,subclass,name,display_id,quality,inventory_type,item_level,required_level,max_durability,buy_price,sell_price,max_stack,damage_min,damage_max,delay_ms,stat_strength,stat_agility,stat_stamina,stat_intellect,stat_spirit,stat_crit,stat_hit,stat_armor,block_value,restores_power,spellid_1,spelltrigger_1,spellid_2,spelltrigger_2,container_slots,sheath,bonding,holy_res,fire_res,nature_res,frost_res,shadow_res,arcane_res,spellid_3,spelltrigger_3,spellid_4,spelltrigger_4,spellid_5,spelltrigger_5,required_skill,required_skill_rank,required_reputation_faction,required_reputation_rank,max_count,item_flags,page_text,start_quest,bag_family,buy_count,food_type,allowed_class,allowed_race", &item_rows); - push_insert( - &mut stmts, - "game_creature_loot", - "id,creature_entry,item_entry,chance_bp,count,group_id,quest_only", - &loot_rows, - ); - push_insert( - &mut stmts, - "game_npc_vendor", - "id,creature_entry,item_entry,slot,max_count", - &vendor_rows, - ); - } + trainer_rows +} +/// The pickpocket / skinning / gameobject(chest) / fishing loot rows, all off one parse of the +/// shared reference-pool table. +fn build_loot_family_rows( + dump: &str, + creature_pickpocket_ids: &std::collections::HashMap, + creature_skin_ids: &std::collections::HashMap, + chest_loot_ids_used: &[u32], +) -> LootFamilyRows { // Loot-family completeness (work-item 210): pickpocket / skinning / gameobject(chest) / fishing. // `refs` (the reference_loot_template pool map) is parsed ONCE here and shared by all four — // `parse_creature_drops` above already parsed its own copy for the creature family; a second parse @@ -4691,7 +4952,7 @@ fn build_dump_plan( // above). Scoped to the lootIds actually used by CHESTs spawned in this box (`chest_loot_ids_used`) // — GATHER nodes never touch this table (they stay on the hardcoded GATHER_NODES grant). let drops_by_goloot = parse_loot_family(dump, "gameobject_loot_template", &refs); - let mut go_loot_ids_used: Vec = chest_loot_ids_used.clone(); + let mut go_loot_ids_used: Vec = chest_loot_ids_used.to_vec(); go_loot_ids_used.sort_unstable(); go_loot_ids_used.dedup(); let mut gameobject_loot_rows: Vec = Vec::new(); @@ -4743,42 +5004,92 @@ fn build_dump_plan( fishing_rows.len(), drops_by_zone.len(), ); + + LootFamilyRows { + pickpocket_rows, + skinning_rows, + gameobject_loot_rows, + fishing_rows, + } +} + +/// The creature / item / loot / gossip clear+reload blocks, each behind its own family gate. +fn push_world_content_statements(args: &Args, content: MappedContent, stmts: &mut Vec) { + let MappedContent { + templates, + waypoints, + item_rows, + loot_rows, + vendor_rows, + pickpocket_rows, + skinning_rows, + gameobject_loot_rows, + fishing_rows, + gossip, + .. + } = content; + if family_active(args, "creatures") { + stmts.push("DELETE FROM game_creature_waypoint WHERE id > 0".into()); + stmts.push("DELETE FROM game_creature_template WHERE entry > 0".into()); + push_insert(stmts, "game_creature_template", "entry,name,subname,display_id,level,health,faction_template,npc_flags,unit_flags,creature_type,creature_family,type_flags,rank,scale,base_attack_time_ms,money_min,money_max,max_level,max_level_health,aggro_range,damage_min,damage_max,armor,pickpocket_loot_id,skin_loot_id,trainer_type,trainer_class", &templates); + push_insert( + stmts, + "game_creature_waypoint", + "id,creature_guid,x,y,z", + &waypoints, + ); + } + + if family_active(args, "items") { + stmts.push("DELETE FROM game_item_template WHERE entry > 0".into()); + stmts.push("DELETE FROM game_creature_loot WHERE id > 0".into()); + stmts.push("DELETE FROM game_npc_vendor WHERE id > 0".into()); + push_insert(stmts, "game_item_template", "entry,class,subclass,name,display_id,quality,inventory_type,item_level,required_level,max_durability,buy_price,sell_price,max_stack,damage_min,damage_max,delay_ms,stat_strength,stat_agility,stat_stamina,stat_intellect,stat_spirit,stat_crit,stat_hit,stat_armor,block_value,restores_power,spellid_1,spelltrigger_1,spellid_2,spelltrigger_2,container_slots,sheath,bonding,holy_res,fire_res,nature_res,frost_res,shadow_res,arcane_res,spellid_3,spelltrigger_3,spellid_4,spelltrigger_4,spellid_5,spelltrigger_5,required_skill,required_skill_rank,required_reputation_faction,required_reputation_rank,max_count,item_flags,page_text,start_quest,bag_family,buy_count,food_type,allowed_class,allowed_race", &item_rows); + push_insert( + stmts, + "game_creature_loot", + "id,creature_entry,item_entry,chance_bp,count,group_id,quest_only", + &loot_rows, + ); + push_insert( + stmts, + "game_npc_vendor", + "id,creature_entry,item_entry,slot,max_count", + &vendor_rows, + ); + } + if family_active(args, "loot") { stmts.push("DELETE FROM game_pickpocket_loot WHERE id > 0".into()); stmts.push("DELETE FROM game_skinning_loot WHERE id > 0".into()); stmts.push("DELETE FROM game_gameobject_loot WHERE id > 0".into()); stmts.push("DELETE FROM game_fishing_loot WHERE id > 0".into()); push_insert( - &mut stmts, + stmts, "game_pickpocket_loot", "id,creature_entry,item_entry,chance_bp,count,group_id,quest_only", &pickpocket_rows, ); push_insert( - &mut stmts, + stmts, "game_skinning_loot", "id,skin_loot_id,item_entry,chance_bp,count,group_id", &skinning_rows, ); push_insert( - &mut stmts, + stmts, "game_gameobject_loot", "id,loot_id,item_entry,chance_bp,count,group_id,quest_only", &gameobject_loot_rows, ); push_insert( - &mut stmts, + stmts, "game_fishing_loot", "id,zone_id,item_entry,chance_bp,count,group_id", &fishing_rows, ); } - // Gossip menus + NPC text + menu options (work-item 217): map each in-box gossip NPC's - // creature_entry to the npc_text id shown in SMSG_GOSSIP_MESSAGE, import the resolved greeting - // strings (all 8 weighted slots, not just the first), and import its clickable menu options - // (vendor/innkeeper/trainer/plain-gossip, quest-status conditions folded). Four-table clear+reload. - let gossip = build_gossip_sql(dump, &entries); if family_active(args, "gossip") { // RESERVED RANGES (283): the ETL owns only the low id bands (it emits dense low ids); a // PACKAGE that mints its own gossip (e.g. the dynamic-events guard) uses the high bands — @@ -4791,38 +5102,52 @@ fn build_dump_plan( stmts.push("DELETE FROM game_npc_text_slot WHERE id >= 0 AND id < 50000".into()); stmts.push("DELETE FROM game_gossip_option WHERE row_id >= 0 AND row_id < 50000".into()); push_insert( - &mut stmts, + stmts, "game_gossip_menu", "entry,text_id", &gossip.menu_rows, ); push_insert( - &mut stmts, + stmts, "game_npc_text", "text_id,text", &gossip.npc_text_rows, ); push_insert( - &mut stmts, + stmts, "game_npc_text_slot", "id,text_id,slot_index,text_male,text_female,probability", &gossip.npc_text_slot_rows, ); - push_insert(&mut stmts, "game_gossip_option", "row_id,entry,option_index,icon,text,action,action_menu_id,cond_type,cond_value1,cond_value2", &gossip.option_rows); + push_insert(stmts, "game_gossip_option", "row_id,entry,option_index,icon,text,action,action_menu_id,cond_type,cond_value1,cond_value2", &gossip.option_rows); push_insert( - &mut stmts, + stmts, "game_gossip_menu_profile", "menu_id,text_id", &gossip.profile_rows, ); push_insert( - &mut stmts, + stmts, "game_gossip_menu_profile_option", "row_id,menu_id,option_index,icon,text,action,action_menu_id,cond_type,cond_value1,cond_value2", &gossip.profile_option_rows, ); } +} +/// The quest / gameobject-template / trainer clear+reload blocks, each behind its own family gate. +fn push_quest_and_gameobject_statements( + args: &Args, + content: MappedContent, + stmts: &mut Vec, +) { + let MappedContent { + quests, + go_template_rows, + go_trap_rows, + trainer_rows, + .. + } = content; // Quests: clear+reload the static quest tables (header / body text / objectives / cast objectives / // reward items / creature giver relations / gameobject giver relations). game_character_quest // (per-player progress) is born in the accept reducer, never here. @@ -4835,45 +5160,45 @@ fn build_dump_plan( stmts.push("DELETE FROM game_quest_reward_choice WHERE id > 0".into()); stmts.push("DELETE FROM game_creature_quest WHERE id > 0".into()); stmts.push("DELETE FROM game_gameobject_quest WHERE id > 0".into()); - push_insert(&mut stmts, "game_quest_template", "entry,min_level,quest_level,title,reward_money,reward_xp,prev_quest_id,required_races,required_classes,zone_or_sort,rew_rep_faction_1,rew_rep_value_1,rew_rep_faction_2,rew_rep_value_2,src_item,src_item_count,repeatable,next_quest_id,limit_time,reward_money_max_level", &quests.templates); + push_insert(stmts, "game_quest_template", "entry,min_level,quest_level,title,reward_money,reward_xp,prev_quest_id,required_races,required_classes,zone_or_sort,rew_rep_faction_1,rew_rep_value_1,rew_rep_faction_2,rew_rep_value_2,src_item,src_item_count,repeatable,next_quest_id,limit_time,reward_money_max_level", &quests.templates); push_insert( - &mut stmts, + stmts, "game_quest_text", "quest_entry,details,objectives,offer_reward_text,request_items_text", &quests.texts, ); push_insert( - &mut stmts, + stmts, "game_quest_objective", "id,quest_entry,obj_index,kind,target_entry,required_count", &quests.objectives, ); push_insert( - &mut stmts, + stmts, "game_quest_cast_objective", "id,quest_entry,obj_index,spell_id", &quests.cast_objectives, ); push_insert( - &mut stmts, + stmts, "game_quest_reward_item", "id,quest_entry,item_entry,count", &quests.reward_items, ); push_insert( - &mut stmts, + stmts, "game_quest_reward_choice", "id,quest_entry,choice_index,item_entry,count", &quests.reward_choices, ); push_insert( - &mut stmts, + stmts, "game_creature_quest", "id,creature_entry,quest_entry,role", &quests.relations, ); push_insert( - &mut stmts, + stmts, "game_gameobject_quest", "id,go_entry,quest_entry,role", &quests.go_relations, @@ -4885,9 +5210,9 @@ fn build_dump_plan( if family_active(args, "gameobjects") { stmts.push("DELETE FROM game_gameobject_trap WHERE entry > 0".into()); stmts.push("DELETE FROM game_gameobject_template WHERE entry > 0".into()); - push_insert(&mut stmts, "game_gameobject_template", "entry,type_id,display_id,name,data0,data1,gather_skill_line,respawn_secs,gather_gray,lock_id,size", &go_template_rows); + push_insert(stmts, "game_gameobject_template", "entry,type_id,display_id,name,data0,data1,gather_skill_line,respawn_secs,gather_gray,lock_id,size", &go_template_rows); push_insert( - &mut stmts, + stmts, "game_gameobject_trap", "entry,spell_id,cooldown_secs", &go_trap_rows, @@ -4897,19 +5222,32 @@ fn build_dump_plan( if family_active(args, "trainers") { stmts.push("DELETE FROM game_trainer_spell WHERE id > 0".into()); push_insert( - &mut stmts, + stmts, "game_trainer_spell", "id,trainer_entry,spell_id,cost,required_level,learn_skill_line,learn_skill_cap", &trainer_rows, ); } +} +/// The caster-mob rotation and EventAI clear+reload blocks, each behind its own family gate. +fn push_creature_behaviour_statements( + args: &Args, + content: MappedContent, + stmts: &mut Vec, +) { + let MappedContent { + creature_casts, + creature_rotation_rows, + eventai, + .. + } = content; // Caster-mob cast + rotation rows (family "casts"). The primary-key is creature_entry, so clear ALL // rows then reload the in-box set (importer-owned). if family_active(args, "casts") { stmts.push("DELETE FROM game_creature_cast WHERE creature_entry > 0".into()); push_insert( - &mut stmts, + stmts, "game_creature_cast", "creature_entry,spell_id", &creature_casts, @@ -4920,7 +5258,7 @@ fn build_dump_plan( // creature_rotation_rows assembled above. stmts.push("DELETE FROM game_creature_spell WHERE id > 0".into()); push_insert( - &mut stmts, + stmts, "game_creature_spell", "id,creature_entry,spell_id,priority,condition,condition_value", &creature_rotation_rows, @@ -4932,25 +5270,28 @@ fn build_dump_plan( stmts.push("DELETE FROM game_creature_ai_summon WHERE id > 0".into()); stmts.push("DELETE FROM game_quest_event_requirement WHERE id > 0".into()); push_insert( - &mut stmts, + stmts, "game_creature_ai_broadcast_text", "id,male_text,female_text,chat_type,language_id,emote_delay_1_ms,emote_id_1,emote_delay_2_ms,emote_id_2,emote_delay_3_ms,emote_id_3", &eventai.broadcast_rows, ); push_insert( - &mut stmts, + stmts, "game_creature_ai_summon", "id,x,y,z,orientation,lifetime_ms", &eventai.summon_rows, ); push_insert( - &mut stmts, + stmts, "game_quest_event_requirement", "id,quest_entry", &eventai.quest_event_requirement_rows(), ); } +} +/// The two global (not box-scoped) families. Returns their row counts for the provenance stamps. +fn push_global_statements(args: &Args, dump: &str, stmts: &mut Vec) -> Result<(u64, u64)> { // P3: the class/level stat curve + P1: all-(race,class) start positions + 209: graveyard-zone // links (all global, plain SQL — not box-scoped) + work-item 212's createinfo spells/actions/items // + work-item 225's areatrigger-teleport dungeon portals. @@ -5014,20 +5355,34 @@ fn build_dump_plan( stmts.extend(spell_proc_event_sql); } - // The spawn payload can hold ~2k rows for a full zone — more than one `spacetime call` string arg - // takes — so split it into batches. Batch 0 goes to `import_creature_spawns` (clears the live - // roster + threat ONCE, then loads); every later batch goes to `import_creature_spawns_append` - // (load only), so the clear happens exactly once and every batch survives. - let spawn_rows: Vec<&str> = packed_spawns.split(';').filter(|r| !r.is_empty()).collect(); - let batches: Vec = spawn_rows - .chunks(SPAWN_BATCH) - .map(|c| c.join(";")) - .collect(); - let go_batches: Vec = go_packed_rows - .chunks(SPAWN_BATCH) - .map(|c| c.join(";")) - .collect(); + Ok((globals_row_count, spellmeta_row_count)) +} +/// One provenance stamp per family that actually had its block pushed — mirroring exactly the +/// `family_active` gates the push helpers used. +fn family_stamps( + args: &Args, + content: MappedContent, + globals_row_count: u64, + spellmeta_row_count: u64, +) -> Vec<(&'static str, u64)> { + let MappedContent { + templates, + item_rows, + pickpocket_rows, + skinning_rows, + gameobject_loot_rows, + fishing_rows, + gossip, + quests, + go_template_rows, + go_trap_rows, + trainer_rows, + creature_casts, + creature_rotation_rows, + eventai, + .. + } = content; // Provenance stamps (work-item 216): one entry per family that ACTUALLY had its block pushed // above — mirrors exactly the `family_active` gates this function used, so a stamp only ever // corresponds to data this run's plan actually carries. @@ -5086,26 +5441,7 @@ fn build_dump_plan( stamps.push(("spellmeta", spellmeta_row_count)); } - let eventai_definition_count = eventai.definition_count(); - let eventai_instruction_count = eventai.instruction_count(); - let eventai_definition_batches = eventai.definition_batches; - let eventai_relay_definition_count = eventai.relay_definition_rows.len() as u64; - let eventai_relay_definition_batches = eventai.relay_definition_batches; - - Ok(DumpPlan { - stmts, - spawn_row_count: spawn_rows.len(), - go_row_count: go_packed_rows.len(), - spawn_batches: batches, - go_batches, - eventai_definition_batches, - eventai_relay_definition_batches, - eventai_definition_count, - eventai_relay_definition_count, - eventai_instruction_count, - eventai_manifest, - stamps, - }) + stamps } fn main() -> Result<()> { diff --git a/importer/src/spell.rs b/importer/src/spell.rs index 90af969de..37368c640 100644 --- a/importer/src/spell.rs +++ b/importer/src/spell.rs @@ -1101,35 +1101,8 @@ fn build_spell_rows( trainers: &[(u32, Vec)], ) -> Result<(SpellRows, Coverage, Vec)> { let allow: std::collections::HashSet = only.iter().copied().collect(); - // spell_id → its DBC-derived spell_level, captured during the header build so the trainer rows below - // get a required_level straight from Spell.dbc (the single firewall-clean source — no cmangos value). - let mut spell_levels: BTreeMap = BTreeMap::new(); - // wrapper spell_id -> its trigger RANK id (first nonzero trigger_spell). A LearnSpell wrapper's own - // spell_level is 0; its real level/cost live on the rank it teaches — used by the trainer offerings. - let mut wrapper_to_rank: BTreeMap = BTreeMap::new(); - let mut chain = open_chain(data_dir)?; - eprintln!("spells: opened MPQ chain from {}", data_dir.display()); - - let spells: DbcSpell = read_table(&mut chain)?; - let cast_times: SpellCastTimes = read_table(&mut chain)?; - let ranges: SpellRange = read_table(&mut chain)?; - let durations: SpellDuration = read_table(&mut chain)?; - let radii: SpellRadius = read_table(&mut chain)?; - eprintln!( - "spells: parsed Spell({}) + SpellCastTimes({}) + SpellRange({}) + SpellDuration({}) + SpellRadius({})", - spells.rows().len(), - cast_times.rows().len(), - ranges.rows().len(), - durations.rows().len(), - radii.rows().len(), - ); - - let mut cov = Coverage::default(); - let mut spell_rows: Vec = Vec::new(); - let mut effect_rows: Vec = Vec::new(); - let mut reagent_rows: Vec = Vec::new(); - let mut eventai_metadata_rows: Vec = Vec::new(); - let mut samples: Vec = Vec::new(); + let dbc = open_spell_tables(data_dir)?; + let mut acc = SpellAccumulator::default(); // Mount creature-template display resolution (entry → display id): the `--dbc --spells` CLI path // is DBC-only (no cmangos creature_template dump is loaded alongside it), so this is empty today — // every real classic mount spell resolves correctly regardless (see `mount_display_p0`), since its @@ -1137,7 +1110,7 @@ fn build_spell_rows( // this map later is then a one-line change here, not a rewrite of the resolution logic itself. let creature_displays: BTreeMap = BTreeMap::new(); - for s in spells.rows() { + for s in dbc.spells.rows() { let spell_id = s.id.id; if spell_id == 0 { continue; // the 0 placeholder @@ -1158,585 +1131,784 @@ fn build_spell_rows( continue; } let id = ((spell_id as u64) << 3) | slot as u64; - reagent_rows.push(format!("({id},{spell_id},{item},{count})")); + acc.reagent_rows + .push(format!("({id},{spell_id},{item},{count})")); } - // --- header (game_spell) --- - // - // ⚠ wow_dbc 0.3 vanilla `Spell` SCHEMA BUG — off-by-one field NAMES from column 21 on. - // The crate's vanilla `SpellRow` is missing the `InterruptFlags` column (real col 21), so from - // there every field is NAMED as the NEXT real column. The BYTES are read sequentially and are - // correct (read position N == real column N); only the wow_dbc field *name* on each is wrong. - // It resyncs before the effect arrays (those read correctly). So to get a logically-correct - // value we read the wow_dbc field whose READ POSITION matches the real column: - // real powerType = s.mana_cost (col 31) - // real manaCost = s.mana_cost_per_level (col 32) - // real DurationIdx = s.power_type (col 30) - // real spellLevel = s.duration.id (col 29) - // real maxLevel = s.base_level (col 27) - // real rangeIndex = s.speed (int bytes read as f32 → recover via .to_bits()) (col 36) - // real stackAmount = s.totem[0] (col 39) - // real AuraIntFlags = s.channel_interrupt_flags (col 22) - // real procFlags = s.proc_chance (col 24) - // real procChance = s.proc_charges (col 25) - // real procCharges = s.max_level (col 26) - // VERIFIED by `--only` dry-run against known values: Fireball powerType=0(mana)/30 mana/4s DoT/ - // 35yd; Battle Shout rage/10/120s; Slam rage/15/L30/instant; Sunder rage/15/L10/30s/STACK=5; - // Frost Armor (168) procFlags=0x28/procChance=100/procCharges=0; Lightning Shield (324) - // procChance=100/procCharges=3 (its real procFlags is a richer taken-hit mask, not 0x28 — every - // "taken" bit, matching its real behavior of zapping back at any attack, not just melee). - // (cast_time/cooldown/gcd/school/dispel/mechanic/attributes/effects are pre-col-21 or post-resync - // → read directly.) Rage costs are stored ×10 in BOTH the DBC and our power bar, mana ×1 in both, - // so `manaCost` imports with no scaling. - let name = s.name.en_gb.clone(); - let power_type = s.mana_cost as u8; // real PowerType: 0 mana / 1 rage / 3 energy / 255 health - let cost = s.mana_cost_per_level.max(0) as u32; // real ManaCost (rage already ×10) - let cast_time_ms = cast_times - .get(s.casting_time_index) - .map(|r| r.base.max(0) as u32) - .unwrap_or(0); - // RecoveryTime is the spell-specific cooldown; CategoryRecoveryTime is the CATEGORY cooldown - // (e.g. Hammer of Justice 853 = 60s, Divine Protection 498 = 5min). Both may carry the real - // per-spell cooldown depending on how the DBC authored the spell, so take the max of both so - // neither path is silently dropped. gcd_ms is computed below after `attributes` is known. - let cooldown_ms = s.recovery_time.max(0).max(s.category_recovery_time.max(0)) as u32; - let range_yd = ranges - .get(SpellRangeKey::new(s.speed.to_bits())) // real rangeIndex (see schema-bug note) - .map(|r| r.range_max.max(0.0) as u32) - .unwrap_or(0); - // DBC duration -1 means INFINITE (toggle auras like Devotion Aura 465, Battle Stance, etc.). - // Collapse to u32::MAX as a sentinel; cast.rs converts that to Timestamp(i64::MAX) so the - // reaper's `expires_at <= now` filter never matches (i64::MAX ≈ 9.2e18 µs >> 2026 epoch). - // Any non-negative duration value is taken as-is (milliseconds). A missing DurationIndex - // entry falls back to 0, which expires immediately (fine — those spells have no aura effect). - let duration_ms = durations - .get(SpellDurationKey::new(s.power_type.max(0) as u32)) // real DurationIndex - .map(|r| { - if r.duration == -1 { - u32::MAX - } else { - r.duration.max(0) as u32 - } - }) - .unwrap_or(0); - // Spell.dbc `school` is a Resistances.dbc INDEX (0=phys,1=holy,2=fire,3=nature,4=frost, - // 5=shadow,6=arcane), NOT a bitmask — our `school_mask` is the bitmask (1< Result { + let mut chain = open_chain(data_dir)?; + eprintln!("spells: opened MPQ chain from {}", data_dir.display()); + + let spells: DbcSpell = read_table(&mut chain)?; + let cast_times: SpellCastTimes = read_table(&mut chain)?; + let ranges: SpellRange = read_table(&mut chain)?; + let durations: SpellDuration = read_table(&mut chain)?; + let radii: SpellRadius = read_table(&mut chain)?; + eprintln!( + "spells: parsed Spell({}) + SpellCastTimes({}) + SpellRange({}) + SpellDuration({}) + SpellRadius({})", + spells.rows().len(), + cast_times.rows().len(), + ranges.rows().len(), + durations.rows().len(), + radii.rows().len(), + ); + + Ok(SpellDbc { + spells, + cast_times, + ranges, + durations, + radii, + }) +} + +/// The row sets and diagnostics one DBC sweep accumulates. +#[derive(Default)] +struct SpellAccumulator { + cov: Coverage, + spell_rows: Vec, + effect_rows: Vec, + reagent_rows: Vec, + eventai_metadata_rows: Vec, + samples: Vec, + /// spell_id → its DBC-derived spell_level, for the trainer offerings' required_level. + spell_levels: BTreeMap, + /// wrapper spell_id → the trigger RANK it teaches; the rank carries the real level and cost. + wrapper_to_rank: BTreeMap, +} + +/// The header values the effect mapping below reads back off the spell it belongs to. +struct SpellHeader { + name: String, + school_mask: u8, + is_negative: bool, + channeled: bool, +} + +/// One effect's resolved kind and magnitudes, after every curated reclassify and p0 fix-up. +struct ResolvedEffect { + kind: u8, + p0: i32, + p0_kind: u8, + base_points: i32, + die_sides: i32, + per_level: f32, + period_ms: u32, +} + +/// Derive one spell's `game_spell` header row and push it, returning the values the effect mapping +/// reads back. +fn push_spell_header_row( + s: &wow_dbc::vanilla_tables::spell::SpellRow, + dbc: &SpellDbc, + allow: &std::collections::HashSet, + acc: &mut SpellAccumulator, +) -> SpellHeader { + let SpellAccumulator { + cov, + spell_rows, + eventai_metadata_rows, + samples, + spell_levels, + .. + } = acc; + let spell_id = s.id.id; + // --- header (game_spell) --- + // + // ⚠ wow_dbc 0.3 vanilla `Spell` SCHEMA BUG — off-by-one field NAMES from column 21 on. + // The crate's vanilla `SpellRow` is missing the `InterruptFlags` column (real col 21), so from + // there every field is NAMED as the NEXT real column. The BYTES are read sequentially and are + // correct (read position N == real column N); only the wow_dbc field *name* on each is wrong. + // It resyncs before the effect arrays (those read correctly). So to get a logically-correct + // value we read the wow_dbc field whose READ POSITION matches the real column: + // real powerType = s.mana_cost (col 31) + // real manaCost = s.mana_cost_per_level (col 32) + // real DurationIdx = s.power_type (col 30) + // real spellLevel = s.duration.id (col 29) + // real maxLevel = s.base_level (col 27) + // real rangeIndex = s.speed (int bytes read as f32 → recover via .to_bits()) (col 36) + // real stackAmount = s.totem[0] (col 39) + // real AuraIntFlags = s.channel_interrupt_flags (col 22) + // real procFlags = s.proc_chance (col 24) + // real procChance = s.proc_charges (col 25) + // real procCharges = s.max_level (col 26) + // VERIFIED by `--only` dry-run against known values: Fireball powerType=0(mana)/30 mana/4s DoT/ + // 35yd; Battle Shout rage/10/120s; Slam rage/15/L30/instant; Sunder rage/15/L10/30s/STACK=5; + // Frost Armor (168) procFlags=0x28/procChance=100/procCharges=0; Lightning Shield (324) + // procChance=100/procCharges=3 (its real procFlags is a richer taken-hit mask, not 0x28 — every + // "taken" bit, matching its real behavior of zapping back at any attack, not just melee). + // (cast_time/cooldown/gcd/school/dispel/mechanic/attributes/effects are pre-col-21 or post-resync + // → read directly.) Rage costs are stored ×10 in BOTH the DBC and our power bar, mana ×1 in both, + // so `manaCost` imports with no scaling. + let name = s.name.en_gb.clone(); + let power_type = s.mana_cost as u8; // real PowerType: 0 mana / 1 rage / 3 energy / 255 health + let cost = s.mana_cost_per_level.max(0) as u32; // real ManaCost (rage already ×10) + let cast_time_ms = dbc + .cast_times + .get(s.casting_time_index) + .map(|r| r.base.max(0) as u32) + .unwrap_or(0); + // RecoveryTime is the spell-specific cooldown; CategoryRecoveryTime is the CATEGORY cooldown + // (e.g. Hammer of Justice 853 = 60s, Divine Protection 498 = 5min). Both may carry the real + // per-spell cooldown depending on how the DBC authored the spell, so take the max of both so + // neither path is silently dropped. gcd_ms is computed below after `attributes` is known. + let cooldown_ms = s.recovery_time.max(0).max(s.category_recovery_time.max(0)) as u32; + let range_yd = dbc + .ranges + .get(SpellRangeKey::new(s.speed.to_bits())) // real rangeIndex (see schema-bug note) + .map(|r| r.range_max.max(0.0) as u32) + .unwrap_or(0); + // DBC duration -1 means INFINITE (toggle auras like Devotion Aura 465, Battle Stance, etc.). + // Collapse to u32::MAX as a sentinel; cast.rs converts that to Timestamp(i64::MAX) so the + // reaper's `expires_at <= now` filter never matches (i64::MAX ≈ 9.2e18 µs >> 2026 epoch). + // Any non-negative duration value is taken as-is (milliseconds). A missing DurationIndex + // entry falls back to 0, which expires immediately (fine — those spells have no aura effect). + let duration_ms = dbc + .durations + .get(SpellDurationKey::new(s.power_type.max(0) as u32)) // real DurationIndex + .map(|r| { + if r.duration == -1 { + u32::MAX + } else { + r.duration.max(0) as u32 + } + }) + .unwrap_or(0); + // Spell.dbc `school` is a Resistances.dbc INDEX (0=phys,1=holy,2=fire,3=nature,4=frost, + // 5=shadow,6=arcane), NOT a bitmask — our `school_mask` is the bitmask (1<(), )); + } + + SpellHeader { + name, + school_mask, + is_negative, + channeled, + } +} + +/// Whether a spell reads as a debuff. Vanilla has no buff/debuff flag, so derive it from the +/// effects: damage, a DoT, CC, or a stat-reducing modifier aura on a non-self target. +fn spell_is_negative(s: &wow_dbc::vanilla_tables::spell::SpellRow) -> bool { + // Polarity: ATTR bit PASSIVE-or-not isn't a buff/debuff flag; vanilla marks debuffs with + // AttributesEx? Negative/CANT_CANCEL flags that we don't model — derive heuristically from the + // FIRST effect's target/aura (a CC/damage-on-enemy effect ⇒ negative). Refined below per-effect. + let mut is_negative = false; + for i in 0..3 { + let eff = s.effect[i]; + if eff == 0 { + continue; + } + let aura = s.effect_aura[i]; + // damage / DoT / CC / a debuff-shaped aura on a non-self target ⇒ negative + if matches!(eff, 2 | 17 | 58 | 121 | 31) { + is_negative = true; } + if matches!( + aura, + AuraMod::PeriodicDamage + | AuraMod::PeriodicLeech + | AuraMod::ModStun + | AuraMod::ModRoot + | AuraMod::ModFear + | AuraMod::ModConfuse + | AuraMod::ModDecreaseSpeed + | AuraMod::ModSilence + | AuraMod::ModDamageTaken + ) { + is_negative = true; + } + // A modifier aura that REDUCES a stat / resistance / combat field (negative base points) is a + // DEBUFF — e.g. Sunder Armor (−armor via ModResistance), Demoralizing Shout (−AP via + // ModAttackPower). The variant alone can't tell buff from debuff (the SAME AuraMod does both; + // the sign of the magnitude decides), so the earlier variant-only list misses these and they'd + // wrongly resolve onto an ALLY (see `is_reducing_modifier_aura`). + if is_reducing_modifier_aura(eff, aura, s.effect_base_points[i]) { + is_negative = true; + } + } + is_negative +} - // --- effects (game_spell_effect) --- - // Track which effect_index slots the DBC populates, so a SYNTHETIC effect (an ADDED A_CONTROL / - // A_MOD_SPEED) can take the first FREE slot — keeping the deterministic id `(spell_id<<2)|index` - // unique and within the 2-bit (0..3) effect-index space. - let mut used_slots = [false; 4]; - // `i` is the DBC EFFECT INDEX, not merely a position in `used_slots`: it addresses four - // parallel DBC arrays (`effect`, `effect_aura`, `effect_mechanic`, `effect_item_type`) AND - // is packed into the deterministic row id `(spell_id << 2) | index`. Iterating one of those - // arrays instead (what `clippy::needless_range_loop` asks for) would hide that. - #[allow(clippy::needless_range_loop)] - for i in 0..3 { - let effect_id = s.effect[i]; - if effect_id == 0 { - continue; // sparse effect slots are common - } - used_slots[i] = true; - let effect_index = i as u8; - let aura = s.effect_aura[i]; - - let (kind, (p0, p0_kind)) = if is_aura_effect(effect_id) || aura != AuraMod::None { - let k = aura_mod_to_kind(aura); - if k == E_SCRIPTED { - *cov.unmapped_aura.entry(format!("{aura:?}")).or_default() += 1; - } - (k, resolve_aura_params(k, aura, s.effect_misc_value[i])) - } else { - let k = instant_effect_to_kind(effect_id); - if k == E_SCRIPTED { - *cov.unmapped_effect.entry(effect_id).or_default() += 1; - } +/// Derive and push one spell's `game_spell_effect` rows, including the synthetic additions that +/// cover CC/speed/seal data the DBC lacks. +fn push_spell_effect_rows( + s: &wow_dbc::vanilla_tables::spell::SpellRow, + header: &SpellHeader, + dbc: &SpellDbc, + creature_displays: &BTreeMap, + allow: &std::collections::HashSet, + acc: &mut SpellAccumulator, +) { + let SpellAccumulator { + cov, + effect_rows, + samples, + wrapper_to_rank, + .. + } = acc; + let spell_id = s.id.id; + let name = header.name.as_str(); + // --- effects (game_spell_effect) --- + // Track which effect_index slots the DBC populates, so a SYNTHETIC effect (an ADDED A_CONTROL / + // A_MOD_SPEED) can take the first FREE slot — keeping the deterministic id `(spell_id<<2)|index` + // unique and within the 2-bit (0..3) effect-index space. + let mut used_slots = [false; 4]; + // `i` is the DBC EFFECT INDEX, not merely a position in `used_slots`: it addresses four + // parallel DBC arrays (`effect`, `effect_aura`, `effect_mechanic`, `effect_item_type`) AND + // is packed into the deterministic row id `(spell_id << 2) | index`. Iterating one of those + // arrays instead (what `clippy::needless_range_loop` asks for) would hide that. + #[allow(clippy::needless_range_loop)] + for i in 0..3 { + let effect_id = s.effect[i]; + if effect_id == 0 { + continue; // sparse effect slots are common + } + used_slots[i] = true; + let effect_index = i as u8; + let ResolvedEffect { + kind, + p0, + p0_kind, + base_points, + die_sides, + per_level, + period_ms, + } = resolve_effect_kind(s, i, header, creature_displays, cov); + let target = resolve_effect_target(s, i, kind, header); + // Evocation (12051): a channeled 8s self-buff that restores a PERCENT of max mana every 2s + // (~60% over the channel). Its DBC effects are inert markers (a +1500% ModPowerRegenPercent → + // A_FLAG, and a second no-op) that restore no real number. Reclassify the FIRST effect + // (effect_index 1) to a GENERIC A_PERIODIC_ENERGIZE self-tick: period 2000ms, amount 15 (a + // PERCENT — p0_kind P_PCT_MAX_POWER makes aura_apply convert it to an absolute per-tick off the + // caster's max mana), MANA power type (p0=0), self-targeted. The header's CHANNELED flag + // (is_channeled by name) holds the caster 8s; break_channel tears this aura down on move/cast/CC + // (the widened periodic-energize filter). The second no-op effect stays inert. Keyed on name + + // effect index, never engine code. (Mirrors the Consecration/Human-Spirit by-name effect fixes.) + let (kind, period_ms, base_points, target, p0, p0_kind) = + if name == "Evocation" && effect_index == 1 { ( - k, - resolve_instant_params(k, s.effect_misc_value[i], s.effect_item_type[i]), + A_PERIODIC_ENERGIZE, + 2000u32, + 15i32, + T_SELF, + 0i32, + P_PCT_MAX_POWER, ) - }; - // Curated correction (Spell.sql analog): reclassify the known script-effect-as-generic spells. - let kind = correct_script_effect_kind(&name, kind); - - // DISMOUNT reclassify (data-driven, never by name/id): a raw DISPEL_MECHANIC effect whose - // misc value names the mount mechanic becomes E_DISMOUNT. Runs on the RAW effect id (not - // the resolved kind), so it fires regardless of what instant_effect_to_kind mapped 108 to. - let kind = dismount_effect_kind(effect_id, s.effect_misc_value[i]).unwrap_or(kind); - - // STANCE p0 remap: a reclassified E_SET_STANCE effect arrives with p0 = the vanilla form id - // (from the ModShapeshift misc value). Remap it to our 0-based stance id via `form_to_stance` - // (Battle 17→0 / Defensive 18→1 / Berserker 19→2 / Bear 5→3 / Cat 1→4 / Dire Bear 8→5) so the - // cast handler writes the right WorldEntity.stance. Mirrors the COMBAT_DODGE ×100 base-point - // rescale below — a kind-keyed p0 fix-up after the reclassify, never a spell id. Non-stance - // effects keep their resolved p0. - let p0 = stance_p0(kind, p0); - - // MOUNT DISPLAY resolution: an A_MOUNTED effect's p0 already defaults to the raw misc value - // (resolve_aura_params) — resolve the rarer creature-template indirection here, mirroring - // the stance/CREATE_ITEM kind-keyed p0 fix-ups above. - let p0 = mount_display_p0(kind, p0, &creature_displays); - - // PROC_DAMAGE school: an A_PROC_DAMAGE effect deals its frozen amount as damage of the - // proc spell's own school — mirrors the mount/stance p0 fix-ups above. - let (p0, p0_kind) = proc_damage_school_p0(kind, p0, p0_kind, school_mask); - - // CREATE_ITEM p0 injection (Healthstone): the by-name reclassify above made these E_CREATE_ITEM, - // but their DBC effect_item_type is 0 (the mangos script hardcodes the item), so the resolved p0 - // is 0 (and p0_kind is P_NONE, since the kind was E_SCRIPTED when resolve_instant_params ran). - // Inject the per-rank Healthstone item template entry + P_ITEM_ENTRY here — the same kind-keyed, - // name-keyed p0 fix-up as the E_SET_STANCE remap above, never disturbing a real effect_item_type - // p0 (the arm only fires for these names, whose native p0 is already 0). count=1 rides base_points. - // The module's E_CREATE_ITEM handler reads only p0; the p0_kind set is for data parity with - // natively-mapped Conjure Water/Food (which carry P_ITEM_ENTRY). - let (p0, p0_kind) = if kind == E_CREATE_ITEM { - let item = match name.as_str() { - "Create Healthstone (Minor)" => Some(5512), - "Create Healthstone (Lesser)" => Some(5511), - "Create Healthstone" => Some(5509), - "Create Healthstone (Greater)" => Some(5510), - "Create Healthstone (Major)" => Some(9421), - _ => None, // a native CreateItem (Conjure Water/Food) keeps its effect_item_type (p0, p0_kind) - }; - match item { - Some(entry) => (entry, P_ITEM_ENTRY), - None => (p0, p0_kind), - } } else { - (p0, p0_kind) + (kind, period_ms, base_points, target, p0, p0_kind) }; + let radius_yd = if s.effect_radius[i] > 0 { + dbc.radii + .get(SpellRadiusKey::new(s.effect_radius[i])) + .map(|r| r.radius) + .unwrap_or(0.0) + } else { + 0.0 + }; + let chain_targets = s.effect_chain_target[i].clamp(0, 255) as u8; + let trigger_spell = s.effect_trigger_spell[i]; + // Record wrapper -> rank: a LearnSpell wrapper's first nonzero trigger is the castable rank it + // teaches (resolves the wrapper's trainer-offering level/cost to the rank, below). + // `is_wrapper_rank_trigger` excludes every OTHER kind that rides the same `trigger_spell` + // column for a non-rank payload (a channel's missile, a Proc's trigger, a plain instant + // trigger) — see its own doc comment. This list must stay in lockstep with + // `resolve_learn_target` (module/src/trainer.rs). + if trigger_spell != 0 && is_wrapper_rank_trigger(kind) { + wrapper_to_rank.entry(spell_id).or_insert(trigger_spell); + } + let effect_mechanic = s.effect_mechanic[i] as u8; + let p1 = if kind == E_POWER_BURN { + power_burn_ratio_bp(s.effect_multiple_values[i]) + } else if kind == A_SPELLMOD_FLAT || kind == A_SPELLMOD_PCT { + // 264: the affected-spell FAMILY MASK (DBC EffectItemType) — matched at fold time + // against the cast header's family_flags. + s.effect_item_type[i] + } else { + power_word_shield_p1_override(spell_id, &name, kind, 0i32) + }; + let script_id = 0u32; + // [093] data-driven "this energize enters/holds combat": set on Bloodrage (cast 2687 + trickle + // 29131, BOTH named "Bloodrage") so the E_ENERGIZE / A_PERIODIC_ENERGIZE arms read the flag, not + // a spell id. Any energize can opt in by adding the name. + let enters_combat = name == "Bloodrage"; - let base_points = dbc_flat_amount(s.effect_base_points[i]); // DBC +1 convention (mounted-speed 59/99 → 60/100 falls out of this for free) - // Avoidance-chance combat fields are basis-points in our engine but PERCENT in the DBC, so - // scale ×100 (Evasion's +50% dodge → +5000 bp). COMBAT_THREAT is a signed percent in both - // (the threat fold divides by 100), so it is NOT scaled. - let base_points = if kind == A_MOD_COMBAT && p0 == COMBAT_DODGE { - base_points * 100 - } else { - base_points - }; - let die_sides = s.effect_die_sides[i]; - let per_level = s.effect_real_points_per_level[i]; - // EffectAmplitude is an INTEGER ms in the real DBC, but wow_dbc 0.3 vanilla mis-declares it as - // f32 — so a value like 3000 arrives as the denormal 4.204e-42 and `as u32` would truncate to - // 0 (silently making EVERY imported DoT/HoT never tick). Recover the integer via `.to_bits()`, - // the SAME float-misdeclaration workaround the importer already uses for rangeIndex. Without - // this, Garrote's bleed (and Rend/SW:Pain/Corruption/Curse of Agony) sit dormant and expire. - let period_ms = s.effect_amplitude[i].to_bits(); // amplitude is an INTEGER ms misdeclared as f32 by wow_dbc - // ModRegen (Demon Skin/Armor's health-per-5, work-item 024) is force-ticked every 5000ms by - // vanilla regardless of the DBC's own EffectAmplitude (a behaviour the reference cores show too) — apply that - // override here so the reclassified A_PERIODIC_HEAL effect actually schedules a tick even if - // Spell.dbc carries 0/garbage amplitude for this aura kind. - let period_ms = if kind == A_PERIODIC_HEAL && aura == AuraMod::ModRegen { - 5000 - } else { - period_ms - }; - // CHANNEL reclassify: on a channeled spell, the periodic-trigger effect (a PeriodicTriggerSpell / - // raw TriggerSpell mapped to E_TRIGGER, with a tick period) IS the channel tick — reclassify it to - // A_PERIODIC_TRIGGER so `tick_auras` fires its `trigger_spell` (the missile) each period at the - // channel target. Gated on the channeled header bit + a real period, so a non-channel periodic - // trigger (a proc) is untouched. Keyed on the kind + period, never a spell id; the channel target - // + the trigger spell id are frozen onto the aura at cast (aura_apply). [import] - let kind = if channeled && kind == E_TRIGGER && period_ms > 0 { - A_PERIODIC_TRIGGER - } else { - kind - }; - // GROUND-AoE (118): a ground-persistent A_PERIODIC_DAMAGE is a FIXED-POSITION area, not a unit - // DoT. The DBC encodes it as A_PERIODIC_DAMAGE with a dynobj/self target that resolves WRONG — - // Consecration → T_SELF would DoT the paladin himself. Reclassify BY NAME to E_PERSISTENT_AREA - // (the Charge/Blink name-rescue precedent) so it spawns a game_ground_area whose own - // tick_ground_areas damages hostiles inside. Consecration is caster-anchored; Flamestrike (262) - // is the first CLICKED-GROUND one — the 118 phase-2 dest plumbing (6067df1) anchors the area at - // the click when the cast carries a DEST_LOCATION block, so the same kind serves both. - // Blizzard/Rain of Fire remain un-rescued (channeled patches — their channel/tick interplay is - // its own follow-up; leaving them A_PERIODIC_DAMAGE keeps them out of the curated kit). - let kind = if kind == A_PERIODIC_DAMAGE - && matches!(name.as_str(), "Consecration" | "Flamestrike") - { - E_PERSISTENT_AREA - } else { - kind - }; - // The Human Spirit: wow_dbc mis-decodes this racial's effect as a FLAT all-stat ModStat, but - // Classic's actual effect is "Mod Stat - %" = +5% SPIRIT (verified vs - // wowhead.com/classic/spell=20598). Force the percent kind + the Spirit stat by name; the - // decoded base_points (5) already carries the 5%. The A_MOD_STAT_PCT recompute fold then makes - // Spirit = round(base * 1.05), Spirit-only. [104] - let (kind, p0, p0_kind) = if name == "The Human Spirit" { - (A_MOD_STAT_PCT, 4, P_STAT_ID) // 4 = Spirit (UNIT_FIELD_STAT4) - } else { - (kind, p0, p0_kind) - }; - let target = resolve_target(s.implicit_target_a[i], is_negative); - // Charge/Judgement/Pick Pocket are inherently ENEMY-targeted; Resurrection is inherently - // ALLY-targeted (a dead friend) — the raw DBC implicit target reads wrong for all of these. - // E_TAUNT joined 2026-07-19 (266): Taunt/Growl carry implicit target 6|25 whose polarity - // fallback read ALLY (they're not is_negative), so the faction gate refused every yank. - let target = match kind { - E_CHARGE | E_JUDGEMENT | E_PICKPOCKET | E_INTERRUPT | E_NEXT_SWING | E_TAUNT => { - T_TARGET_ENEMY - } - E_RESURRECT => T_TARGET_ALLY, - // Feint's threat drop acts on the CASTER as source — force T_SELF so it self-targets - // (the handler reads caster_guid) and the faction gate never trips (self-cast bypass). - E_REDUCE_THREAT => T_SELF, - // Blink (116): a self-cast forward teleport — the handler reads caster_guid only and - // ignores any resolved target, so force T_SELF (fires once, bypasses the faction gate). - E_BLINK => T_SELF, - // Ground-AoE (118): anchor at the CASTER (Consecration is caster-centered) — force T_SELF so - // select_targets yields the caster once (the handler stamps the area at that position) and the - // faction gate is bypassed. A clicked-ground variant anchors at the dest coords instead. - E_PERSISTENT_AREA => T_SELF, - // Summon (Summon Imp): the pet is summoned at the CASTER — the handler reads caster_guid and - // ignores the resolved target. Force T_SELF so `select_targets` yields the caster (the - // summon fires exactly once) AND the faction gate is bypassed (a self-cast imposes no - // faction constraint), so casting it while an enemy is selected still summons the pet. - E_SUMMON_PET => T_SELF, - E_DUEL => T_TARGET_ANY, - E_TAME_CREATURE => T_TARGET_ENEMY, - _ => target, - }; - // Slice and Dice (a combo FINISHER) is cast AT the enemy you built combo on (to read + spend - // it); its inert marker effect reads ally-typed in the DBC, which makes the faction gate reject - // the enemy cast. Force the marker enemy-targeted — the self-haste effect (T_SELF) is untouched. - let target = if name == "Slice and Dice" && target == T_TARGET_ALLY { - T_TARGET_ENEMY - } else { - target - }; - // Mind Soothe (453, reduces a hostile creature's aggro radius) and Disarm (676, strips the - // enemy's weapon) are ENEMY debuffs, but their DBC implicit target reads ally/self-typed - // (Mind Soothe → T_SELF/T_TARGET_ALLY; Disarm imports as target=2 = T_TARGET_ALLY), so the - // faction gate would refuse the hostile cast + `select_targets` wouldn't reach the foe. Force - // both onto the enemy. Keyed on name (the Slice and Dice precedent above). - let target = if matches!(name.as_str(), "Mind Soothe" | "Disarm") { - T_TARGET_ENEMY - } else { - target - }; - // Thunder Clap / Frost Nova are enemy PBAoEs (negative=true) but their DBC implicit - // target reads as a friendly-party code -> mapped T_TARGET_ALLY, so the AoE fan-out - // never fires and the faction gate refuses casting them at a hostile. Force all - // effects to T_AREA_ENEMY so they splash nearby hostiles. (Frost Nova: both effects - // (E_DAMAGE + A_CONTROL M_ROOT) carry target=2 and radius=10 in the DBC; the - // PBAoE fan-out + root already exist in the engine — this is purely a data fix.) - let target = if name == "Thunder Clap" || name == "Frost Nova" { - T_AREA_ENEMY - } else { - target - }; - // Flamestrike (262): the INITIAL-impact nuke (its E_DAMAGE effect) fans out around the - // CLICK — the 118 phase-2 select_targets anchors an area target on the cast's dest when - // one is present. Scoped to E_DAMAGE only: forcing all effects (the Thunder Clap shape) - // would drag the PATCH effect to T_AREA_ENEMY and spawn one ground area per hostile. - let target = if name == "Flamestrike" && kind == E_DAMAGE { - T_AREA_ENEMY - } else { - target - }; - // Battle Shout is a party PBAoE buff (EFFECT_APPLY_AREA_AURA_PARTY in the DBC, - // 30yd radius) but its implicit_target_a=20 (TARGET_UNIT_PARTY_CASTER) maps to - // T_TARGET_ALLY (single ally), so only one party member is buffed in a group. - // Force T_AREA_ALLY so the fan-out engine splashes all nearby allies at the - // DBC-imported radius (30yd). Mirrors the Thunder Clap precedent above. - let target = if name == "Battle Shout" { - T_AREA_ALLY - } else { - target - }; - // Arcane Intellect (1459) imports with implicit_target_a=0 → T_SELF, but it is - // a friendly single-target buff (targets yourself OR an ally). Force T_TARGET_ALLY - // so the faction gate allows casting on a friendly target; the engine falls back to - // the caster when no friendly target is selected (same behaviour as Resurrection). - // Same DBC-collapse trap hits the paladin/priest friendly single-target kit — Holy - // Light (635/639), Lay on Hands (633), Blessing of Might (19740) and Purify (1152) all - // import with implicit_target_a=0 (see work-item 007, archived), so they share this override. - let target = friendly_self_or_ally_target_override(&name, target); - // CHANNEL self-marker: a channeled spell carries an inert A_FLAG "you are channeling" marker - // (Arcane Missiles eff2) that the DBC reads as ALLY-targeted — but the channel is cast AT an - // ENEMY, so an ally-typed effect would make the faction gate REJECT the enemy cast (the same - // trap fixed for Slice and Dice / Thunder Clap). Force the marker to T_SELF: it's a caster-side - // flag, so self-targeting it both lands it correctly AND removes it from the faction gate's - // hits_ally/hits_enemy scan (a self-cast effect imposes no faction constraint). Generic over the - // channeled bit + the A_FLAG kind, never a spell id; the channel's A_PERIODIC_TRIGGER tick effect - // is already T_SELF, so only the stray marker is corrected. - let target = if channeled && kind == A_FLAG { - T_SELF - } else { - target - }; - // Evocation (12051): a channeled 8s self-buff that restores a PERCENT of max mana every 2s - // (~60% over the channel). Its DBC effects are inert markers (a +1500% ModPowerRegenPercent → - // A_FLAG, and a second no-op) that restore no real number. Reclassify the FIRST effect - // (effect_index 1) to a GENERIC A_PERIODIC_ENERGIZE self-tick: period 2000ms, amount 15 (a - // PERCENT — p0_kind P_PCT_MAX_POWER makes aura_apply convert it to an absolute per-tick off the - // caster's max mana), MANA power type (p0=0), self-targeted. The header's CHANNELED flag - // (is_channeled by name) holds the caster 8s; break_channel tears this aura down on move/cast/CC - // (the widened periodic-energize filter). The second no-op effect stays inert. Keyed on name + - // effect index, never engine code. (Mirrors the Consecration/Human-Spirit by-name effect fixes.) - let (kind, period_ms, base_points, target, p0, p0_kind) = - if name == "Evocation" && effect_index == 1 { - ( - A_PERIODIC_ENERGIZE, - 2000u32, - 15i32, - T_SELF, - 0i32, - P_PCT_MAX_POWER, - ) - } else { - (kind, period_ms, base_points, target, p0, p0_kind) - }; - let radius_yd = if s.effect_radius[i] > 0 { - radii - .get(SpellRadiusKey::new(s.effect_radius[i])) - .map(|r| r.radius) - .unwrap_or(0.0) - } else { - 0.0 - }; - let chain_targets = s.effect_chain_target[i].clamp(0, 255) as u8; - let trigger_spell = s.effect_trigger_spell[i]; - // Record wrapper -> rank: a LearnSpell wrapper's first nonzero trigger is the castable rank it - // teaches (resolves the wrapper's trainer-offering level/cost to the rank, below). - // `is_wrapper_rank_trigger` excludes every OTHER kind that rides the same `trigger_spell` - // column for a non-rank payload (a channel's missile, a Proc's trigger, a plain instant - // trigger) — see its own doc comment. This list must stay in lockstep with - // `resolve_learn_target` (module/src/trainer.rs). - if trigger_spell != 0 && is_wrapper_rank_trigger(kind) { - wrapper_to_rank.entry(spell_id).or_insert(trigger_spell); - } - let effect_mechanic = s.effect_mechanic[i] as u8; - let p1 = if kind == E_POWER_BURN { - power_burn_ratio_bp(s.effect_multiple_values[i]) - } else if kind == A_SPELLMOD_FLAT || kind == A_SPELLMOD_PCT { - // 264: the affected-spell FAMILY MASK (DBC EffectItemType) — matched at fold time - // against the cast header's family_flags. - s.effect_item_type[i] - } else { - power_word_shield_p1_override(spell_id, &name, kind, 0i32) - }; - let script_id = 0u32; - // [093] data-driven "this energize enters/holds combat": set on Bloodrage (cast 2687 + trickle - // 29131, BOTH named "Bloodrage") so the E_ENERGIZE / A_PERIODIC_ENERGIZE arms read the flag, not - // a spell id. Any energize can opt in by adding the name. - let enters_combat = name == "Bloodrage"; - - effect_rows.push(SpellEffectRow { - spell_id, - effect_index, - kind, - base_points, - die_sides, - per_level, - period_ms, - target, - radius_yd, - chain_targets, - trigger_spell, - effect_mechanic, - p0, - p0_kind, - p1, - script_id, - enters_combat, - }); - - cov.effects += 1; - *cov.by_kind.entry(kind).or_default() += 1; - if kind == E_SCRIPTED { - cov.scripted += 1; - } else { - cov.real += 1; - } + effect_rows.push(SpellEffectRow { + spell_id, + effect_index, + kind, + base_points, + die_sides, + per_level, + period_ms, + target, + radius_yd, + chain_targets, + trigger_spell, + effect_mechanic, + p0, + p0_kind, + p1, + script_id, + enters_combat, + }); + + cov.effects += 1; + *cov.by_kind.entry(kind).or_default() += 1; + if kind == E_SCRIPTED { + cov.scripted += 1; + } else { + cov.real += 1; + } - if !allow.is_empty() { - // Allowlist mode: log EVERY effect (incl. E_SCRIPTED no-ops) so an unmapped ability is - // visible, not silently dropped — the operator decides whether it's usable as-is. - samples.push(format!( + if !allow.is_empty() { + // Allowlist mode: log EVERY effect (incl. E_SCRIPTED no-ops) so an unmapped ability is + // visible, not silently dropped — the operator decides whether it's usable as-is. + samples.push(format!( " eff{effect_index}: {kn} (0x{kind:02X}) base={base_points} die={die_sides} period_ms={period_ms} target={target} p0={p0} p0_kind={p0_kind} trigger={trigger_spell}", kn = kind_name(kind), )); - } else if kind != E_SCRIPTED && samples.len() < 8 { - // Full-import mode: keep a few human-readable samples for the dry-run (first 8 REAL effects). - samples.push(format!( + } else if kind != E_SCRIPTED && samples.len() < 8 { + // Full-import mode: keep a few human-readable samples for the dry-run (first 8 REAL effects). + samples.push(format!( " spell {spell_id} '{}' eff{effect_index}: kind=0x{kind:02X} base={base_points} period_ms={period_ms} target={target} p0={p0} p0_kind={p0_kind}", name.chars().take(28).collect::(), )); - } } + } - // --- SYNTHETIC effect ADDITIONS (the curated correction for CC/speed/seal data the DBC lacks) --- - // A synthetic A_CONTROL (Gouge) / A_MOD_SPEED (Stealth) / A_SEAL (Seal of the Crusader) is ADDED at - // the FIRST free effect_index (the lowest open slot, keeping the deterministic id - // `(spell_id<<2)|index` unique + in the 0..3 range). At most ONE synthetic per spell (Gouge/Stealth/ - // SoC are distinct ids), so a single free slot suffices. These are REAL kinds → counted toward - // coverage like any mapped effect. - let synth_slot = used_slots.iter().position(|&u| !u).unwrap_or(3) as u8; - if let Some(synth) = synthetic_control_effect(spell_id, &name, synth_slot) - .or_else(|| synthetic_stealth_slow_effect(spell_id, &name, synth_slot)) - .or_else(|| synthetic_seal_effect(spell_id, &name, synth_slot)) - { - cov.effects += 1; - cov.real += 1; - *cov.by_kind.entry(synth.kind).or_default() += 1; - if !allow.is_empty() { - samples.push(format!(" [synthetic+curated] {}", synth.sql_values())); - } - effect_rows.push(synth); + // --- SYNTHETIC effect ADDITIONS (the curated correction for CC/speed/seal data the DBC lacks) --- + // A synthetic A_CONTROL (Gouge) / A_MOD_SPEED (Stealth) / A_SEAL (Seal of the Crusader) is ADDED at + // the FIRST free effect_index (the lowest open slot, keeping the deterministic id + // `(spell_id<<2)|index` unique + in the 0..3 range). At most ONE synthetic per spell (Gouge/Stealth/ + // SoC are distinct ids), so a single free slot suffices. These are REAL kinds → counted toward + // coverage like any mapped effect. + let synth_slot = used_slots.iter().position(|&u| !u).unwrap_or(3) as u8; + if let Some(synth) = synthetic_control_effect(spell_id, &name, synth_slot) + .or_else(|| synthetic_stealth_slow_effect(spell_id, &name, synth_slot)) + .or_else(|| synthetic_seal_effect(spell_id, &name, synth_slot)) + { + cov.effects += 1; + cov.real += 1; + *cov.by_kind.entry(synth.kind).or_default() += 1; + if !allow.is_empty() { + samples.push(format!(" [synthetic+curated] {}", synth.sql_values())); } + effect_rows.push(synth); } +} +/// Resolve one DBC effect slot to our kind + magnitudes: the aura/instant mapping, then every +/// curated reclassify and p0 fix-up that rides on it. +fn resolve_effect_kind( + s: &wow_dbc::vanilla_tables::spell::SpellRow, + i: usize, + header: &SpellHeader, + creature_displays: &BTreeMap, + cov: &mut Coverage, +) -> ResolvedEffect { + let effect_id = s.effect[i]; + let aura = s.effect_aura[i]; + let name = header.name.as_str(); + let school_mask = header.school_mask; + let channeled = header.channeled; + let (kind, (p0, p0_kind)) = if is_aura_effect(effect_id) || aura != AuraMod::None { + let k = aura_mod_to_kind(aura); + if k == E_SCRIPTED { + *cov.unmapped_aura.entry(format!("{aura:?}")).or_default() += 1; + } + (k, resolve_aura_params(k, aura, s.effect_misc_value[i])) + } else { + let k = instant_effect_to_kind(effect_id); + if k == E_SCRIPTED { + *cov.unmapped_effect.entry(effect_id).or_default() += 1; + } + ( + k, + resolve_instant_params(k, s.effect_misc_value[i], s.effect_item_type[i]), + ) + }; + // Curated correction (Spell.sql analog): reclassify the known script-effect-as-generic spells. + let kind = correct_script_effect_kind(&name, kind); + + // DISMOUNT reclassify (data-driven, never by name/id): a raw DISPEL_MECHANIC effect whose + // misc value names the mount mechanic becomes E_DISMOUNT. Runs on the RAW effect id (not + // the resolved kind), so it fires regardless of what instant_effect_to_kind mapped 108 to. + let kind = dismount_effect_kind(effect_id, s.effect_misc_value[i]).unwrap_or(kind); + + // STANCE p0 remap: a reclassified E_SET_STANCE effect arrives with p0 = the vanilla form id + // (from the ModShapeshift misc value). Remap it to our 0-based stance id via `form_to_stance` + // (Battle 17→0 / Defensive 18→1 / Berserker 19→2 / Bear 5→3 / Cat 1→4 / Dire Bear 8→5) so the + // cast handler writes the right WorldEntity.stance. Mirrors the COMBAT_DODGE ×100 base-point + // rescale below — a kind-keyed p0 fix-up after the reclassify, never a spell id. Non-stance + // effects keep their resolved p0. + let p0 = stance_p0(kind, p0); + + // MOUNT DISPLAY resolution: an A_MOUNTED effect's p0 already defaults to the raw misc value + // (resolve_aura_params) — resolve the rarer creature-template indirection here, mirroring + // the stance/CREATE_ITEM kind-keyed p0 fix-ups above. + let p0 = mount_display_p0(kind, p0, &creature_displays); + + // PROC_DAMAGE school: an A_PROC_DAMAGE effect deals its frozen amount as damage of the + // proc spell's own school — mirrors the mount/stance p0 fix-ups above. + let (p0, p0_kind) = proc_damage_school_p0(kind, p0, p0_kind, school_mask); + + // CREATE_ITEM p0 injection (Healthstone): the by-name reclassify above made these E_CREATE_ITEM, + // but their DBC effect_item_type is 0 (the mangos script hardcodes the item), so the resolved p0 + // is 0 (and p0_kind is P_NONE, since the kind was E_SCRIPTED when resolve_instant_params ran). + // Inject the per-rank Healthstone item template entry + P_ITEM_ENTRY here — the same kind-keyed, + // name-keyed p0 fix-up as the E_SET_STANCE remap above, never disturbing a real effect_item_type + // p0 (the arm only fires for these names, whose native p0 is already 0). count=1 rides base_points. + // The module's E_CREATE_ITEM handler reads only p0; the p0_kind set is for data parity with + // natively-mapped Conjure Water/Food (which carry P_ITEM_ENTRY). + let (p0, p0_kind) = if kind == E_CREATE_ITEM { + let item = match name { + "Create Healthstone (Minor)" => Some(5512), + "Create Healthstone (Lesser)" => Some(5511), + "Create Healthstone" => Some(5509), + "Create Healthstone (Greater)" => Some(5510), + "Create Healthstone (Major)" => Some(9421), + _ => None, // a native CreateItem (Conjure Water/Food) keeps its effect_item_type (p0, p0_kind) + }; + match item { + Some(entry) => (entry, P_ITEM_ENTRY), + None => (p0, p0_kind), + } + } else { + (p0, p0_kind) + }; + + let base_points = dbc_flat_amount(s.effect_base_points[i]); // DBC +1 convention (mounted-speed 59/99 → 60/100 falls out of this for free) + // Avoidance-chance combat fields are basis-points in our engine but PERCENT in the DBC, so + // scale ×100 (Evasion's +50% dodge → +5000 bp). COMBAT_THREAT is a signed percent in both + // (the threat fold divides by 100), so it is NOT scaled. + let base_points = if kind == A_MOD_COMBAT && p0 == COMBAT_DODGE { + base_points * 100 + } else { + base_points + }; + let die_sides = s.effect_die_sides[i]; + let per_level = s.effect_real_points_per_level[i]; + // EffectAmplitude is an INTEGER ms in the real DBC, but wow_dbc 0.3 vanilla mis-declares it as + // f32 — so a value like 3000 arrives as the denormal 4.204e-42 and `as u32` would truncate to + // 0 (silently making EVERY imported DoT/HoT never tick). Recover the integer via `.to_bits()`, + // the SAME float-misdeclaration workaround the importer already uses for rangeIndex. Without + // this, Garrote's bleed (and Rend/SW:Pain/Corruption/Curse of Agony) sit dormant and expire. + let period_ms = s.effect_amplitude[i].to_bits(); // amplitude is an INTEGER ms misdeclared as f32 by wow_dbc + // ModRegen (Demon Skin/Armor's health-per-5, work-item 024) is force-ticked every 5000ms by + // vanilla regardless of the DBC's own EffectAmplitude (a behaviour the reference cores show too) — apply that + // override here so the reclassified A_PERIODIC_HEAL effect actually schedules a tick even if + // Spell.dbc carries 0/garbage amplitude for this aura kind. + let period_ms = if kind == A_PERIODIC_HEAL && aura == AuraMod::ModRegen { + 5000 + } else { + period_ms + }; + // CHANNEL reclassify: on a channeled spell, the periodic-trigger effect (a PeriodicTriggerSpell / + // raw TriggerSpell mapped to E_TRIGGER, with a tick period) IS the channel tick — reclassify it to + // A_PERIODIC_TRIGGER so `tick_auras` fires its `trigger_spell` (the missile) each period at the + // channel target. Gated on the channeled header bit + a real period, so a non-channel periodic + // trigger (a proc) is untouched. Keyed on the kind + period, never a spell id; the channel target + // + the trigger spell id are frozen onto the aura at cast (aura_apply). [import] + let kind = if channeled && kind == E_TRIGGER && period_ms > 0 { + A_PERIODIC_TRIGGER + } else { + kind + }; + // GROUND-AoE (118): a ground-persistent A_PERIODIC_DAMAGE is a FIXED-POSITION area, not a unit + // DoT. The DBC encodes it as A_PERIODIC_DAMAGE with a dynobj/self target that resolves WRONG — + // Consecration → T_SELF would DoT the paladin himself. Reclassify BY NAME to E_PERSISTENT_AREA + // (the Charge/Blink name-rescue precedent) so it spawns a game_ground_area whose own + // tick_ground_areas damages hostiles inside. Consecration is caster-anchored; Flamestrike (262) + // is the first CLICKED-GROUND one — the 118 phase-2 dest plumbing (6067df1) anchors the area at + // the click when the cast carries a DEST_LOCATION block, so the same kind serves both. + // Blizzard/Rain of Fire remain un-rescued (channeled patches — their channel/tick interplay is + // its own follow-up; leaving them A_PERIODIC_DAMAGE keeps them out of the curated kit). + let kind = if kind == A_PERIODIC_DAMAGE && matches!(name, "Consecration" | "Flamestrike") { + E_PERSISTENT_AREA + } else { + kind + }; + // The Human Spirit: wow_dbc mis-decodes this racial's effect as a FLAT all-stat ModStat, but + // Classic's actual effect is "Mod Stat - %" = +5% SPIRIT (verified vs + // wowhead.com/classic/spell=20598). Force the percent kind + the Spirit stat by name; the + // decoded base_points (5) already carries the 5%. The A_MOD_STAT_PCT recompute fold then makes + // Spirit = round(base * 1.05), Spirit-only. [104] + let (kind, p0, p0_kind) = if name == "The Human Spirit" { + (A_MOD_STAT_PCT, 4, P_STAT_ID) // 4 = Spirit (UNIT_FIELD_STAT4) + } else { + (kind, p0, p0_kind) + }; + + ResolvedEffect { + kind, + p0, + p0_kind, + base_points, + die_sides, + per_level, + period_ms, + } +} + +/// Resolve one effect's target: the DBC implicit target, then the curated by-name overrides that +/// correct the ones vanilla's data reads wrong. +fn resolve_effect_target( + s: &wow_dbc::vanilla_tables::spell::SpellRow, + i: usize, + kind: u8, + header: &SpellHeader, +) -> u8 { + let name = header.name.as_str(); + let is_negative = header.is_negative; + let channeled = header.channeled; + let target = resolve_target(s.implicit_target_a[i], is_negative); + // Charge/Judgement/Pick Pocket are inherently ENEMY-targeted; Resurrection is inherently + // ALLY-targeted (a dead friend) — the raw DBC implicit target reads wrong for all of these. + // E_TAUNT joined 2026-07-19 (266): Taunt/Growl carry implicit target 6|25 whose polarity + // fallback read ALLY (they're not is_negative), so the faction gate refused every yank. + let target = match kind { + E_CHARGE | E_JUDGEMENT | E_PICKPOCKET | E_INTERRUPT | E_NEXT_SWING | E_TAUNT => { + T_TARGET_ENEMY + } + E_RESURRECT => T_TARGET_ALLY, + // Feint's threat drop acts on the CASTER as source — force T_SELF so it self-targets + // (the handler reads caster_guid) and the faction gate never trips (self-cast bypass). + E_REDUCE_THREAT => T_SELF, + // Blink (116): a self-cast forward teleport — the handler reads caster_guid only and + // ignores any resolved target, so force T_SELF (fires once, bypasses the faction gate). + E_BLINK => T_SELF, + // Ground-AoE (118): anchor at the CASTER (Consecration is caster-centered) — force T_SELF so + // select_targets yields the caster once (the handler stamps the area at that position) and the + // faction gate is bypassed. A clicked-ground variant anchors at the dest coords instead. + E_PERSISTENT_AREA => T_SELF, + // Summon (Summon Imp): the pet is summoned at the CASTER — the handler reads caster_guid and + // ignores the resolved target. Force T_SELF so `select_targets` yields the caster (the + // summon fires exactly once) AND the faction gate is bypassed (a self-cast imposes no + // faction constraint), so casting it while an enemy is selected still summons the pet. + E_SUMMON_PET => T_SELF, + E_DUEL => T_TARGET_ANY, + E_TAME_CREATURE => T_TARGET_ENEMY, + _ => target, + }; + // Slice and Dice (a combo FINISHER) is cast AT the enemy you built combo on (to read + spend + // it); its inert marker effect reads ally-typed in the DBC, which makes the faction gate reject + // the enemy cast. Force the marker enemy-targeted — the self-haste effect (T_SELF) is untouched. + let target = if name == "Slice and Dice" && target == T_TARGET_ALLY { + T_TARGET_ENEMY + } else { + target + }; + // Mind Soothe (453, reduces a hostile creature's aggro radius) and Disarm (676, strips the + // enemy's weapon) are ENEMY debuffs, but their DBC implicit target reads ally/self-typed + // (Mind Soothe → T_SELF/T_TARGET_ALLY; Disarm imports as target=2 = T_TARGET_ALLY), so the + // faction gate would refuse the hostile cast + `select_targets` wouldn't reach the foe. Force + // both onto the enemy. Keyed on name (the Slice and Dice precedent above). + let target = if matches!(name, "Mind Soothe" | "Disarm") { + T_TARGET_ENEMY + } else { + target + }; + // Thunder Clap / Frost Nova are enemy PBAoEs (negative=true) but their DBC implicit + // target reads as a friendly-party code -> mapped T_TARGET_ALLY, so the AoE fan-out + // never fires and the faction gate refuses casting them at a hostile. Force all + // effects to T_AREA_ENEMY so they splash nearby hostiles. (Frost Nova: both effects + // (E_DAMAGE + A_CONTROL M_ROOT) carry target=2 and radius=10 in the DBC; the + // PBAoE fan-out + root already exist in the engine — this is purely a data fix.) + let target = if name == "Thunder Clap" || name == "Frost Nova" { + T_AREA_ENEMY + } else { + target + }; + // Flamestrike (262): the INITIAL-impact nuke (its E_DAMAGE effect) fans out around the + // CLICK — the 118 phase-2 select_targets anchors an area target on the cast's dest when + // one is present. Scoped to E_DAMAGE only: forcing all effects (the Thunder Clap shape) + // would drag the PATCH effect to T_AREA_ENEMY and spawn one ground area per hostile. + let target = if name == "Flamestrike" && kind == E_DAMAGE { + T_AREA_ENEMY + } else { + target + }; + // Battle Shout is a party PBAoE buff (EFFECT_APPLY_AREA_AURA_PARTY in the DBC, + // 30yd radius) but its implicit_target_a=20 (TARGET_UNIT_PARTY_CASTER) maps to + // T_TARGET_ALLY (single ally), so only one party member is buffed in a group. + // Force T_AREA_ALLY so the fan-out engine splashes all nearby allies at the + // DBC-imported radius (30yd). Mirrors the Thunder Clap precedent above. + let target = if name == "Battle Shout" { + T_AREA_ALLY + } else { + target + }; + // Arcane Intellect (1459) imports with implicit_target_a=0 → T_SELF, but it is + // a friendly single-target buff (targets yourself OR an ally). Force T_TARGET_ALLY + // so the faction gate allows casting on a friendly target; the engine falls back to + // the caster when no friendly target is selected (same behaviour as Resurrection). + // Same DBC-collapse trap hits the paladin/priest friendly single-target kit — Holy + // Light (635/639), Lay on Hands (633), Blessing of Might (19740) and Purify (1152) all + // import with implicit_target_a=0 (see work-item 007, archived), so they share this override. + let target = friendly_self_or_ally_target_override(&name, target); + // CHANNEL self-marker: a channeled spell carries an inert A_FLAG "you are channeling" marker + // (Arcane Missiles eff2) that the DBC reads as ALLY-targeted — but the channel is cast AT an + // ENEMY, so an ally-typed effect would make the faction gate REJECT the enemy cast (the same + // trap fixed for Slice and Dice / Thunder Clap). Force the marker to T_SELF: it's a caster-side + // flag, so self-targeting it both lands it correctly AND removes it from the faction gate's + // hits_ally/hits_enemy scan (a self-cast effect imposes no faction constraint). Generic over the + // channeled bit + the A_FLAG kind, never a spell id; the channel's A_PERIODIC_TRIGGER tick effect + // is already T_SELF, so only the stray marker is corrected. + let target = if channeled && kind == A_FLAG { + T_SELF + } else { + target + }; + + target +} + +/// The `game_trainer_spell` rows for each `--trainer =` binding. +fn trainer_offering_rows( + trainers: &[(u32, Vec)], + spell_levels: &BTreeMap, + wrapper_to_rank: &BTreeMap, +) -> Vec { // --- trainer offerings (game_trainer_spell) — FIREWALL-CLEAN --- // For each `--trainer =` binding, emit one row per offered spell. required_level comes // straight from the DBC `spell_level` captured above; cost from our own level-keyed formula @@ -1794,18 +1966,7 @@ fn build_spell_rows( ); } - Ok(( - SpellRows { - headers: spell_rows, - effects: effect_rows, - fishing: fishing_marker_rows(), - reagents: reagent_rows, - eventai_metadata: eventai_metadata_rows, - trainers: trainer_rows, - }, - cov, - samples, - )) + trainer_rows } /// Fishing (060): the E_FISH marker effect rows for the three tier ids (skill 356 — 7620/7731/7732, From 26f6258a14b76d234893e3c51e247c7a5b91c2d2 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Thu, 3 Sep 2026 19:41:06 +0200 Subject: [PATCH 2/3] refactor(module): split apply_effect and hold the line on function length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `apply_effect` dispatched 30 effect kinds in 681 lines. The dispatch shape stays: a match on `e.kind`, one arm per family, each handing off to a named applier — damage, restore, threat, reposition, inventory, character and the two that delegate to another engine path. The aura branch stays inline, because an architecture test pins `apply_effect` itself on the aura-application boundary. Every arm body moved verbatim; the 1,461 module tests pass unchanged. `clippy::too_many_lines` now warns workspace-wide so this does not grow back. Its default threshold of 100 fires on 140 pre-existing functions and would bury every real finding, so `clippy.toml` sets 200 — the size this split targeted. That leaves 43 warnings, none in the code this branch touched: 16 of them are the generated SpacetimeDB bindings, and the rest are long functions that were already there. Co-Authored-By: Claude Fable 5.1 --- Cargo.toml | 6 + clippy.toml | 5 + crates/lyracore-package-delta/Cargo.toml | 3 + crates/lyracore-shared/Cargo.toml | 3 + gateway/Cargo.toml | 3 + importer/Cargo.toml | 3 + module/Cargo.toml | 3 + module/src/spell/cast/targeting.rs | 707 ++++++++++++++--------- 8 files changed, 450 insertions(+), 283 deletions(-) create mode 100644 clippy.toml diff --git a/Cargo.toml b/Cargo.toml index ab74ae8e3..1e395c112 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,12 @@ homepage = "https://github.com/LyraCoreProject/LyraCore" # either repository — so the whole workspace is publish-gated here rather than crate by crate. publish = false +# A function this long stops fitting in one head (human or agent). The threshold in `clippy.toml` +# is set above today's worst untouched offenders, so the lint holds the line without burying the +# real findings; lower it as those shrink. +[workspace.lints.clippy] +too_many_lines = "warn" + [workspace.dependencies] lyracore-shared = { path = "crates/lyracore-shared" } lyracore-package-delta = { path = "crates/lyracore-package-delta" } diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 000000000..04a404c3f --- /dev/null +++ b/clippy.toml @@ -0,0 +1,5 @@ +# `too_many_lines` (warn, workspace-wide) holds a ceiling rather than describing today's code: the +# default 100 fires on 140 pre-existing functions and buries every real finding. 200 is the size the +# split of the four longest production functions targeted, so only untouched code exceeds it. Lower +# it as those shrink. +too-many-lines-threshold = 200 diff --git a/crates/lyracore-package-delta/Cargo.toml b/crates/lyracore-package-delta/Cargo.toml index 560e71c42..c288dee23 100644 --- a/crates/lyracore-package-delta/Cargo.toml +++ b/crates/lyracore-package-delta/Cargo.toml @@ -23,3 +23,6 @@ serde_json = "1" [[bin]] name = "lyracore-delta-check" path = "src/bin/lyracore-delta-check.rs" + +[lints] +workspace = true diff --git a/crates/lyracore-shared/Cargo.toml b/crates/lyracore-shared/Cargo.toml index 252de8ec1..96a6cfd38 100644 --- a/crates/lyracore-shared/Cargo.toml +++ b/crates/lyracore-shared/Cargo.toml @@ -12,3 +12,6 @@ description = "LyraCore: protocol-agnostic constants and helpers shared by the S # Intentionally NO external dependencies: this crate is consumed both by the wasm module # and the native gateway, so it must stay portable and trivially compilable. [dependencies] + +[lints] +workspace = true diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index 80183720b..d5b6a9402 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -71,3 +71,6 @@ dhat-heap = ["dep:dhat"] # drift tripwire. [target.'cfg(target_os = "linux")'.dev-dependencies] lyracore-module = { path = "../module" } + +[lints] +workspace = true diff --git a/importer/Cargo.toml b/importer/Cargo.toml index a97f64df7..02def9237 100644 --- a/importer/Cargo.toml +++ b/importer/Cargo.toml @@ -39,3 +39,6 @@ wow_dbc = { version = "0.3", features = ["vanilla"] } # Those types live in wow_world_base; wow_dbc 0.3 pins it to =0.2.0, so we depend on the SAME version # (same type instance) to name the variants by hand. Pure parser, ships ZERO Blizzard data. wow_world_base = { version = "=0.2.0", features = ["vanilla"] } + +[lints] +workspace = true diff --git a/module/Cargo.toml b/module/Cargo.toml index f7dac90e6..9f6e3c085 100644 --- a/module/Cargo.toml +++ b/module/Cargo.toml @@ -49,3 +49,6 @@ getrandom = "0.3" # parameter has to arrive as a JSON string literal rather than as bare text — this encodes them. # Already in the lockfile through `lyracore-package-delta`, so it adds nothing to the tree. serde_json = "1" + +[lints] +workspace = true diff --git a/module/src/spell/cast/targeting.rs b/module/src/spell/cast/targeting.rs index 6d6ba361e..ea5578934 100644 --- a/module/src/spell/cast/targeting.rs +++ b/module/src/spell/cast/targeting.rs @@ -766,19 +766,6 @@ pub(crate) fn apply_effect( // never start a proc. triggered: bool, ) -> EffectHit { - // The hit a damage arm below hands to the shared pipeline: this spell, and the proc event its - // source raises — or Triggered, raising nothing, when the whole cast was one. - let hit_of = |source: crate::combat::HitSource, crit: bool| { - crate::combat::Hit::spell( - if triggered { - crate::combat::HitSource::Triggered - } else { - source - }, - hdr.spell_id, - crit, - ) - }; if e.kind & KIND_AURA_BIT != 0 { // CC IMMUNITY: an `A_CONTROL` crowd-control effect (a stun/root/…) is REFUSED if the target is // immune to that mechanic (an active `A_IMMUNITY(mechanic)` aura). The effect simply doesn't land @@ -809,7 +796,7 @@ pub(crate) fn apply_effect( level, points, ); - // Linked-debuff APPLY (Weakened Soul, #013): the twin of the refusal gate in `resolve_cast_at` — a + // Linked-debuff APPLY (Weakened Soul): the twin of the refusal gate in `resolve_cast_at` — a // nonzero `e.p1` names a SEPARATE spell to also apply on `target_guid` right after this aura lands // (PW:Shield's absorb effect names Weakened Soul). Generic over the kind/spell id: any aura effect // can opt in via its `p1`, the engine never names one. No-op if the linked spell/effect isn't loaded @@ -837,6 +824,94 @@ pub(crate) fn apply_effect( } EffectHit::none() } + E_DAMAGE | E_WEAPON_STRIKE | E_JUDGEMENT | E_FINISHER_DAMAGE => apply_damage_effect( + ctx, + e, + hdr, + caster_guid, + target_guid, + level, + points, + triggered, + ), + E_HEAL | E_HEAL_MAX_HEALTH | E_ENERGIZE | E_CONVERT_RESOURCE | E_POWER_BURN + | E_ADD_COMBO => { + apply_restore_effect(ctx, e, hdr, caster_guid, target_guid, points, triggered) + } + E_TAUNT | E_INTERRUPT | E_REDUCE_THREAT | E_NEXT_SWING => { + apply_threat_effect(ctx, e, hdr, caster_guid, target_guid, points) + } + E_CHARGE | E_BLINK | E_RECALL_HOME | E_DISMOUNT | E_PERSISTENT_AREA => { + apply_reposition_effect(ctx, e, hdr, caster_guid, target_guid, points, dest) + } + E_CREATE_ITEM | E_PICKPOCKET => { + apply_inventory_effect(ctx, e, caster_guid, target_guid, points) + } + E_RESURRECT | E_SUMMON_PET | E_TAME_CREATURE | E_SET_STANCE => { + apply_character_effect(ctx, e, caster_guid, target_guid, points) + } + E_TRIGGER | E_SCRIPTED => apply_delegating_effect(ctx, e, caster_guid, target_guid, level), + E_DISPEL => { + // The dispelled CATEGORY: the effect's `p0` (the authored override) if set, else the + // dispelling spell's header `dispel_type`. 0 = strip every foreign aura (the baseline — + // Dispel Magic 527 has p0=0 + dispel_type=0, so it strips everything). + let category = if e.p0 != 0 { + e.p0 as u8 + } else { + hdr.dispel_type + }; + let removed = dispel_target(ctx, target_guid, category); + log::info!("dispel (cat {category}) on {target_guid} removed {removed} debuff(s)"); + EffectHit::none() + } + other => { + // Unmapped residue: a graceful no-op + log (the import safety net — casts and does + // nothing, vs doing the wrong thing). Spells that LOOK scripted in the raw DBC but are + // really a known effect (Holy Light → a heal, Life Tap → a resource convert, Charge → a rush) + // are reclassified to a generic kind at IMPORT time (the importer's curated correction), so + // they arrive here as E_HEAL / E_CONVERT_RESOURCE / E_CHARGE and never reach this arm. + log::info!( + "spell {} effect kind 0x{other:02x} not implemented (graceful no-op)", + e.spell_id + ); + EffectHit::none() + } + } +} + +/// The hit a damage arm hands to the shared pipeline: this spell, and the proc event its source +/// raises — or Triggered, raising nothing, when the whole cast was one. +fn hit_of( + hdr: &Spell, + triggered: bool, + source: crate::combat::HitSource, + crit: bool, +) -> crate::combat::Hit { + crate::combat::Hit::spell( + if triggered { + crate::combat::HitSource::Triggered + } else { + source + }, + hdr.spell_id, + crit, + ) +} + +#[allow(clippy::too_many_arguments)] +/// The damaging effects: the spell nuke, the weapon-derived strike, the judgement, and the +/// combo-point finisher. Each returns the post-mitigation figures the caller relays. +fn apply_damage_effect( + ctx: &ReducerContext, + e: &SpellEffect, + hdr: &Spell, + caster_guid: u64, + target_guid: u64, + level: u8, + points: i32, + triggered: bool, +) -> EffectHit { + match e.kind { E_DAMAGE => { // Binary spell MISS: a level-derived chance the spell fully misses (4% vs equal, up to // 17% vs +3) — a clean 0-damage outcome with NO threat/scaling. Rolled BEFORE everything else, @@ -928,7 +1003,7 @@ pub(crate) fn apply_effect( target_guid, caster_guid, after_resist, - hit_of(crate::combat::HitSource::Spell, is_crit), + hit_of(hdr, triggered, crate::combat::HitSource::Spell, is_crit), ); EffectHit { dealt, @@ -960,13 +1035,68 @@ pub(crate) fn apply_effect( target_guid, caster_guid, dmg as i32, - hit_of(crate::combat::HitSource::MeleeSpell, crit), + hit_of(hdr, triggered, crate::combat::HitSource::MeleeSpell, crit), + ); + EffectHit::dmg(dealt, absorbed) + } else { + EffectHit::none() + } + } + E_JUDGEMENT => { + // Unleash the caster's active SEAL: a one-time holy hit on the target derived from the seal + // value, then consume the seal. Generic — reads the A_SEAL aura, never a spell id. No seal → + // no-op. (Damage = the seal coefficient; the exact vanilla Judgement-of-Righteousness formula + // is a tuning refinement.) + let seal = seal_amount(ctx, caster_guid); + if seal > 0 { + let (dealt, absorbed) = apply_target_damage( + ctx, + target_guid, + caster_guid, + seal, + hit_of(hdr, triggered, crate::combat::HitSource::Spell, false), ); + remove_seal_auras(ctx, caster_guid); EffectHit::dmg(dealt, absorbed) } else { EffectHit::none() } } + E_FINISHER_DAMAGE => { + // Rogue FINISHER: damage scales with the combo points built on this target (base_points is the + // PER-POINT damage), then spend them. Generic — reads game_combo_point, never a spell id. + let combo = crate::combo::combo_points(ctx, caster_guid, target_guid); + let dmg = crate::combo::finisher_damage(points, combo); + let hit = if dmg > 0 { + let (dealt, absorbed) = apply_target_damage( + ctx, + target_guid, + caster_guid, + dmg, + hit_of(hdr, triggered, crate::combat::HitSource::MeleeSpell, false), + ); + EffectHit::dmg(dealt, absorbed) + } else { + EffectHit::none() + }; + crate::combo::spend_combo(ctx, caster_guid, target_guid); + hit + } + _ => EffectHit::none(), + } +} + +/// The restoring effects: heals, energize, the resource convert, the power burn, and combo points. +fn apply_restore_effect( + ctx: &ReducerContext, + e: &SpellEffect, + hdr: &Spell, + caster_guid: u64, + target_guid: u64, + points: i32, + triggered: bool, +) -> EffectHit { + match e.kind { E_HEAL => EffectHit::heal(apply_heal(ctx, hdr, caster_guid, target_guid, points)), E_HEAL_MAX_HEALTH => { // Lay on Hands: heal the target to FULL max health regardless of `points` (the DBC @@ -983,19 +1113,6 @@ pub(crate) fn apply_effect( } EffectHit::none() } - E_DISPEL => { - // The dispelled CATEGORY: the effect's `p0` (the authored override) if set, else the - // dispelling spell's header `dispel_type`. 0 = strip every foreign aura (the baseline — - // Dispel Magic 527 has p0=0 + dispel_type=0, so it strips everything). - let category = if e.p0 != 0 { - e.p0 as u8 - } else { - hdr.dispel_type - }; - let removed = dispel_target(ctx, target_guid, category); - log::info!("dispel (cat {category}) on {target_guid} removed {removed} debuff(s)"); - EffectHit::none() - } E_ENERGIZE => { if let Some(mut t) = ctx.db.game_world_entity().guid().find(target_guid) { t.power = energized_value(t.power, t.max_power, points); @@ -1008,6 +1125,56 @@ pub(crate) fn apply_effect( } EffectHit::none() } + E_CONVERT_RESOURCE => { + convert_health_to_power(ctx, caster_guid, points); + EffectHit::none() + } + E_POWER_BURN => { + // MANA-only gate: the vanilla effect reads the target's power type and returns a + // silent no-op on anything else (rage/energy) — the cast still resolves, it just does + // nothing on this target. Read straight off `unit_bytes_0` byte 3, same as `is_rage_user`. + let Some(mut t) = ctx.db.game_world_entity().guid().find(target_guid) else { + return EffectHit::none(); + }; + if ((t.unit_bytes_0 >> 24) & 0xFF) as u8 != lyracore_shared::packing::power_type::MANA { + return EffectHit::none(); + } + let drained = mana_burn_drain(t.power, points); + if drained == 0 { + return EffectHit::none(); + } + t.power -= drained; + ctx.db.game_world_entity().guid().update(t); + let damage = mana_burn_damage(drained, e.p1); + let (dealt, absorbed) = apply_target_damage( + ctx, + target_guid, + caster_guid, + damage as i32, + hit_of(hdr, triggered, crate::combat::HitSource::Spell, false), + ); + EffectHit::dmg(dealt, absorbed) + } + E_ADD_COMBO => { + // Rogue GENERATOR: +1 combo point on the target (capped at 5); the finisher reads it. Generic + // over game_combo_point, never a spell id. + crate::combo::add_combo_point(ctx, caster_guid, target_guid); + EffectHit::none() + } + _ => EffectHit::none(), + } +} + +/// The threat-table effects: taunt, interrupt, the threat wipe, and the queued next swing. +fn apply_threat_effect( + ctx: &ReducerContext, + e: &SpellEffect, + hdr: &Spell, + caster_guid: u64, + target_guid: u64, + points: i32, +) -> EffectHit { + match e.kind { E_TAUNT => { // Taunt: top the caster's threat on the target CREATURE so the retarget pass switches it to // the caster (the threat-yank). Only meaningful on a creature target — a no-op against a @@ -1024,72 +1191,68 @@ pub(crate) fn apply_effect( } EffectHit::none() } - E_CREATE_ITEM => { - // Conjure / quest item: create `points` of item `e.p0` (a game_item_template entry) in the - // CASTER's inventory — vanilla CreateItem always gives to the caster, not the explicit target. - // Reuses the shared `items::grant_item` (free backpack slot, one stack). A failure (inventory - // full, or the item template not loaded — the importer P4 dependency) is logged, NOT - // propagated: the cast still completes (graceful, like the other handlers). `points` is the - // DBC count (already +1-adjusted at import), floored at 1. - let entry = e.p0.max(0) as u32; - let count = points.max(1) as u32; - if entry == 0 { - log::info!( - "spell {} E_CREATE_ITEM with no item entry (p0=0) — skipped", - e.spell_id - ); - } else if let Err(err) = crate::items::grant_item(ctx, caster_guid, entry, count) { - log::info!( - "spell {} E_CREATE_ITEM: could not grant {count}x item {entry} to {caster_guid}: {err}", - e.spell_id - ); - // CRAFT (282): the reagents were already consumed (the gate in resolve_cast_at), so a - // failed product grant (full bag) must NOT eat them — refund EVERY reagent (vanilla refunds - // an unstorable craft). A conjure/quest CreateItem has no reagents → this loop is empty. - for (item, cnt) in recipe_reagents(ctx, e.spell_id) { - let _ = crate::items::grant_item(ctx, caster_guid, item, cnt); - } - } else if let Some((line, green, gray)) = recipe_skill(ctx, e.spell_id) { - // CRAFT skill-up (282) — the profession-loop's third leg, co-located with the product grant. - // Reaching here means the craft fully succeeded → climb the recipe's OWN profession line one - // step toward its cap, with the REAL difficulty band (green floor .. gray ceiling) from - // game_skill_ability. A no-op if the line isn't learned or is at cap; a degenerate/zero band - // ⇒ always +1 (skillup_chance_bp). Non-craft E_CREATE_ITEM (conjure) has no profession - // ability → None → skipped. - crate::skill::gain_profession_skill(ctx, caster_guid, line, green, gray); - } + E_INTERRUPT => { + // Kick: cancel the TARGET's in-progress (timed) cast — delete its game_pending_cast row(s) so + // the spell never resolves — AND lock the interrupted cast's SCHOOL (the Kick silence: + // `interrupt_cast_and_lock` writes a game_school_lockout row the cast gate reads). Generic over + // the kind, never a spell id; the importer reclassifies the raw vanilla InterruptCast effect + // (68) to E_INTERRUPT. Safe no-op if the target isn't mid-cast (nothing to cancel/lock). Uses + // the lock wrapper (not the bare `interrupt_cast`) so ONLY a Kick locks the school — a CC that + // happens to interrupt a cast (stun/poly/fear) stays a pure cancel. The lockout window comes + // from `game_spell.duration_ms` (the SPELL HEADER row, NOT `game_spell_effect` — SpellEffect + // has no duration_ms column). Vanilla values: Kick = 5000 ms, Counterspell = 8000 ms, + // Pummel = 4000 ms. To change a lockout window, edit `game_spell.duration_ms` on the header + // row; there is no effect-level duration field. Falls back to 5 s when the header carries 0. + let lockout_ms = if hdr.duration_ms > 0 { + hdr.duration_ms as i64 + } else { + 5_000 + }; + interrupt_cast_and_lock(ctx, target_guid, lockout_ms); EffectHit::none() } - E_TRIGGER => { - // Cast the linked spell NOW at the SAME target, routed through the shared cast core so the - // triggered spell runs its OWN effect list (a proc / "cast X which casts Y" chain). Guards: - // `trigger_spell == 0` is no-op (unset link); a spell triggering ITSELF (`trigger_spell == - // e.spell_id`) is skipped to avoid unbounded recursion. The trigger reuses the caster + - // level; its own GCD/cost/range gates apply — a rejected trigger is logged, never panics. - // The triggered spell routes through its OWN resolve_cast_at, which emits its OWN cast-GO event - // (with its OWN damage log) — so its damage is NOT folded into this parent cast's row (no - // double-count / double-log). Return 0 here. - if e.trigger_spell != 0 && e.trigger_spell != e.spell_id { - if let Err(err) = resolve_cast_at( - ctx, - caster_guid, - e.trigger_spell, - level, - target_guid, - false, - false, - None, - ) { - log::info!( - "spell {} triggered {} which did not resolve: {}", - e.spell_id, - e.trigger_spell, - err - ); - } + E_REDUCE_THREAT => { + // Feint: a one-time drop of the CASTER's CURRENT threat by the effect MAGNITUDE on EVERY + // creature it is a source on (floored at 0) — the rogue slides down each mob's aggro list and + // the retarget pass re-points them at the new top source (the tank). The vanilla data encodes + // Feint's threat drop as a NEGATIVE Threat-effect base (e.g. −150), so the reduction amount is + // `|points|` — `reduce_threat_from_source` then SUBTRACTS that positive magnitude. DISTINCT + // from Fade (an A_MOD_COMBAT(COMBAT_THREAT) percent on FUTURE threat). The effect is + // self-sourced — `target_guid` is ignored (the importer forces it T_SELF, so + // `target_guid == caster_guid` here anyway). Generic over the kind, never a spell id. + crate::threat::reduce_threat_from_source(ctx, caster_guid, (points as i64).abs()); + EffectHit::none() + } + E_NEXT_SWING => { + // On-next-swing QUEUE (Heroic Strike / Cleave): instead of hitting NOW, queue this spell onto the + // CASTER's next melee swing. Set `next_swing_spell`; the swing path (combat::resolve_swing) reads + // the queued spell's E_NEXT_SWING base_points as bonus damage on a LANDED swing and clears the + // field. The rage is VALIDATED at press but CHARGED at the swing-fire (resolve_swing, 114) — + // vanilla defers the deduction; a fizzle (rage dropped below cost before the swing) un-queues + // without firing. A missed swing still charges + spends the queue (vanilla). Generic over the kind, never a spell id; + // the importer reclassifies Heroic Strike/Cleave's weapon-damage E_DAMAGE to E_NEXT_SWING by name. + // No-op if the caster left the world. + if let Some(mut c) = ctx.db.game_world_entity().guid().find(caster_guid) { + c.next_swing_spell = e.spell_id; + ctx.db.game_world_entity().guid().update(c); } EffectHit::none() } + _ => EffectHit::none(), + } +} + +/// The effects that move a unit or anchor a patch of ground. +fn apply_reposition_effect( + ctx: &ReducerContext, + e: &SpellEffect, + hdr: &Spell, + caster_guid: u64, + target_guid: u64, + points: i32, + dest: Option<(f32, f32, f32)>, +) -> EffectHit { + match e.kind { E_CHARGE => { charge_to_target(ctx, caster_guid, target_guid); EffectHit::none() @@ -1101,7 +1264,7 @@ pub(crate) fn apply_effect( EffectHit::none() } E_RECALL_HOME => { - // Hearthstone (#387): recall the caster to its bound home, always into instance 0 (the open + // Hearthstone: recall the caster to its bound home, always into instance 0 (the open // world) even from inside a dungeon — `recall_to_home` resolves the durable char row's // home_map/x/y/z itself, so no target/effect params are read here. crate::world::recall_to_home(ctx, caster_guid); @@ -1125,111 +1288,52 @@ pub(crate) fn apply_effect( create_ground_area(ctx, e, hdr, caster_guid, target_guid, points, dest); EffectHit::none() } - E_CONVERT_RESOURCE => { - convert_health_to_power(ctx, caster_guid, points); - EffectHit::none() - } - E_POWER_BURN => { - // MANA-only gate: the vanilla effect reads the target's power type and returns a - // silent no-op on anything else (rage/energy) — the cast still resolves, it just does - // nothing on this target. Read straight off `unit_bytes_0` byte 3, same as `is_rage_user`. - let Some(mut t) = ctx.db.game_world_entity().guid().find(target_guid) else { - return EffectHit::none(); - }; - if ((t.unit_bytes_0 >> 24) & 0xFF) as u8 != lyracore_shared::packing::power_type::MANA { - return EffectHit::none(); - } - let drained = mana_burn_drain(t.power, points); - if drained == 0 { - return EffectHit::none(); - } - t.power -= drained; - ctx.db.game_world_entity().guid().update(t); - let damage = mana_burn_damage(drained, e.p1); - let (dealt, absorbed) = apply_target_damage( - ctx, - target_guid, - caster_guid, - damage as i32, - hit_of(crate::combat::HitSource::Spell, false), - ); - EffectHit::dmg(dealt, absorbed) - } - E_JUDGEMENT => { - // Unleash the caster's active SEAL: a one-time holy hit on the target derived from the seal - // value, then consume the seal. Generic — reads the A_SEAL aura, never a spell id. No seal → - // no-op. (Damage = the seal coefficient; the exact vanilla Judgement-of-Righteousness formula - // is a tuning refinement.) - let seal = seal_amount(ctx, caster_guid); - if seal > 0 { - let (dealt, absorbed) = apply_target_damage( - ctx, - target_guid, - caster_guid, - seal, - hit_of(crate::combat::HitSource::Spell, false), - ); - remove_seal_auras(ctx, caster_guid); - EffectHit::dmg(dealt, absorbed) - } else { - EffectHit::none() - } - } - E_ADD_COMBO => { - // Rogue GENERATOR: +1 combo point on the target (capped at 5); the finisher reads it. Generic - // over game_combo_point, never a spell id. - crate::combo::add_combo_point(ctx, caster_guid, target_guid); - EffectHit::none() - } - E_FINISHER_DAMAGE => { - // Rogue FINISHER: damage scales with the combo points built on this target (base_points is the - // PER-POINT damage), then spend them. Generic — reads game_combo_point, never a spell id. - let combo = crate::combo::combo_points(ctx, caster_guid, target_guid); - let dmg = crate::combo::finisher_damage(points, combo); - let hit = if dmg > 0 { - let (dealt, absorbed) = apply_target_damage( - ctx, - target_guid, - caster_guid, - dmg, - hit_of(crate::combat::HitSource::MeleeSpell, false), + _ => EffectHit::none(), + } +} + +/// The inventory effects: conjuring an item and pickpocketing one. +fn apply_inventory_effect( + ctx: &ReducerContext, + e: &SpellEffect, + caster_guid: u64, + target_guid: u64, + points: i32, +) -> EffectHit { + match e.kind { + E_CREATE_ITEM => { + // Conjure / quest item: create `points` of item `e.p0` (a game_item_template entry) in the + // CASTER's inventory — vanilla CreateItem always gives to the caster, not the explicit target. + // Reuses the shared `items::grant_item` (free backpack slot, one stack). A failure (inventory + // full, or the item template not loaded — the importer P4 dependency) is logged, NOT + // propagated: the cast still completes (graceful, like the other handlers). `points` is the + // DBC count (already +1-adjusted at import), floored at 1. + let entry = e.p0.max(0) as u32; + let count = points.max(1) as u32; + if entry == 0 { + log::info!( + "spell {} E_CREATE_ITEM with no item entry (p0=0) — skipped", + e.spell_id ); - EffectHit::dmg(dealt, absorbed) - } else { - EffectHit::none() - }; - crate::combo::spend_combo(ctx, caster_guid, target_guid); - hit - } - E_RESURRECT => { - // Offer a revive to a DEAD ally: emit SMSG_RESURRECT_REQUEST and wait for the target's - // CMSG_RESURRECT_RESPONSE accept — vanilla never revives a player without their consent. - // No-op on a living target (nothing to offer) and on a non-player target (creatures have no - // client to prompt; a future "always accept" NPC-rez path is out of scope). The actual revive - // (restore `points`% of max hp/power + clear the death/ghost state, mirroring corpse.rs - // `reclaim_corpse`) lives in `resurrect_response`, gated on accept. - if let Some(t) = ctx.db.game_world_entity().guid().find(target_guid) { - if t.dead && t.is_player() { - let caster_name = ctx - .db - .game_character() - .guid() - .find(caster_guid) - .map(|c| c.name) - .unwrap_or_default(); - ctx.db - .game_resurrect_request() - .target_guid() - .delete(target_guid); - ctx.db.game_resurrect_request().insert(ResurrectRequest { - target_guid, - target_identity: t.owner_identity, - caster_guid, - caster_name, - points, - created_at: ctx.timestamp, - }); + } else if let Err(err) = crate::items::grant_item(ctx, caster_guid, entry, count) { + log::info!( + "spell {} E_CREATE_ITEM: could not grant {count}x item {entry} to {caster_guid}: {err}", + e.spell_id + ); + // CRAFT (282): the reagents were already consumed (the gate in resolve_cast_at), so a + // failed product grant (full bag) must NOT eat them — refund EVERY reagent (vanilla refunds + // an unstorable craft). A conjure/quest CreateItem has no reagents → this loop is empty. + for (item, cnt) in recipe_reagents(ctx, e.spell_id) { + let _ = crate::items::grant_item(ctx, caster_guid, item, cnt); } + } else if let Some((line, green, gray)) = recipe_skill(ctx, e.spell_id) { + // CRAFT skill-up (282) — the profession-loop's third leg, co-located with the product grant. + // Reaching here means the craft fully succeeded → climb the recipe's OWN profession line one + // step toward its cap, with the REAL difficulty band (green floor .. gray ceiling) from + // game_skill_ability. A no-op if the line isn't learned or is at cap; a degenerate/zero band + // ⇒ always +1 (skillup_chance_bp). Non-craft E_CREATE_ITEM (conjure) has no profession + // ability → None → skipped. + crate::skill::gain_profession_skill(ctx, caster_guid, line, green, gray); } EffectHit::none() } @@ -1270,50 +1374,126 @@ pub(crate) fn apply_effect( } EffectHit::none() } - E_INTERRUPT => { - // Kick: cancel the TARGET's in-progress (timed) cast — delete its game_pending_cast row(s) so - // the spell never resolves — AND lock the interrupted cast's SCHOOL (the Kick silence: - // `interrupt_cast_and_lock` writes a game_school_lockout row the cast gate reads). Generic over - // the kind, never a spell id; the importer reclassifies the raw vanilla InterruptCast effect - // (68) to E_INTERRUPT. Safe no-op if the target isn't mid-cast (nothing to cancel/lock). Uses - // the lock wrapper (not the bare `interrupt_cast`) so ONLY a Kick locks the school — a CC that - // happens to interrupt a cast (stun/poly/fear) stays a pure cancel. The lockout window comes - // from `game_spell.duration_ms` (the SPELL HEADER row, NOT `game_spell_effect` — SpellEffect - // has no duration_ms column). Vanilla values: Kick = 5000 ms, Counterspell = 8000 ms, - // Pummel = 4000 ms. To change a lockout window, edit `game_spell.duration_ms` on the header - // row; there is no effect-level duration field. Falls back to 5 s when the header carries 0. - let lockout_ms = if hdr.duration_ms > 0 { - hdr.duration_ms as i64 - } else { - 5_000 - }; - interrupt_cast_and_lock(ctx, target_guid, lockout_ms); + _ => EffectHit::none(), + } +} + +/// The effects that hand the work to another engine path: a triggered cast, and a Runtime Script. +fn apply_delegating_effect( + ctx: &ReducerContext, + e: &SpellEffect, + caster_guid: u64, + target_guid: u64, + level: u8, +) -> EffectHit { + match e.kind { + E_TRIGGER => { + // Cast the linked spell NOW at the SAME target, routed through the shared cast core so the + // triggered spell runs its OWN effect list (a proc / "cast X which casts Y" chain). Guards: + // `trigger_spell == 0` is no-op (unset link); a spell triggering ITSELF (`trigger_spell == + // e.spell_id`) is skipped to avoid unbounded recursion. The trigger reuses the caster + + // level; its own GCD/cost/range gates apply — a rejected trigger is logged, never panics. + // The triggered spell routes through its OWN resolve_cast_at, which emits its OWN cast-GO event + // (with its OWN damage log) — so its damage is NOT folded into this parent cast's row (no + // double-count / double-log). Return 0 here. + if e.trigger_spell != 0 && e.trigger_spell != e.spell_id { + if let Err(err) = resolve_cast_at( + ctx, + caster_guid, + e.trigger_spell, + level, + target_guid, + false, + false, + None, + ) { + log::info!( + "spell {} triggered {} which did not resolve: {}", + e.spell_id, + e.trigger_spell, + err + ); + } + } EffectHit::none() } - E_REDUCE_THREAT => { - // Feint: a one-time drop of the CASTER's CURRENT threat by the effect MAGNITUDE on EVERY - // creature it is a source on (floored at 0) — the rogue slides down each mob's aggro list and - // the retarget pass re-points them at the new top source (the tank). The vanilla data encodes - // Feint's threat drop as a NEGATIVE Threat-effect base (e.g. −150), so the reduction amount is - // `|points|` — `reduce_threat_from_source` then SUBTRACTS that positive magnitude. DISTINCT - // from Fade (an A_MOD_COMBAT(COMBAT_THREAT) percent on FUTURE threat). The effect is - // self-sourced — `target_guid` is ignored (the importer forces it T_SELF, so - // `target_guid == caster_guid` here anyway). Generic over the kind, never a spell id. - crate::threat::reduce_threat_from_source(ctx, caster_guid, (points as i64).abs()); + E_SCRIPTED => { + // script_id == 0 is the vanilla no-op — every seed/imported spell (including the + // importer's fallback for a raw effect it cannot map to a known kind) carries it, and + // this arm changes nothing for them. + if e.script_id != 0 { + // The precast gate (`check_cast_gate_suffix`) already refused the whole cast if + // this script was missing or disabled — this is the SAME lookup, re-run because + // the row can change between the gate and here (an Operator disables it mid-cast, + // or an earlier effect in this same cast removed it). A miss here fails only THIS + // effect: no panic, no late refusal — the cost is already spent and the cast's + // other effects still apply. + if let Some(script) = ctx.db.game_script().script_id().find(e.script_id) { + if script.enabled { + // Label carries the spell + effect identity a Script Diagnostic names on + // failure, and what `event.name` reads as inside the script. Distinct from + // an Event Binding's event name (there is no binding here — script_id + // names the script outright). + let event = crate::runtime_script::ScriptEvent { + name: format!("spell_effect:{}:{}", e.spell_id, e.effect_index), + actor: crate::runtime_script::EntityView::read(ctx, caster_guid), + target: crate::runtime_script::EntityView::read(ctx, target_guid), + }; + crate::runtime_script::invoke_by_identity( + ctx, + crate::runtime_script::RuntimeScript { + name: &script.name, + source: &script.source, + }, + &event, + ); + } + } + } EffectHit::none() } - E_NEXT_SWING => { - // On-next-swing QUEUE (Heroic Strike / Cleave): instead of hitting NOW, queue this spell onto the - // CASTER's next melee swing. Set `next_swing_spell`; the swing path (combat::resolve_swing) reads - // the queued spell's E_NEXT_SWING base_points as bonus damage on a LANDED swing and clears the - // field. The rage is VALIDATED at press but CHARGED at the swing-fire (resolve_swing, 114) — - // vanilla defers the deduction; a fizzle (rage dropped below cost before the swing) un-queues - // without firing. A missed swing still charges + spends the queue (vanilla). Generic over the kind, never a spell id; - // the importer reclassifies Heroic Strike/Cleave's weapon-damage E_DAMAGE to E_NEXT_SWING by name. - // No-op if the caster left the world. - if let Some(mut c) = ctx.db.game_world_entity().guid().find(caster_guid) { - c.next_swing_spell = e.spell_id; - ctx.db.game_world_entity().guid().update(c); + _ => EffectHit::none(), + } +} + +/// The effects that change what a character IS: resurrected, accompanied by a pet, or in a stance. +fn apply_character_effect( + ctx: &ReducerContext, + e: &SpellEffect, + caster_guid: u64, + target_guid: u64, + points: i32, +) -> EffectHit { + match e.kind { + E_RESURRECT => { + // Offer a revive to a DEAD ally: emit SMSG_RESURRECT_REQUEST and wait for the target's + // CMSG_RESURRECT_RESPONSE accept — vanilla never revives a player without their consent. + // No-op on a living target (nothing to offer) and on a non-player target (creatures have no + // client to prompt; a future "always accept" NPC-rez path is out of scope). The actual revive + // (restore `points`% of max hp/power + clear the death/ghost state, mirroring corpse.rs + // `reclaim_corpse`) lives in `resurrect_response`, gated on accept. + if let Some(t) = ctx.db.game_world_entity().guid().find(target_guid) { + if t.dead && t.is_player() { + let caster_name = ctx + .db + .game_character() + .guid() + .find(caster_guid) + .map(|c| c.name) + .unwrap_or_default(); + ctx.db + .game_resurrect_request() + .target_guid() + .delete(target_guid); + ctx.db.game_resurrect_request().insert(ResurrectRequest { + target_guid, + target_identity: t.owner_identity, + caster_guid, + caster_name, + points, + created_at: ctx.timestamp, + }); + } } EffectHit::none() } @@ -1381,53 +1561,7 @@ pub(crate) fn apply_effect( } EffectHit::none() } - E_SCRIPTED => { - // script_id == 0 is the vanilla no-op — every seed/imported spell (including the - // importer's fallback for a raw effect it cannot map to a known kind) carries it, and - // this arm changes nothing for them. - if e.script_id != 0 { - // The precast gate (`check_cast_gate_suffix`) already refused the whole cast if - // this script was missing or disabled — this is the SAME lookup, re-run because - // the row can change between the gate and here (an Operator disables it mid-cast, - // or an earlier effect in this same cast removed it). A miss here fails only THIS - // effect: no panic, no late refusal — the cost is already spent and the cast's - // other effects still apply. - if let Some(script) = ctx.db.game_script().script_id().find(e.script_id) { - if script.enabled { - // Label carries the spell + effect identity a Script Diagnostic names on - // failure, and what `event.name` reads as inside the script. Distinct from - // an Event Binding's event name (there is no binding here — script_id - // names the script outright). - let event = crate::runtime_script::ScriptEvent { - name: format!("spell_effect:{}:{}", e.spell_id, e.effect_index), - actor: crate::runtime_script::EntityView::read(ctx, caster_guid), - target: crate::runtime_script::EntityView::read(ctx, target_guid), - }; - crate::runtime_script::invoke_by_identity( - ctx, - crate::runtime_script::RuntimeScript { - name: &script.name, - source: &script.source, - }, - &event, - ); - } - } - } - EffectHit::none() - } - other => { - // Unmapped residue: a graceful no-op + log (the import safety net — casts and does - // nothing, vs doing the wrong thing). Spells that LOOK scripted in the raw DBC but are - // really a known effect (Holy Light → a heal, Life Tap → a resource convert, Charge → a rush) - // are reclassified to a generic kind at IMPORT time (the importer's curated correction), so - // they arrive here as E_HEAL / E_CONVERT_RESOURCE / E_CHARGE and never reach this arm. - log::info!( - "spell {} effect kind 0x{other:02x} not implemented (graceful no-op)", - e.spell_id - ); - EffectHit::none() - } + _ => EffectHit::none(), } } @@ -1997,7 +2131,10 @@ mod effect_handler_tests { fn thirty_two_buffs_full_refuses_the_thirty_third_leaving_the_thirty_two_untouched() { let buffs_full: Vec<(u8, u32)> = (0u8..32).map(|slot| (slot, slot as u32 + 2000)).collect(); assert_eq!(buffs_full.len(), 32); - assert_eq!(super::pick_aura_slot(&buffs_full, 8888, false, 32, 48), None); + assert_eq!( + super::pick_aura_slot(&buffs_full, 8888, false, 32, 48), + None + ); for &(slot, spell_id) in &buffs_full { assert_eq!( super::pick_aura_slot(&buffs_full, spell_id, false, 32, 48), @@ -2028,7 +2165,11 @@ mod effect_handler_tests { // Both ranges full simultaneously: a fresh debuff is refused (debuff range is what's full for // it) and a fresh buff is refused (buff range is what's full for it) — each by its OWN range, // not by the other polarity's occupancy. - let both_full: Vec<(u8, u32)> = buffs_full.iter().chain(debuffs_full.iter()).copied().collect(); + let both_full: Vec<(u8, u32)> = buffs_full + .iter() + .chain(debuffs_full.iter()) + .copied() + .collect(); assert_eq!(super::pick_aura_slot(&both_full, 9999, true, 32, 48), None); assert_eq!(super::pick_aura_slot(&both_full, 8888, false, 32, 48), None); } From 9a3a2e5c3d40c61561fdadf5fef2f6fa0f48b268 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Thu, 3 Sep 2026 21:09:13 +0200 Subject: [PATCH 3/3] fix(refactor): keep extracted helpers lint-clean --- Cargo.toml | 6 +++--- importer/src/main.rs | 30 +++++++++++++++--------------- importer/src/spell.rs | 20 +++++++++----------- 3 files changed, 27 insertions(+), 29 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1e395c112..542d34776 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,9 +43,9 @@ homepage = "https://github.com/LyraCoreProject/LyraCore" # either repository — so the whole workspace is publish-gated here rather than crate by crate. publish = false -# A function this long stops fitting in one head (human or agent). The threshold in `clippy.toml` -# is set above today's worst untouched offenders, so the lint holds the line without burying the -# real findings; lower it as those shrink. +# A function above the threshold in `clippy.toml` no longer fits in one head. Existing warnings +# mark untouched long functions; changed code stays below the 200-line ceiling. Lower it as those +# functions shrink. [workspace.lints.clippy] too_many_lines = "warn" diff --git a/importer/src/main.rs b/importer/src/main.rs index 4bc82ccef..95da7b4a5 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -4008,7 +4008,7 @@ fn collect_creature_spawns(dump: &str, scope: &WorldImportScope) -> Vec = Vec::new(); + let mut spawns: Vec = Vec::new(); let mut pool_resolved = 0u32; let mut pool_skipped = 0u32; for row in parse_table(dump, "creature") { @@ -5031,12 +5031,12 @@ fn push_world_content_statements(args: &Args, content: MappedContent, stmts: &mu if family_active(args, "creatures") { stmts.push("DELETE FROM game_creature_waypoint WHERE id > 0".into()); stmts.push("DELETE FROM game_creature_template WHERE entry > 0".into()); - push_insert(stmts, "game_creature_template", "entry,name,subname,display_id,level,health,faction_template,npc_flags,unit_flags,creature_type,creature_family,type_flags,rank,scale,base_attack_time_ms,money_min,money_max,max_level,max_level_health,aggro_range,damage_min,damage_max,armor,pickpocket_loot_id,skin_loot_id,trainer_type,trainer_class", &templates); + push_insert(stmts, "game_creature_template", "entry,name,subname,display_id,level,health,faction_template,npc_flags,unit_flags,creature_type,creature_family,type_flags,rank,scale,base_attack_time_ms,money_min,money_max,max_level,max_level_health,aggro_range,damage_min,damage_max,armor,pickpocket_loot_id,skin_loot_id,trainer_type,trainer_class", templates); push_insert( stmts, "game_creature_waypoint", "id,creature_guid,x,y,z", - &waypoints, + waypoints, ); } @@ -5044,18 +5044,18 @@ fn push_world_content_statements(args: &Args, content: MappedContent, stmts: &mu stmts.push("DELETE FROM game_item_template WHERE entry > 0".into()); stmts.push("DELETE FROM game_creature_loot WHERE id > 0".into()); stmts.push("DELETE FROM game_npc_vendor WHERE id > 0".into()); - push_insert(stmts, "game_item_template", "entry,class,subclass,name,display_id,quality,inventory_type,item_level,required_level,max_durability,buy_price,sell_price,max_stack,damage_min,damage_max,delay_ms,stat_strength,stat_agility,stat_stamina,stat_intellect,stat_spirit,stat_crit,stat_hit,stat_armor,block_value,restores_power,spellid_1,spelltrigger_1,spellid_2,spelltrigger_2,container_slots,sheath,bonding,holy_res,fire_res,nature_res,frost_res,shadow_res,arcane_res,spellid_3,spelltrigger_3,spellid_4,spelltrigger_4,spellid_5,spelltrigger_5,required_skill,required_skill_rank,required_reputation_faction,required_reputation_rank,max_count,item_flags,page_text,start_quest,bag_family,buy_count,food_type,allowed_class,allowed_race", &item_rows); + push_insert(stmts, "game_item_template", "entry,class,subclass,name,display_id,quality,inventory_type,item_level,required_level,max_durability,buy_price,sell_price,max_stack,damage_min,damage_max,delay_ms,stat_strength,stat_agility,stat_stamina,stat_intellect,stat_spirit,stat_crit,stat_hit,stat_armor,block_value,restores_power,spellid_1,spelltrigger_1,spellid_2,spelltrigger_2,container_slots,sheath,bonding,holy_res,fire_res,nature_res,frost_res,shadow_res,arcane_res,spellid_3,spelltrigger_3,spellid_4,spelltrigger_4,spellid_5,spelltrigger_5,required_skill,required_skill_rank,required_reputation_faction,required_reputation_rank,max_count,item_flags,page_text,start_quest,bag_family,buy_count,food_type,allowed_class,allowed_race", item_rows); push_insert( stmts, "game_creature_loot", "id,creature_entry,item_entry,chance_bp,count,group_id,quest_only", - &loot_rows, + loot_rows, ); push_insert( stmts, "game_npc_vendor", "id,creature_entry,item_entry,slot,max_count", - &vendor_rows, + vendor_rows, ); } @@ -5068,25 +5068,25 @@ fn push_world_content_statements(args: &Args, content: MappedContent, stmts: &mu stmts, "game_pickpocket_loot", "id,creature_entry,item_entry,chance_bp,count,group_id,quest_only", - &pickpocket_rows, + pickpocket_rows, ); push_insert( stmts, "game_skinning_loot", "id,skin_loot_id,item_entry,chance_bp,count,group_id", - &skinning_rows, + skinning_rows, ); push_insert( stmts, "game_gameobject_loot", "id,loot_id,item_entry,chance_bp,count,group_id,quest_only", - &gameobject_loot_rows, + gameobject_loot_rows, ); push_insert( stmts, "game_fishing_loot", "id,zone_id,item_entry,chance_bp,count,group_id", - &fishing_rows, + fishing_rows, ); } @@ -5210,12 +5210,12 @@ fn push_quest_and_gameobject_statements( if family_active(args, "gameobjects") { stmts.push("DELETE FROM game_gameobject_trap WHERE entry > 0".into()); stmts.push("DELETE FROM game_gameobject_template WHERE entry > 0".into()); - push_insert(stmts, "game_gameobject_template", "entry,type_id,display_id,name,data0,data1,gather_skill_line,respawn_secs,gather_gray,lock_id,size", &go_template_rows); + push_insert(stmts, "game_gameobject_template", "entry,type_id,display_id,name,data0,data1,gather_skill_line,respawn_secs,gather_gray,lock_id,size", go_template_rows); push_insert( stmts, "game_gameobject_trap", "entry,spell_id,cooldown_secs", - &go_trap_rows, + go_trap_rows, ); } // Trainer spell lists (family "trainers"). @@ -5225,7 +5225,7 @@ fn push_quest_and_gameobject_statements( stmts, "game_trainer_spell", "id,trainer_entry,spell_id,cost,required_level,learn_skill_line,learn_skill_cap", - &trainer_rows, + trainer_rows, ); } } @@ -5250,7 +5250,7 @@ fn push_creature_behaviour_statements( stmts, "game_creature_cast", "creature_entry,spell_id", - &creature_casts, + creature_casts, ); // Rotation rows (game_creature_spell, rank 20): multi-spell casters use these instead of (or in // addition to) game_creature_cast. Clear ALL rows first (same pattern as game_creature_cast: the @@ -5261,7 +5261,7 @@ fn push_creature_behaviour_statements( stmts, "game_creature_spell", "id,creature_entry,spell_id,priority,condition,condition_value", - &creature_rotation_rows, + creature_rotation_rows, ); } diff --git a/importer/src/spell.rs b/importer/src/spell.rs index 37368c640..927c7fca1 100644 --- a/importer/src/spell.rs +++ b/importer/src/spell.rs @@ -1564,7 +1564,7 @@ fn push_spell_effect_rows( // against the cast header's family_flags. s.effect_item_type[i] } else { - power_word_shield_p1_override(spell_id, &name, kind, 0i32) + power_word_shield_p1_override(spell_id, name, kind, 0i32) }; let script_id = 0u32; // [093] data-driven "this energize enters/holds combat": set on Bloodrage (cast 2687 + trickle @@ -1623,9 +1623,9 @@ fn push_spell_effect_rows( // SoC are distinct ids), so a single free slot suffices. These are REAL kinds → counted toward // coverage like any mapped effect. let synth_slot = used_slots.iter().position(|&u| !u).unwrap_or(3) as u8; - if let Some(synth) = synthetic_control_effect(spell_id, &name, synth_slot) - .or_else(|| synthetic_stealth_slow_effect(spell_id, &name, synth_slot)) - .or_else(|| synthetic_seal_effect(spell_id, &name, synth_slot)) + if let Some(synth) = synthetic_control_effect(spell_id, name, synth_slot) + .or_else(|| synthetic_stealth_slow_effect(spell_id, name, synth_slot)) + .or_else(|| synthetic_seal_effect(spell_id, name, synth_slot)) { cov.effects += 1; cov.real += 1; @@ -1668,7 +1668,7 @@ fn resolve_effect_kind( ) }; // Curated correction (Spell.sql analog): reclassify the known script-effect-as-generic spells. - let kind = correct_script_effect_kind(&name, kind); + let kind = correct_script_effect_kind(name, kind); // DISMOUNT reclassify (data-driven, never by name/id): a raw DISPEL_MECHANIC effect whose // misc value names the mount mechanic becomes E_DISMOUNT. Runs on the RAW effect id (not @@ -1686,7 +1686,7 @@ fn resolve_effect_kind( // MOUNT DISPLAY resolution: an A_MOUNTED effect's p0 already defaults to the raw misc value // (resolve_aura_params) — resolve the rarer creature-template indirection here, mirroring // the stance/CREATE_ITEM kind-keyed p0 fix-ups above. - let p0 = mount_display_p0(kind, p0, &creature_displays); + let p0 = mount_display_p0(kind, p0, creature_displays); // PROC_DAMAGE school: an A_PROC_DAMAGE effect deals its frozen amount as damage of the // proc spell's own school — mirrors the mount/stance p0 fix-ups above. @@ -1885,7 +1885,7 @@ fn resolve_effect_target( // Same DBC-collapse trap hits the paladin/priest friendly single-target kit — Holy // Light (635/639), Lay on Hands (633), Blessing of Might (19740) and Purify (1152) all // import with implicit_target_a=0 (see work-item 007, archived), so they share this override. - let target = friendly_self_or_ally_target_override(&name, target); + let target = friendly_self_or_ally_target_override(name, target); // CHANNEL self-marker: a channeled spell carries an inert A_FLAG "you are channeling" marker // (Arcane Missiles eff2) that the DBC reads as ALLY-targeted — but the channel is cast AT an // ENEMY, so an ally-typed effect would make the faction gate REJECT the enemy cast (the same @@ -1894,13 +1894,11 @@ fn resolve_effect_target( // hits_ally/hits_enemy scan (a self-cast effect imposes no faction constraint). Generic over the // channeled bit + the A_FLAG kind, never a spell id; the channel's A_PERIODIC_TRIGGER tick effect // is already T_SELF, so only the stray marker is corrected. - let target = if channeled && kind == A_FLAG { + if channeled && kind == A_FLAG { T_SELF } else { target - }; - - target + } } /// The `game_trainer_spell` rows for each `--trainer =` binding.