From 12e77e69ceefb30139e88b3e1eca73bfe6678034 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 10 May 2026 03:54:39 -0700 Subject: [PATCH] feat(pipeline): WTSC transit schedule catalog (128th open format) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Novel replacement for the implicit taxi/zeppelin/boat scheduling that vanilla WoW drove from a tangle of TaxiNodes.dbc + TaxiPath.dbc + per-zeppelin GameObject scripts + hard-coded transport interval timers in the server's MapManager. Each WTSC entry binds one scheduled passenger route to its origin / destination coords, vehicle type (Taxi/Zeppelin/Boat/Mount), departure interval, in-flight duration, capacity, and faction- access gate. Initially designed with magic 'WTRN' but discovered collision with existing trainers catalog (also WTRN) — renamed to 'WTSC' (Transit SChedule) and updated all CLI flags. Three presets: --gen-trn-zeppelins 3 vanilla Horde zeppelin routes (OG<->UC 240s interval, OG<->Grom'Gol, UC<->Grom'Gol) --gen-trn-boats 3 vanilla boat routes (Auberdine<-> Stormwind Alliance, Menethil<->Theramore Alliance, BootyBay<->Ratchet Neutral cross-faction) --gen-trn-taxis 3 taxi gryphon/wyvern routes — capacity=0 indicates solo gryphon ride CRITICAL scheduling invariant validator catches: when capacity > 0 the departureInterval MUST be >= travelDuration. A zeppelin with interval=60s + travel=90s with capacity=40 would overflow the vehicle pool — next zeppelin departs before prior arrives. Solo gryphon (capacity=0) is exempt because each ride is independent. Validator also catches: id+name+origin+destination required, vehicleType/factionAccess range, zero intervals/travel, duplicate routeIds, duplicate route names. Warns on same-map routes (originMapId == destinationMapId) — preset taxi route Crossroads to Razor Hill triggered this warning in smoke-test (both in Kalimdor mapId=1, intentional). Format count 127 -> 128. CLI flag count 1346 -> 1353. --- CMakeLists.txt | 3 + include/pipeline/wowee_transit_schedule.hpp | 158 +++++++++ src/pipeline/wowee_transit_schedule.cpp | 328 ++++++++++++++++++ tools/editor/cli_arg_required.cpp | 2 + tools/editor/cli_dispatch.cpp | 2 + tools/editor/cli_format_table.cpp | 1 + tools/editor/cli_help.cpp | 10 + tools/editor/cli_list_formats.cpp | 1 + tools/editor/cli_transit_schedule_catalog.cpp | 321 +++++++++++++++++ tools/editor/cli_transit_schedule_catalog.hpp | 12 + 10 files changed, 838 insertions(+) create mode 100644 include/pipeline/wowee_transit_schedule.hpp create mode 100644 src/pipeline/wowee_transit_schedule.cpp create mode 100644 tools/editor/cli_transit_schedule_catalog.cpp create mode 100644 tools/editor/cli_transit_schedule_catalog.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f5557af9..8bf51bc1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -716,6 +716,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_addon_manifest.cpp src/pipeline/wowee_spell_pack.cpp src/pipeline/wowee_player_movement_anim.cpp + src/pipeline/wowee_transit_schedule.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1595,6 +1596,7 @@ add_executable(wowee_editor tools/editor/cli_addon_manifest_catalog.cpp tools/editor/cli_spell_pack_catalog.cpp tools/editor/cli_player_movement_anim_catalog.cpp + tools/editor/cli_transit_schedule_catalog.cpp tools/editor/cli_catalog_pluck.cpp tools/editor/cli_catalog_find.cpp tools/editor/cli_catalog_by_name.cpp @@ -1793,6 +1795,7 @@ add_executable(wowee_editor src/pipeline/wowee_addon_manifest.cpp src/pipeline/wowee_spell_pack.cpp src/pipeline/wowee_player_movement_anim.cpp + src/pipeline/wowee_transit_schedule.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_transit_schedule.hpp b/include/pipeline/wowee_transit_schedule.hpp new file mode 100644 index 00000000..36550b9c --- /dev/null +++ b/include/pipeline/wowee_transit_schedule.hpp @@ -0,0 +1,158 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +// Wowee Open Transit Schedule catalog (.wtsc) — +// novel replacement for the implicit +// taxi/zeppelin/boat scheduling that vanilla WoW +// drove from a tangle of TaxiNodes.dbc + +// TaxiPath.dbc + per-zeppelin GameObject scripts + +// hard-coded transport interval timers in the +// server's MapManager. Each WTRN entry binds one +// scheduled passenger route to its origin / +// destination coordinates, vehicle type +// (Taxi/Zeppelin/Boat/Mount), departure interval, +// in-flight duration, capacity, and faction-access +// gate. +// +// Cross-references with previously-added formats: +// WMS: originMapId / destinationMapId reference +// the WMS map catalog. +// WTAX: vehicleType=Taxi routes are derived from +// (and extend) WTAX taxi-node catalog — +// WTRN adds the scheduling layer that WTAX +// lacked. +// +// Binary layout (little-endian): +// magic[4] = "WTSC" +// version (uint32) = current 1 +// nameLen + name (catalog label) +// entryCount (uint32) +// entries (each): +// routeId (uint32) +// nameLen + name +// vehicleType (uint8) — 0=Taxi / +// 1=Zeppelin / +// 2=Boat / +// 3=Mount +// factionAccess (uint8) — 0=Both / +// 1=Alliance / +// 2=Horde / +// 3=Neutral +// pad0 (uint16) +// originLen + originName +// originX (float) +// originY (float) +// originMapId (uint32) +// destinationLen + destinationName +// destinationX (float) +// destinationY (float) +// destinationMapId (uint32) +// departureIntervalSec (uint32) — period between +// successive +// departures from +// origin +// travelDurationSec (uint32) — in-flight time +// origin->dest +// capacity (uint16) — max simultaneous +// riders (0 = +// unlimited, e.g. +// solo gryphon) +// pad1 (uint16) +struct WoweeTransitSchedule { + enum VehicleType : uint8_t { + Taxi = 0, + Zeppelin = 1, + Boat = 2, + Mount = 3, // hired riding-mount + // (e.g., kodo + // caravan in vanilla + // Barrens) + }; + + enum FactionAccess : uint8_t { + Both = 0, + Alliance = 1, + Horde = 2, + Neutral = 3, // Booty Bay-style + // cross-faction routes + }; + + struct Entry { + uint32_t routeId = 0; + std::string name; + uint8_t vehicleType = Taxi; + uint8_t factionAccess = Both; + uint16_t pad0 = 0; + std::string originName; + float originX = 0.f; + float originY = 0.f; + uint32_t originMapId = 0; + std::string destinationName; + float destinationX = 0.f; + float destinationY = 0.f; + uint32_t destinationMapId = 0; + uint32_t departureIntervalSec = 0; + uint32_t travelDurationSec = 0; + uint16_t capacity = 0; + uint16_t pad1 = 0; + }; + + std::string name; + std::vector entries; + + bool isValid() const { return !entries.empty(); } + + const Entry* findById(uint32_t routeId) const; + + // Returns all routes accessible by a given faction + // mask (Alliance/Horde/Neutral all see Both routes; + // a faction-specific call also includes that + // faction's exclusive routes). + std::vector findAccessibleByFaction( + uint8_t faction) const; + + // Returns all routes departing from a given + // origin map. Used by the boat-dock UI to + // populate the "next departure" widget. + std::vector findDeparturesFromMap( + uint32_t mapId) const; +}; + +class WoweeTransitScheduleLoader { +public: + static bool save(const WoweeTransitSchedule& cat, + const std::string& basePath); + static WoweeTransitSchedule load(const std::string& basePath); + static bool exists(const std::string& basePath); + + // Preset emitters used by --gen-trn* variants. + // + // makeZeppelins — 3 vanilla zeppelin routes + // (Orgrimmar<->Undercity 240s + // interval / OG<->Grom'gol / + // UC<->Grom'gol). All Horde-only. + // makeBoats — 3 vanilla boat routes + // (Auberdine<->Stormwind / + // Menethil<->Theramore / + // BootyBay<->Ratchet — last is + // Neutral, both factions can + // board). + // makeTaxis — 3 taxi gryphon/wyvern routes + // (Stormwind<->Ironforge + // Alliance / Crossroads<-> + // Razor Hill Horde / + // Booty Bay<->Stormwind + // Neutral). + static WoweeTransitSchedule makeZeppelins(const std::string& catalogName); + static WoweeTransitSchedule makeBoats(const std::string& catalogName); + static WoweeTransitSchedule makeTaxis(const std::string& catalogName); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_transit_schedule.cpp b/src/pipeline/wowee_transit_schedule.cpp new file mode 100644 index 00000000..04e83a7b --- /dev/null +++ b/src/pipeline/wowee_transit_schedule.cpp @@ -0,0 +1,328 @@ +#include "pipeline/wowee_transit_schedule.hpp" + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'T', 'S', 'C'}; +constexpr uint32_t kVersion = 1; + +template +void writePOD(std::ofstream& os, const T& v) { + os.write(reinterpret_cast(&v), sizeof(T)); +} + +template +bool readPOD(std::ifstream& is, T& v) { + is.read(reinterpret_cast(&v), sizeof(T)); + return is.gcount() == static_cast(sizeof(T)); +} + +void writeStr(std::ofstream& os, const std::string& s) { + uint32_t n = static_cast(s.size()); + writePOD(os, n); + if (n > 0) os.write(s.data(), n); +} + +bool readStr(std::ifstream& is, std::string& s) { + uint32_t n = 0; + if (!readPOD(is, n)) return false; + if (n > (1u << 20)) return false; + s.resize(n); + if (n > 0) { + is.read(s.data(), n); + if (is.gcount() != static_cast(n)) { + s.clear(); + return false; + } + } + return true; +} + +std::string normalizePath(std::string base) { + if (base.size() < 5 || base.substr(base.size() - 5) != ".wtsc") { + base += ".wtsc"; + } + return base; +} + +} // namespace + +const WoweeTransitSchedule::Entry* +WoweeTransitSchedule::findById(uint32_t routeId) const { + for (const auto& e : entries) + if (e.routeId == routeId) return &e; + return nullptr; +} + +std::vector +WoweeTransitSchedule::findAccessibleByFaction(uint8_t faction) const { + std::vector out; + for (const auto& e : entries) { + if (e.factionAccess == Both) { + out.push_back(&e); + } else if (faction != Both && e.factionAccess == faction) { + out.push_back(&e); + } else if (e.factionAccess == Neutral) { + // Neutral routes are accessible to ALL + // factions including Both — Booty Bay / + // Ratchet style. + out.push_back(&e); + } + } + return out; +} + +std::vector +WoweeTransitSchedule::findDeparturesFromMap(uint32_t mapId) const { + std::vector out; + for (const auto& e : entries) { + if (e.originMapId == mapId) out.push_back(&e); + } + return out; +} + +bool WoweeTransitScheduleLoader::save( + const WoweeTransitSchedule& cat, + const std::string& basePath) { + std::ofstream os(normalizePath(basePath), std::ios::binary); + if (!os) return false; + os.write(kMagic, 4); + writePOD(os, kVersion); + writeStr(os, cat.name); + uint32_t entryCount = static_cast(cat.entries.size()); + writePOD(os, entryCount); + for (const auto& e : cat.entries) { + writePOD(os, e.routeId); + writeStr(os, e.name); + writePOD(os, e.vehicleType); + writePOD(os, e.factionAccess); + writePOD(os, e.pad0); + writeStr(os, e.originName); + writePOD(os, e.originX); + writePOD(os, e.originY); + writePOD(os, e.originMapId); + writeStr(os, e.destinationName); + writePOD(os, e.destinationX); + writePOD(os, e.destinationY); + writePOD(os, e.destinationMapId); + writePOD(os, e.departureIntervalSec); + writePOD(os, e.travelDurationSec); + writePOD(os, e.capacity); + writePOD(os, e.pad1); + } + return os.good(); +} + +WoweeTransitSchedule WoweeTransitScheduleLoader::load( + const std::string& basePath) { + WoweeTransitSchedule out; + std::ifstream is(normalizePath(basePath), std::ios::binary); + if (!is) return out; + char magic[4]; + is.read(magic, 4); + if (std::memcmp(magic, kMagic, 4) != 0) return out; + uint32_t version = 0; + if (!readPOD(is, version) || version != kVersion) return out; + if (!readStr(is, out.name)) return out; + uint32_t entryCount = 0; + if (!readPOD(is, entryCount)) return out; + if (entryCount > (1u << 20)) return out; + out.entries.resize(entryCount); + for (auto& e : out.entries) { + if (!readPOD(is, e.routeId)) { + out.entries.clear(); return out; + } + if (!readStr(is, e.name)) { + out.entries.clear(); return out; + } + if (!readPOD(is, e.vehicleType) || + !readPOD(is, e.factionAccess) || + !readPOD(is, e.pad0)) { + out.entries.clear(); return out; + } + if (!readStr(is, e.originName)) { + out.entries.clear(); return out; + } + if (!readPOD(is, e.originX) || + !readPOD(is, e.originY) || + !readPOD(is, e.originMapId)) { + out.entries.clear(); return out; + } + if (!readStr(is, e.destinationName)) { + out.entries.clear(); return out; + } + if (!readPOD(is, e.destinationX) || + !readPOD(is, e.destinationY) || + !readPOD(is, e.destinationMapId)) { + out.entries.clear(); return out; + } + if (!readPOD(is, e.departureIntervalSec) || + !readPOD(is, e.travelDurationSec) || + !readPOD(is, e.capacity) || + !readPOD(is, e.pad1)) { + out.entries.clear(); return out; + } + } + return out; +} + +bool WoweeTransitScheduleLoader::exists( + const std::string& basePath) { + std::ifstream is(normalizePath(basePath), std::ios::binary); + return is.good(); +} + +WoweeTransitSchedule WoweeTransitScheduleLoader::makeZeppelins( + const std::string& catalogName) { + using T = WoweeTransitSchedule; + WoweeTransitSchedule c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, + const char* origin, float ox, float oy, + uint32_t omap, + const char* dest, float dx, float dy, + uint32_t dmap, + uint32_t intervalSec, + uint32_t travelSec, + uint16_t capacity) { + T::Entry e; + e.routeId = id; e.name = name; + e.vehicleType = T::Zeppelin; + e.factionAccess = T::Horde; + e.originName = origin; + e.originX = ox; e.originY = oy; + e.originMapId = omap; + e.destinationName = dest; + e.destinationX = dx; e.destinationY = dy; + e.destinationMapId = dmap; + e.departureIntervalSec = intervalSec; + e.travelDurationSec = travelSec; + e.capacity = capacity; + c.entries.push_back(e); + }; + // Vanilla zeppelin tower coordinates (Orgrimmar + // map=1, Eastern Kingdoms via UC map=0, + // Stranglethorn via Grom'Gol map=0). Capacity 40 + // approximates the rim platform headcount. + add(1, "Orgrimmar to Undercity", + "Orgrimmar Zeppelin Tower", 1843.f, -4416.f, 1, + "Tirisfal Zeppelin Tower", 2055.f, 273.f, 0, + 240, 60, 40); + add(2, "Orgrimmar to Grom'Gol", + "Orgrimmar Zeppelin Tower", 1843.f, -4416.f, 1, + "Grom'Gol Zeppelin Tower", -12422.f, 110.f, 0, + 240, 90, 40); + add(3, "Undercity to Grom'Gol", + "Tirisfal Zeppelin Tower", 2055.f, 273.f, 0, + "Grom'Gol Zeppelin Tower", -12422.f, 110.f, 0, + 240, 90, 40); + return c; +} + +WoweeTransitSchedule WoweeTransitScheduleLoader::makeBoats( + const std::string& catalogName) { + using T = WoweeTransitSchedule; + WoweeTransitSchedule c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, + uint8_t faction, + const char* origin, float ox, float oy, + uint32_t omap, + const char* dest, float dx, float dy, + uint32_t dmap, + uint32_t intervalSec, + uint32_t travelSec, + uint16_t capacity) { + T::Entry e; + e.routeId = id; e.name = name; + e.vehicleType = T::Boat; + e.factionAccess = faction; + e.originName = origin; + e.originX = ox; e.originY = oy; + e.originMapId = omap; + e.destinationName = dest; + e.destinationX = dx; e.destinationY = dy; + e.destinationMapId = dmap; + e.departureIntervalSec = intervalSec; + e.travelDurationSec = travelSec; + e.capacity = capacity; + c.entries.push_back(e); + }; + add(10, "Auberdine to Stormwind Harbor", + T::Alliance, + "Auberdine Dock", 6577.f, 769.f, 1, + "Stormwind Harbor", -8713.f, 1281.f, 0, + 300, 90, 30); + add(11, "Menethil Harbor to Theramore", + T::Alliance, + "Menethil Harbor", -3814.f, -616.f, 0, + "Theramore Isle", -3870.f, -4533.f, 1, + 300, 90, 30); + add(12, "Booty Bay to Ratchet", + T::Neutral, // both factions + // may board + "Booty Bay Dock", -14305.f, 570.f, 0, + "Ratchet Dock", -984.f, -3835.f, 1, + 180, 75, 35); + return c; +} + +WoweeTransitSchedule WoweeTransitScheduleLoader::makeTaxis( + const std::string& catalogName) { + using T = WoweeTransitSchedule; + WoweeTransitSchedule c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, + uint8_t faction, + const char* origin, float ox, float oy, + uint32_t omap, + const char* dest, float dx, float dy, + uint32_t dmap, + uint32_t intervalSec, + uint32_t travelSec, + uint16_t capacity) { + T::Entry e; + e.routeId = id; e.name = name; + e.vehicleType = T::Taxi; + e.factionAccess = faction; + e.originName = origin; + e.originX = ox; e.originY = oy; + e.originMapId = omap; + e.destinationName = dest; + e.destinationX = dx; e.destinationY = dy; + e.destinationMapId = dmap; + e.departureIntervalSec = intervalSec; + e.travelDurationSec = travelSec; + e.capacity = capacity; + c.entries.push_back(e); + }; + // Capacity=0 for taxis: each gryphon/wyvern is a + // solo ride, no shared seating — interval matters + // only for the visual gryphon respawn timer at the + // taxi master. + add(20, "Stormwind to Ironforge", + T::Alliance, + "Stormwind, Eastvale", -8836.f, 490.f, 0, + "Ironforge, Tinkertown", -4815.f, -1170.f, 0, + 30, 200, 0); + add(21, "Crossroads to Razor Hill", + T::Horde, + "The Crossroads", -445.f, -2598.f, 1, + "Razor Hill", 314.f, -4748.f, 1, + 30, 130, 0); + add(22, "Booty Bay to Stormwind", + T::Neutral, + "Booty Bay Gryphon Master", -14373.f, 555.f, 0, + "Stormwind, Eastvale", -8836.f, 490.f, 0, + 30, 320, 0); + return c; +} + +} // namespace pipeline +} // namespace wowee diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index 6b0d1b7c..9168574e 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -392,6 +392,8 @@ const char* const kArgRequired[] = { "--gen-phm-human", "--gen-phm-orc", "--gen-phm-undead", "--info-wphm", "--validate-wphm", "--export-wphm-json", "--import-wphm-json", + "--gen-trn-zeppelins", "--gen-trn-boats", "--gen-trn-taxis", + "--info-wtsc", "--validate-wtsc", "--gen-weather-temperate", "--gen-weather-arctic", "--gen-weather-desert", "--gen-weather-stormy", "--gen-zone-atmosphere", diff --git a/tools/editor/cli_dispatch.cpp b/tools/editor/cli_dispatch.cpp index 2519a862..20bf83ab 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -172,6 +172,7 @@ #include "cli_addon_manifest_catalog.hpp" #include "cli_spell_pack_catalog.hpp" #include "cli_player_movement_anim_catalog.hpp" +#include "cli_transit_schedule_catalog.hpp" #include "cli_catalog_pluck.hpp" #include "cli_catalog_find.hpp" #include "cli_catalog_by_name.hpp" @@ -389,6 +390,7 @@ constexpr DispatchFn kDispatchTable[] = { handleAddonManifestCatalog, handleSpellPackCatalog, handlePlayerMovementAnimCatalog, + handleTransitScheduleCatalog, handleCatalogPluck, handleCatalogFind, handleCatalogByName, diff --git a/tools/editor/cli_format_table.cpp b/tools/editor/cli_format_table.cpp index 19831451..ca7def8f 100644 --- a/tools/editor/cli_format_table.cpp +++ b/tools/editor/cli_format_table.cpp @@ -130,6 +130,7 @@ constexpr FormatMagicEntry kFormats[] = { {{'W','M','O','D'}, ".wmod", "addons", "--info-wmod", "Addon manifest catalog"}, {{'W','S','P','K'}, ".wspk", "spells", "--info-wspk", "Spell pack catalog"}, {{'W','P','H','M'}, ".wphm", "anim", "--info-wphm", "Player movement-to-animation map"}, + {{'W','T','S','C'}, ".wtsc", "transit", "--info-wtsc", "Transit schedule catalog"}, {{'W','F','A','C'}, ".wfac", "factions", nullptr, "Faction catalog"}, {{'W','L','C','K'}, ".wlck", "locks", nullptr, "Lock catalog"}, {{'W','S','K','L'}, ".wskl", "skills", nullptr, "Skill catalog"}, diff --git a/tools/editor/cli_help.cpp b/tools/editor/cli_help.cpp index d93b75d1..240a4153 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -2545,6 +2545,16 @@ void printUsage(const char* argv0) { std::printf(" Export binary .wphm to a human-editable JSON sidecar (defaults to .wphm.json; emits movementState as int + name string)\n"); std::printf(" --import-wphm-json [out-base]\n"); std::printf(" Import a .wphm.json sidecar back into binary .wphm (movementState int OR \"idle\"/\"walk\"/\"run\"/\"swim\"/\"fly\"/\"sit\"/\"mount\"/\"death\")\n"); + std::printf(" --gen-trn-zeppelins [name]\n"); + std::printf(" Emit .wtsc 3 vanilla Horde zeppelin routes (Orgrimmar<->Undercity 240s interval, OG<->Grom'Gol, UC<->Grom'Gol)\n"); + std::printf(" --gen-trn-boats [name]\n"); + std::printf(" Emit .wtsc 3 vanilla boat routes (Auberdine<->Stormwind Alliance, Menethil<->Theramore Alliance, Booty Bay<->Ratchet Neutral cross-faction)\n"); + std::printf(" --gen-trn-taxis [name]\n"); + std::printf(" Emit .wtsc 3 taxi gryphon/wyvern routes (Stormwind<->Ironforge Alliance, Crossroads<->Razor Hill Horde, Booty Bay<->Stormwind Neutral) — capacity=0 indicates solo gryphon ride\n"); + std::printf(" --info-wtsc [--json]\n"); + std::printf(" Print WTSC entries (id / vehicleType / factionAccess / departureIntervalSec / travelDurationSec / capacity / name)\n"); + std::printf(" --validate-wtsc [--json]\n"); + std::printf(" Static checks: id+name+origin+destination required, vehicleType 0..3, factionAccess 0..3, no zero intervals/travel, no duplicate routeIds, no duplicate route names; CRITICAL scheduling invariant: when capacity > 0 the departureInterval >= travelDuration (else vehicle pool overflow — next zeppelin departs before prior arrives). Warns on same-map routes (originMapId == destinationMapId) — verify intentional\n"); std::printf(" --catalog-pluck [--json]\n"); std::printf(" Extract one entry by id from any registered catalog format. Auto-detects magic, dispatches to the per-format --info-* handler internally, then prints just the matching entry. Primary-key field is auto-detected (first *Id field, or first numeric)\n"); std::printf(" --catalog-find [--magic ] [--json]\n"); diff --git a/tools/editor/cli_list_formats.cpp b/tools/editor/cli_list_formats.cpp index bce4f799..b010043d 100644 --- a/tools/editor/cli_list_formats.cpp +++ b/tools/editor/cli_list_formats.cpp @@ -152,6 +152,7 @@ constexpr FormatRow kFormats[] = { {"WMOD", ".wmod", "addons", "per-addon TOC text + load-order rules","Addon manifest catalog (deps + cycle detection)"}, {"WSPK", ".wspk", "spells", "SkillLineAbility + per-spec tab order","Spell pack catalog (per-class spellbook tab layout)"}, {"WPHM", ".wphm", "anim", "implicit M2 movementState->anim map","Player movement-to-animation map (per race/gender/state)"}, + {"WTSC", ".wtsc", "transit", "TaxiNodes + zeppelin GO scripts", "Transit schedule catalog (taxi/zeppelin/boat scheduled departures)"}, // Additional pipeline catalogs without the alternating // gen/info/validate CLI surface (loaded by the engine diff --git a/tools/editor/cli_transit_schedule_catalog.cpp b/tools/editor/cli_transit_schedule_catalog.cpp new file mode 100644 index 00000000..896dcdbb --- /dev/null +++ b/tools/editor/cli_transit_schedule_catalog.cpp @@ -0,0 +1,321 @@ +#include "cli_transit_schedule_catalog.hpp" +#include "cli_arg_parse.hpp" +#include "cli_box_emitter.hpp" + +#include "pipeline/wowee_transit_schedule.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::string stripWtscExt(std::string base) { + stripExt(base, ".wtsc"); + return base; +} + +const char* vehicleTypeName(uint8_t v) { + using T = wowee::pipeline::WoweeTransitSchedule; + switch (v) { + case T::Taxi: return "taxi"; + case T::Zeppelin: return "zeppelin"; + case T::Boat: return "boat"; + case T::Mount: return "mount"; + default: return "?"; + } +} + +const char* factionAccessName(uint8_t f) { + using T = wowee::pipeline::WoweeTransitSchedule; + switch (f) { + case T::Both: return "both"; + case T::Alliance: return "alliance"; + case T::Horde: return "horde"; + case T::Neutral: return "neutral"; + default: return "?"; + } +} + +bool saveOrError(const wowee::pipeline::WoweeTransitSchedule& c, + const std::string& base, const char* cmd) { + if (!wowee::pipeline::WoweeTransitScheduleLoader::save(c, base)) { + std::fprintf(stderr, "%s: failed to save %s.wtsc\n", + cmd, base.c_str()); + return false; + } + return true; +} + +void printGenSummary(const wowee::pipeline::WoweeTransitSchedule& c, + const std::string& base) { + std::printf("Wrote %s.wtsc\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" routes : %zu\n", c.entries.size()); +} + +int handleGenZeppelins(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "VanillaZeppelins"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWtscExt(base); + auto c = wowee::pipeline::WoweeTransitScheduleLoader:: + makeZeppelins(name); + if (!saveOrError(c, base, "gen-trn-zeppelins")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenBoats(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "VanillaBoats"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWtscExt(base); + auto c = wowee::pipeline::WoweeTransitScheduleLoader:: + makeBoats(name); + if (!saveOrError(c, base, "gen-trn-boats")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenTaxis(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "VanillaTaxis"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWtscExt(base); + auto c = wowee::pipeline::WoweeTransitScheduleLoader:: + makeTaxis(name); + if (!saveOrError(c, base, "gen-trn-taxis")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleInfo(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWtscExt(base); + if (!wowee::pipeline::WoweeTransitScheduleLoader::exists(base)) { + std::fprintf(stderr, "WTSC not found: %s.wtsc\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeTransitScheduleLoader::load(base); + if (jsonOut) { + nlohmann::json j; + j["wtsc"] = base + ".wtsc"; + j["name"] = c.name; + j["count"] = c.entries.size(); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : c.entries) { + arr.push_back({ + {"routeId", e.routeId}, + {"name", e.name}, + {"vehicleType", e.vehicleType}, + {"vehicleTypeName", + vehicleTypeName(e.vehicleType)}, + {"factionAccess", e.factionAccess}, + {"factionAccessName", + factionAccessName(e.factionAccess)}, + {"originName", e.originName}, + {"originX", e.originX}, + {"originY", e.originY}, + {"originMapId", e.originMapId}, + {"destinationName", e.destinationName}, + {"destinationX", e.destinationX}, + {"destinationY", e.destinationY}, + {"destinationMapId", e.destinationMapId}, + {"departureIntervalSec", + e.departureIntervalSec}, + {"travelDurationSec", e.travelDurationSec}, + {"capacity", e.capacity}, + }); + } + j["entries"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WTSC: %s.wtsc\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" routes : %zu\n", c.entries.size()); + if (c.entries.empty()) return 0; + std::printf(" id vehicle fact intv travel cap name\n"); + for (const auto& e : c.entries) { + std::printf(" %4u %-9s %-8s %4us %4us %4u %s\n", + e.routeId, + vehicleTypeName(e.vehicleType), + factionAccessName(e.factionAccess), + e.departureIntervalSec, + e.travelDurationSec, + e.capacity, e.name.c_str()); + } + return 0; +} + +int handleValidate(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWtscExt(base); + if (!wowee::pipeline::WoweeTransitScheduleLoader::exists(base)) { + std::fprintf(stderr, + "validate-wtsc: WTSC not found: %s.wtsc\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeTransitScheduleLoader::load(base); + std::vector errors; + std::vector warnings; + if (c.entries.empty()) { + warnings.push_back("catalog has zero entries"); + } + std::set idsSeen; + std::set namesSeen; + for (size_t k = 0; k < c.entries.size(); ++k) { + const auto& e = c.entries[k]; + std::string ctx = "entry " + std::to_string(k) + + " (id=" + std::to_string(e.routeId); + if (!e.name.empty()) ctx += " " + e.name; + ctx += ")"; + if (e.routeId == 0) + errors.push_back(ctx + ": routeId is 0"); + if (e.name.empty()) + errors.push_back(ctx + ": name is empty"); + if (e.originName.empty()) + errors.push_back(ctx + ": originName is empty"); + if (e.destinationName.empty()) + errors.push_back(ctx + + ": destinationName is empty"); + if (e.vehicleType > 3) { + errors.push_back(ctx + ": vehicleType " + + std::to_string(e.vehicleType) + + " out of range (0..3)"); + } + if (e.factionAccess > 3) { + errors.push_back(ctx + ": factionAccess " + + std::to_string(e.factionAccess) + + " out of range (0..3)"); + } + // Critical scheduling invariant: a new + // departure cannot leave before the previous + // one has arrived if capacity is finite — an + // interval shorter than travel would + // overflow the route's vehicle pool. (This + // doesn't apply to capacity==0 = solo + // gryphon, where each ride is independent.) + if (e.capacity > 0 && + e.departureIntervalSec > 0 && + e.travelDurationSec > 0 && + e.departureIntervalSec < e.travelDurationSec) { + errors.push_back(ctx + + ": departureIntervalSec=" + + std::to_string(e.departureIntervalSec) + + " < travelDurationSec=" + + std::to_string(e.travelDurationSec) + + " with finite capacity — vehicle pool " + "overflow (next zeppelin departs " + "before prior arrives)"); + } + if (e.departureIntervalSec == 0) { + errors.push_back(ctx + + ": departureIntervalSec is 0 (route " + "would never depart)"); + } + if (e.travelDurationSec == 0) { + errors.push_back(ctx + + ": travelDurationSec is 0 (route would " + "instant-teleport, not a transit)"); + } + // Same-map vehicle: not an error (some + // vanilla flightpaths cross only intra-zone) + // but is worth flagging — the reader may want + // to verify this is intentional. + if (e.originMapId == e.destinationMapId && + e.originMapId != 0) { + warnings.push_back(ctx + + ": originMapId == destinationMapId=" + + std::to_string(e.originMapId) + + " — same-map route, verify intentional"); + } + // No identical (origin, destination) pair within + // a single catalog — would be a duplicate route. + if (!e.name.empty() && + !namesSeen.insert(e.name).second) { + errors.push_back(ctx + + ": duplicate route name '" + e.name + + "' — UI dispatch would route ambiguously"); + } + if (!idsSeen.insert(e.routeId).second) { + errors.push_back(ctx + ": duplicate routeId"); + } + } + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["wtsc"] = base + ".wtsc"; + j["ok"] = ok; + j["errors"] = errors; + j["warnings"] = warnings; + std::printf("%s\n", j.dump(2).c_str()); + return ok ? 0 : 1; + } + std::printf("validate-wtsc: %s.wtsc\n", base.c_str()); + if (ok && warnings.empty()) { + std::printf(" OK — %zu routes, all routeIds + " + "names unique, vehicleType 0..3, " + "factionAccess 0..3, no zero " + "intervals/travel, no scheduling " + "overflow (interval >= travel where " + "capacity is finite)\n", + c.entries.size()); + return 0; + } + if (!warnings.empty()) { + std::printf(" warnings (%zu):\n", warnings.size()); + for (const auto& w : warnings) + std::printf(" - %s\n", w.c_str()); + } + if (!errors.empty()) { + std::printf(" ERRORS (%zu):\n", errors.size()); + for (const auto& e : errors) + std::printf(" - %s\n", e.c_str()); + } + return ok ? 0 : 1; +} + +} // namespace + +bool handleTransitScheduleCatalog(int& i, int argc, char** argv, + int& outRc) { + if (std::strcmp(argv[i], "--gen-trn-zeppelins") == 0 && + i + 1 < argc) { + outRc = handleGenZeppelins(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-trn-boats") == 0 && + i + 1 < argc) { + outRc = handleGenBoats(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-trn-taxis") == 0 && + i + 1 < argc) { + outRc = handleGenTaxis(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-wtsc") == 0 && i + 1 < argc) { + outRc = handleInfo(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-wtsc") == 0 && + i + 1 < argc) { + outRc = handleValidate(i, argc, argv); return true; + } + return false; +} + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_transit_schedule_catalog.hpp b/tools/editor/cli_transit_schedule_catalog.hpp new file mode 100644 index 00000000..17eb151f --- /dev/null +++ b/tools/editor/cli_transit_schedule_catalog.hpp @@ -0,0 +1,12 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleTransitScheduleCatalog(int& i, int argc, char** argv, + int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee