diff --git a/docs/data-ingestion.md b/docs/data-ingestion.md index 4b4d6e5e6..b1476991c 100644 --- a/docs/data-ingestion.md +++ b/docs/data-ingestion.md @@ -289,6 +289,36 @@ failure during those statements can leave a partial family, but `import-world.sh failure and the next run clears the family again before rebuilding it. No DBC bytes or generated catalogue artifact is written into the repository. +TaxiNodes mount fields name creature-template entries, despite the `wow_dbc` field name +`mount_creature_display_info`. The standalone DBC pass requires `--taxi-mounts ` +and resolves them through the full dump, not the spatially filtered creature catalogue. The world +import script passes its existing `DUMP` input. This makes import order independent of which mount +creatures have spawns in the World Shard. + +For each faction, a zero creature entry selects the other faction's entry before lookup. A missing +template, a template with no model, or a selected model absent from `CreatureDisplayInfo` produces +display zero and a warning. The Module then refuses that faction's flight before charging the fare. +An explicit missing entry does not fall back to the other faction. Empty template input, unfinished +INSERT statements, duplicate entries and invalid entry or model fields fail before any DBC catalogue +SQL runs. + +The catalogue stores the first nonzero model in the template's four model slots. This deterministic +choice omits the reference's random model and gender selection. The creature-entry lookup and +faction fallback follow [CMaNGOS Classic's taxi mount selection](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Globals/ObjectMgr.cpp#L6500-L6534). + +Existing catalogues need the standalone DBC pass again with the same client and full dump. Updating +the binary alone does not repair stored displays. With flights stopped, an Operator can rebuild +them on each affected World Shard with explicit targets: + +```bash +cargo run -p lyracore-importer -- --server "$SPACETIME_SERVER" --db "$DB" \ + --dbc "$DBC" --taxi-mounts "$DUMP" +# Review the dry run, then repeat with --apply during the import maintenance window. +``` + +This reloads all standalone DBC catalogues, including taxi nodes, paths and points. It preserves the +reserved taxi fixture. Flights already active retain their captured mount display until they end. + ## What the seeded fixture knowingly contains The firewall above is about **bulk data**: this project never vendors, mirrors, or ships a diff --git a/docs/verification/taxi-flight-verification.md b/docs/verification/taxi-flight-verification.md index 136486652..e3ce98cd8 100644 --- a/docs/verification/taxi-flight-verification.md +++ b/docs/verification/taxi-flight-verification.md @@ -26,3 +26,15 @@ and run the full adapter suite. The attended gate remains separate: an unmodified 1.12.1 build-5875 client must confirm the taxi map, multi-point spline, mount, flight animation, landing cleanup, and session stability by eye. + +## Imported mount displays + +The importer tests use different creature entries and display IDs for both factions. They cover +zero-entry fallback, missing templates, absent client displays and template model selection. These +checks verify the stored display values, not the client's rendered model. + +For a real-client check, record the source and destination node IDs, the source TaxiNodes faction +entry, the template's first nonzero model, and the imported `game_taxi_node` display. Take the route +with each supported faction and compare the active flight's mount display with that value and the +model shown by the client. Include the reported flight over Mist's Edge once its endpoints are +known. The report did not supply those endpoints, so that route has not been reproduced here. diff --git a/importer/scripts/import-world-sql-test.sh b/importer/scripts/import-world-sql-test.sh index 04b8524c7..d06e76519 100644 --- a/importer/scripts/import-world-sql-test.sh +++ b/importer/scripts/import-world-sql-test.sh @@ -196,6 +196,8 @@ STUB_MODE=succeed STUB_ROW_COUNT=150 WORLD_PROFILE=alliance-single run_import check "the profile run reaches the assertion stage" not_contains "$out" '[world] ABORT' check "the profile is displayed with all of its planned maps" \ contains "$out" 'canonical profile alliance-single (planned maps: 0 1 36 369)' +check "the standalone DBC catalogue receives the full taxi mount dump" \ + grep -q -- '--dbc .*--taxi-mounts .import/classic-db-full.sql --apply' "$stub_log" check "the dump ETL receives the canonical profile name" \ grep -q -- 'lyracore-importer .*--world-profile alliance-single' "$stub_log" check "the dump ETL does not recreate a profile as legacy spatial flags" \ diff --git a/importer/scripts/import-world.sh b/importer/scripts/import-world.sh index a7eb446aa..ea511e1c8 100755 --- a/importer/scripts/import-world.sh +++ b/importer/scripts/import-world.sh @@ -427,15 +427,11 @@ else ./target/debug/lyracore-importer --db "$DB" --server "$SPACETIME_SERVER" \ --nav "$DBC" "${SCOPE_FLAGS[@]}" --apply || exit 1 fi -# Character-creation + faction DBC tables. The combined --dump --dbc pass above uses the DBCs ONLY for -# Scale==0 resolution; dbc::run() — the SOLE loader of game_start_item / game_char_base_info / -# game_race_info / game_faction[_template] — is gated on --dump being ABSENT, so a --dump pass SKIPS it. -# Run --dbc ALONE here so a fresh reprovision + this script populates those tables; otherwise non-Warrior -# classes fall back to the Warrior loadout, Human Female renders Male, and create_character skips -# race/class validation (each is an idempotent clear+reload, independent of the cmangos creature slice). +# The standalone DBC pass loads character creation, faction and taxi catalogues. +# Taxi mount entries resolve through the full dump, independent of the imported creature slice. echo "[world] character-creation + faction DBC tables (start items / race+class combos / race display / factions)" run_checked_step "standalone DBC catalogue import" "dbc: loaded|error|abort" 6 \ - ./target/debug/lyracore-importer --db "$DB" --server "$SPACETIME_SERVER" --dbc "$DBC" --apply || exit 1 + ./target/debug/lyracore-importer --db "$DB" --server "$SPACETIME_SERVER" --dbc "$DBC" --taxi-mounts "$DUMP" --apply || exit 1 # Real talent trees (TalentTab.dbc + Talent.dbc → game_talent_tab/game_talent). # WITHOUT this the tables hold only the 8 demo talents, whose ids don't match what the 5875 client # sends on a talent click → talent selection silently fails (the client renders the real trees from its diff --git a/importer/src/dbc.rs b/importer/src/dbc.rs index 4e04bb3bb..022c96f04 100644 --- a/importer/src/dbc.rs +++ b/importer/src/dbc.rs @@ -160,11 +160,12 @@ pub(crate) fn read_table(chain: &mut PatchChain) -> Result { }) } -/// `--dbc ` mode. P1a (proof-of-toolchain): confirm `wow-mpq` opens the operator's real -/// `dbc.MPQ`, the known-path lookup works, `wow_dbc` accepts the 5875 schema, and a foreign-key chase -/// resolves — by printing Human's male display (must be 49) and the demo chicken's display-304 scale. -/// Writes NO game data and NO `.dbc` file; it only reads + prints, so it's safe to run any time. +/// Build the DBC catalogues from the operator's client and taxi mount dump. +/// Dry runs print the plan; `--apply` clears and reloads the derived tables. pub fn run(data_dir: &str, args: &Args) -> Result<()> { + let mount_dump = crate::read_dump(args.taxi_mounts.as_deref().context( + "standalone --dbc requires --taxi-mounts to resolve taxi mount displays", + )?)?; let dir = Path::new(data_dir); let mut chain = open_chain(dir)?; eprintln!("dbc: opened MPQ chain from {}", dir.display()); @@ -244,7 +245,13 @@ pub fn run(data_dir: &str, args: &Args) -> Result<()> { statements: taxi_stmts, counts: taxi_counts, warnings: taxi_warnings, - } = taxi_catalogue_sql(&taxi_nodes, &taxi_paths, &taxi_path_nodes)?; + } = taxi_catalogue_sql( + &taxi_nodes, + &taxi_paths, + &taxi_path_nodes, + &mount_dump, + &cdi.rows().iter().map(|row| row.id.id).collect(), + )?; for warning in taxi_warnings { eprintln!("{warning}"); } @@ -900,6 +907,43 @@ struct TaxiCatalogueSql { warnings: Vec, } +/// Resolve from the full dump, since taxi mounts need not have a spawn in the imported area. +fn taxi_mount_displays(dump: &str, display_ids: &HashSet) -> Result> { + let rows = crate::parse_complete_table(dump, "creature_template")?; + if rows.is_empty() { + bail!("taxi mount dump has no creature_template rows"); + } + let mut mounts = HashMap::with_capacity(rows.len()); + for row in rows { + let entry: u32 = crate::field(&row, crate::ct::ENTRY) + .parse() + .context("taxi mount dump has an invalid creature entry")?; + let mut display = 0; + for index in crate::ct::MODEL_ID1..crate::ct::MODEL_ID1 + 4 { + let model: u32 = row + .get(index) + .with_context(|| { + format!("taxi mount creature {entry} is missing model column {index}") + })? + .trim() + .parse() + .with_context(|| format!("taxi mount creature {entry} has an invalid model"))?; + // A catalogue has one display per faction. Choose the first nonzero template model + // deterministically, rather than rerolling the template's model on each flight. + if display == 0 { + display = model; + } + } + if !display_ids.contains(&display) { + display = 0; + } + if mounts.insert(entry, display).is_some() { + bail!("taxi mount dump has duplicate creature entry {entry}"); + } + } + Ok(mounts) +} + /// Validate and emit the three-table taxi catalogue as one deterministic clear+reload family. /// All validation happens before SQL is returned: a path must resolve both endpoint nodes, /// costs/indices must be non-negative, and the operator's data may not enter the reserved fixture @@ -910,6 +954,8 @@ fn taxi_catalogue_sql( nodes: &DbcTaxiNodes, paths: &DbcTaxiPath, path_nodes: &DbcTaxiPathNode, + mount_dump: &str, + display_ids: &HashSet, ) -> Result { use lyracore_shared::constants::taxi_fixture as fixture; use lyracore_shared::constants::taxi_protocol; @@ -918,6 +964,8 @@ fn taxi_catalogue_sql( bail!("taxi DBC catalogue is incomplete: TaxiNodes=0 rows"); } + let mounts = taxi_mount_displays(mount_dump, display_ids)?; + let mut warnings = Vec::new(); let mut node_ids = HashSet::with_capacity(nodes.rows().len()); let mut node_rows = Vec::with_capacity(nodes.rows().len() + 2); for row in nodes.rows() { @@ -944,6 +992,26 @@ fn taxi_catalogue_sql( { bail!("TaxiNodes.dbc node {id} has a non-finite position"); } + // Despite wow_dbc's field name, vanilla TaxiNodes stores creature-template entries. + let entries = row.mount_creature_display_info; + let displays = [0, 1].map(|faction| { + let entry = if entries[faction] == 0 { + entries[1 - faction] + } else { + entries[faction] + }; + let display = if entry == 0 { + 0 + } else { + mounts.get(&entry).copied().unwrap_or(0) + }; + if entry != 0 && display == 0 { + warnings.push(format!( + "dbc: WARN TaxiNodes.dbc node {id} faction slot {faction}: creature {entry} has no valid mount display; flights unavailable" + )); + } + display + }); node_rows.push(( id, format!( @@ -955,8 +1023,8 @@ fn taxi_catalogue_sql( row.location_y, row.location_z, sql_text(&row.name.en_gb), - row.mount_creature_display_info[0], - row.mount_creature_display_info[1], + displays[0], + displays[1], ), )); } @@ -1181,7 +1249,7 @@ fn taxi_catalogue_sql( &point_rows, ); dangling_points.sort_unstable(); - let mut warnings = Vec::with_capacity(dangling_points.len() + empty_paths.len()); + warnings.sort_unstable(); warnings.extend(dangling_points.into_iter().map(|(id, path_id)| { format!("dbc: WARN TaxiPathNode.dbc point {id} references missing path {path_id}; omitted") })); @@ -1695,6 +1763,24 @@ mod tests { } } + fn catalogue_with_mounts( + nodes: &DbcTaxiNodes, + paths: &DbcTaxiPath, + points: &DbcTaxiPathNode, + ) -> Result { + super::taxi_catalogue_sql( + nodes, + paths, + points, + "INSERT INTO `creature_template` VALUES \ + (1100,'Horde mount','',1,1,295,0,0,0), + (1101,'Alliance mount','',1,1,1147,0,0,0), + (2200,'Other Horde mount','',1,1,295,0,0,0), + (2201,'Other Alliance mount','',1,1,1147,0,0,0);", + &HashSet::from([295, 1147]), + ) + } + fn synthetic_taxi_catalogue() -> (DbcTaxiNodes, DbcTaxiPath, DbcTaxiPathNode) { ( DbcTaxiNodes { @@ -1719,6 +1805,128 @@ mod tests { ) } + #[test] + fn taxi_mount_fallback_requires_an_absent_faction_entry() { + let (mut nodes, paths, points) = synthetic_taxi_catalogue(); + for (entries, expected, warning_count) in [ + ([0, 1101], [1147, 1147], 0), + ([1100, 0], [295, 295], 0), + ([0, 0], [0, 0], 0), + ([9999, 1101], [0, 1147], 1), + ([1100, 9999], [295, 0], 1), + ] { + nodes.rows[1].mount_creature_display_info = entries; + let catalogue = catalogue_with_mounts(&nodes, &paths, &points).unwrap(); + let expected = format!( + "(10,10,0,10,2,3,'O''Ryan''s Source',{},{})", + expected[0], expected[1] + ); + assert!( + catalogue + .statements + .iter() + .any(|sql| sql.contains(&expected)), + "{entries:?}" + ); + assert_eq!(catalogue.warnings.len(), warning_count); + if warning_count != 0 { + assert!(catalogue.warnings[0].contains("creature 9999")); + } + } + } + + #[test] + fn taxi_mounts_use_the_first_nonzero_template_model() { + let (nodes, paths, points) = synthetic_taxi_catalogue(); + let catalogue = super::taxi_catalogue_sql( + &nodes, + &paths, + &points, + "INSERT INTO `creature_template` VALUES \ + (1100,'Horde mount','',1,1, 0, 295, 1147, 0), + (1101,'Alliance mount','',1,1,0,0,0,1147);", + &HashSet::from([295, 1147]), + ) + .unwrap(); + assert!(catalogue + .statements + .iter() + .any(|sql| sql.contains("(10,10,0,10,2,3,'O''Ryan''s Source',295,1147)"))); + } + + #[test] + fn taxi_mounts_without_a_client_display_stay_unavailable() { + let (nodes, paths, points) = synthetic_taxi_catalogue(); + let catalogue = super::taxi_catalogue_sql( + &nodes, + &paths, + &points, + "INSERT INTO `creature_template` VALUES \ + (1100,'Unknown model','',1,1,9999,0,0,0), + (1101,'No model','',1,1,0,0,0,0);", + &HashSet::from([295, 1147]), + ) + .unwrap(); + assert!(catalogue + .statements + .iter() + .any(|sql| sql.contains("(10,10,0,10,2,3,'O''Ryan''s Source',0,0)"))); + assert!(catalogue + .warnings + .iter() + .any(|warning| warning.contains("creature 1100"))); + assert!(catalogue + .warnings + .iter() + .any(|warning| warning.contains("creature 1101"))); + } + + #[test] + fn taxi_catalogue_refuses_unusable_template_dumps_before_returning_sql() { + let (nodes, paths, points) = synthetic_taxi_catalogue(); + for dump in [ + "", + "INSERT INTO `creature_template` VALUES (1100,'Short row');", + "INSERT INTO `creature_template` VALUES (1100,'Bad model','',1,1,'bad',0,0,0);", + "INSERT INTO `creature_template` VALUES ('bad','Bad entry','',1,1,295,0,0,0);", + "INSERT INTO `creature_template` VALUES \ + (1100,'First','',1,1,295,0,0,0),(1100,'Duplicate','',1,1,1147,0,0,0);", + ] { + assert!( + super::taxi_catalogue_sql( + &nodes, + &paths, + &points, + dump, + &HashSet::from([295, 1147]) + ) + .is_err(), + "{dump}" + ); + } + } + + #[test] + fn taxi_catalogue_refuses_truncated_template_inserts() { + let (nodes, paths, points) = synthetic_taxi_catalogue(); + for tail in [",(1101,'Unfinished", ",(1101,'Mount',1", ""] { + let dump = format!( + "INSERT INTO `creature_template` VALUES (1100,'Mount','',1,1,295,0,0,0){tail}" + ); + let error = super::taxi_catalogue_sql( + &nodes, + &paths, + &points, + &dump, + &HashSet::from([295, 1147]), + ) + .unwrap_err(); + assert!(error + .to_string() + .contains("unterminated creature_template INSERT")); + } + } + #[test] fn taxi_catalogue_sql_preserves_direction_fare_mounts_and_point_order() { let (nodes, paths, points) = synthetic_taxi_catalogue(); @@ -1726,7 +1934,7 @@ mod tests { statements: stmts, counts, warnings, - } = taxi_catalogue_sql(&nodes, &paths, &points).unwrap(); + } = catalogue_with_mounts(&nodes, &paths, &points).unwrap(); assert!(warnings.is_empty()); assert_eq!( counts, @@ -1751,8 +1959,8 @@ mod tests { .find(|sql| sql.starts_with("INSERT INTO game_taxi_node ")) .unwrap(); assert!( - node_insert.contains("(10,10,0,10,2,3,'O''Ryan''s Source',1100,1101)"), - "Horde/Alliance DBC slots and escaped names must survive: {node_insert}" + node_insert.contains("(10,10,0,10,2,3,'O''Ryan''s Source',295,1147)"), + "Horde/Alliance creature entries must resolve to displays: {node_insert}" ); let path_insert = stmts @@ -1789,7 +1997,7 @@ mod tests { statements: stmts, counts, warnings, - } = taxi_catalogue_sql(&nodes, &paths, &points).unwrap(); + } = catalogue_with_mounts(&nodes, &paths, &points).unwrap(); assert_eq!(counts.path_nodes, 3); assert_eq!( @@ -1818,7 +2026,7 @@ mod tests { statements: stmts, counts, warnings, - } = taxi_catalogue_sql(&nodes, &paths, &points).unwrap(); + } = catalogue_with_mounts(&nodes, &paths, &points).unwrap(); assert_eq!( counts, @@ -1854,7 +2062,7 @@ mod tests { use lyracore_shared::constants::taxi_fixture as fixture; let (nodes, paths, points) = synthetic_taxi_catalogue(); - let first = taxi_catalogue_sql(&nodes, &paths, &points) + let first = catalogue_with_mounts(&nodes, &paths, &points) .unwrap() .statements; @@ -1862,7 +2070,7 @@ mod tests { nodes_reversed.rows.reverse(); let mut points_reversed = points.clone(); points_reversed.rows.reverse(); - let second = taxi_catalogue_sql(&nodes_reversed, &paths, &points_reversed) + let second = catalogue_with_mounts(&nodes_reversed, &paths, &points_reversed) .unwrap() .statements; assert_eq!( @@ -1913,7 +2121,7 @@ mod tests { rows: vec![taxi_path_row(70, 10, 999, 125)], }; let no_points = DbcTaxiPathNode { rows: vec![] }; - let err = taxi_catalogue_sql(&nodes, &bad_path, &no_points) + let err = catalogue_with_mounts(&nodes, &bad_path, &no_points) .unwrap_err() .to_string(); assert!(err.contains("missing destination node 999"), "{err}"); @@ -1922,7 +2130,7 @@ mod tests { #[test] fn taxi_catalogue_rejects_an_empty_file_before_emitting_clear_sql() { let (nodes, paths, _) = synthetic_taxi_catalogue(); - let err = taxi_catalogue_sql(&nodes, &paths, &DbcTaxiPathNode { rows: vec![] }) + let err = catalogue_with_mounts(&nodes, &paths, &DbcTaxiPathNode { rows: vec![] }) .unwrap_err() .to_string(); assert!(err.contains("TaxiPathNode=0 rows"), "{err}"); @@ -1937,7 +2145,7 @@ mod tests { let point = DbcTaxiPathNode { rows: vec![taxi_point_row(701, 70, 0, 10.0, 0, 0)], }; - assert!(taxi_catalogue_sql(&nodes, &negative_fare, &point) + assert!(catalogue_with_mounts(&nodes, &negative_fare, &point) .unwrap_err() .to_string() .contains("negative fare")); @@ -1948,7 +2156,7 @@ mod tests { let negative_index = DbcTaxiPathNode { rows: vec![taxi_point_row(701, 70, -1, 10.0, 0, 0)], }; - assert!(taxi_catalogue_sql(&nodes, &path, &negative_index) + assert!(catalogue_with_mounts(&nodes, &path, &negative_index) .unwrap_err() .to_string() .contains("negative node index")); @@ -1956,7 +2164,7 @@ mod tests { let negative_delay = DbcTaxiPathNode { rows: vec![taxi_point_row(701, 70, 0, 10.0, 0, -1)], }; - assert!(taxi_catalogue_sql(&nodes, &path, &negative_delay) + assert!(catalogue_with_mounts(&nodes, &path, &negative_delay) .unwrap_err() .to_string() .contains("negative delay")); @@ -1969,7 +2177,7 @@ mod tests { 0.0, )], }; - assert!(taxi_catalogue_sql( + assert!(catalogue_with_mounts( &reserved_nodes, &DbcTaxiPath { rows: vec![] }, &DbcTaxiPathNode { rows: vec![] } @@ -1985,7 +2193,7 @@ mod tests { let out_of_range = DbcTaxiNodes { rows: vec![taxi_node_row(id, "unrepresentable", [1, 2], 0.0)], }; - assert!(taxi_catalogue_sql( + assert!(catalogue_with_mounts( &out_of_range, &DbcTaxiPath { rows: vec![] }, &DbcTaxiPathNode { rows: vec![] } @@ -2003,7 +2211,7 @@ mod tests { 0.0, )], }; - assert!(taxi_catalogue_sql( + assert!(catalogue_with_mounts( &wire_collision, &DbcTaxiPath { rows: vec![] }, &DbcTaxiPathNode { rows: vec![] } diff --git a/importer/src/main.rs b/importer/src/main.rs index f6ecc40d0..c95257223 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -17,6 +17,7 @@ //! --dbc client DBC extraction/checks (dbc.rs); add --spells for the //! Spell.dbc importer (spell.rs) or --talents for the //! TalentTab.dbc/Talent.dbc importer (talent.rs) +//! Standalone catalogue import needs --taxi-mounts . //! Required with --dump when items reference Random Properties. //! [--packages reapplies every enabled //! Package's Delta claims after the base spell rows are back; @@ -45,7 +46,7 @@ //! for distribution. Reads no client, refuses baseline-derived //! files, takes no --apply (pack_client.rs) //! e.g. importer --dump .import/classic-db-full.sql --apply -//! importer --dbc ../wowclient/Data +//! importer --dbc ../wowclient/Data --taxi-mounts .import/classic-db-full.sql //! importer --dbc ../wowclient/Data --talents --apply //! importer --dump .import/classic-db-full.sql --map 0 --print-extents @@ -882,9 +883,22 @@ mod ai { // single-quoted strings with `\` escapes (names contain commas/apostrophes). Returns each field as // an unescaped String (quotes stripped). We PARSE, never execute (the firewall: no GPL code runs). pub(crate) fn parse_table(dump: &str, table: &str) -> Vec> { + parse_table_inserts(dump, table).0 +} + +pub(crate) fn parse_complete_table(dump: &str, table: &str) -> Result>> { + let (rows, complete) = parse_table_inserts(dump, table); + if !complete { + bail!("unterminated {table} INSERT in dump"); + } + Ok(rows) +} + +fn parse_table_inserts(dump: &str, table: &str) -> (Vec>, bool) { let needle = format!("INSERT INTO `{table}` VALUES "); let mut rows = Vec::new(); let mut search = 0; + let mut complete = true; while let Some(rel) = dump[search..].find(&needle) { let mut i = search + rel + needle.len(); let bytes = dump.as_bytes(); @@ -935,9 +949,10 @@ pub(crate) fn parse_table(dump: &str, table: &str) -> Vec> { } i += 1; } + complete &= i < bytes.len() && !in_tuple && !in_quote; search = i; } - rows + (rows, complete) } pub(crate) fn field(row: &[String], idx: usize) -> &str { @@ -989,6 +1004,7 @@ pub(crate) fn sql_text(s: &str) -> String { } pub(crate) struct Args { + taxi_mounts: Option, dump: Option, // cmangos creature ETL input (.sql[.gz]) dbc: Option, // client Data/ dir for the DBC stream pub(crate) terrain: Option, // client Data/ dir for the ADT heightmap stream (see terrain.rs) @@ -1063,6 +1079,7 @@ where { let mut a = Args { dump: None, + taxi_mounts: None, dbc: None, pack_client: None, pack_out: None, @@ -1099,6 +1116,9 @@ where let mut it = args.into_iter().map(Into::into); while let Some(arg) = it.next() { match arg.as_str() { + "--taxi-mounts" => { + a.taxi_mounts = Some(it.next().context("--taxi-mounts needs a dump path")?) + } "--dump" => a.dump = Some(it.next().context("--dump needs a path")?), "--terrain" => { a.terrain = Some(it.next().context("--terrain needs the client Data/ dir")?) @@ -1333,6 +1353,24 @@ where ); } } + if a.taxi_mounts.is_some() + && (a.dbc.is_none() + || a.dump.is_some() + || a.spells + || a.talents + || a.spell_snapshot.is_some() + || a.terrain.is_some() + || a.dump_collision.is_some() + || a.nav.is_some() + || a.vmap.is_some() + || a.vmap_status + || a.vmap_prepare_coverage.is_some() + || a.go_models.is_some() + || a.pack_client.is_some() + || a.pack_out.is_some()) + { + bail!("--taxi-mounts is only valid with standalone --dbc catalogue import"); + } // Need at least one input. `--dump` alone = cmangos creatures; `--dbc` alone = standalone DBC // mode (proof/checks); BOTH = cmangos creatures with DBC-resolved scale (display-derived). if a.dump.is_none() @@ -8259,12 +8297,44 @@ mod tests { ); } + #[test] + fn taxi_mount_dump_belongs_to_standalone_dbc_mode() { + let args = + parse_args_from(["--dbc", "client/Data", "--taxi-mounts", "world.sql.gz"]).unwrap(); + assert_eq!(args.taxi_mounts.as_deref(), Some("world.sql.gz")); + assert!(args.dump.is_none()); + for mode in ["--spells", "--talents"] { + assert!( + parse_args_from(["--dbc", "client/Data", "--taxi-mounts", "world.sql", mode]) + .is_err() + ); + } + assert!(parse_args_from(["--taxi-mounts", "world.sql"]).is_err()); + assert!(parse_args_from([ + "--dbc", + "client/Data", + "--taxi-mounts", + "world.sql", + "--dump", + "world.sql" + ]) + .is_err()); + } + + #[test] + fn standalone_dbc_requires_mount_templates_before_opening_inputs() { + let args = parse_args_from(["--dbc", "/nonexistent/client/Data"]).unwrap(); + let error = dbc::run("/nonexistent/client/Data", &args).unwrap_err(); + assert!(error.to_string().contains("--taxi-mounts")); + } + /// A default `Args` for tests that need the struct but not real CLI input (mirrors `parse_args`'s /// defaults where it matters for `build_dump_plan`: center/radius match the Northshire default so /// a fixture creature placed exactly at the center lands in scope). fn test_args() -> Args { Args { dump: None, + taxi_mounts: None, dbc: None, terrain: None, packages: None,