From 321c2610d0f82c9af1808e317c7f8241c4db1d89 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 9 May 2026 23:51:49 -0700 Subject: [PATCH] feat(editor): add WHLD (Instance Lockout Schedule) open catalog format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open replacement for the engine-side instance reset timer logic plus the per-map InstanceTemplate.dbc reset fields. Defines how often each (map × difficulty) combination resets its lockout, how many boss kills each character can claim per lockout window, and the number of bonus rolls available (Cataclysm+ stub for forward compatibility). One entry per (map × difficulty × group size). Icecrown Citadel 10-Normal weekly, ICC 25-Normal weekly, ICC 10-Heroic weekly, and ICC 25-Heroic weekly are four separate entries with the same mapId but different difficultyId and resetIntervalMs. Cross-references back to WMS (mapId), WCDF (difficultyId), and forward to WBOS — the encounters bound to one lockout are the WBOS entries whose (mapId, difficultyId) pair matches. Four lockout kinds capture the canonical reset cadences: - Daily (24h, 86400000ms) — heroic dungeons, daily quests - Weekly (7d, 604800000ms) — raid lockouts - SemiWeekly (3.5d, 302400000ms) — Cata+ split lockouts - Custom (arbitrary intervalMs) — Wintergrasp 2.5h, holiday events with non-standard cadence nextResetMs(lockoutId, currentMs) is the engine helper that returns the next reset wall-clock millis after a given current time, rounded up to the nearest interval boundary. The engine overrides the epoch with its configured server reset time (typically Tuesday 8:00am server-local), but the catalog provides the interval shape. The info renderer pretty-prints intervals: 86400000ms reads as "1d", 9000000ms as "150m", which matches how server admins think about reset cadences. Three preset emitters: --gen-hld (4 ICC raid weekly lockouts), --gen-hld-dungeon (4 5-man heroic daily lockouts), --gen-hld-event (3 world-event lockouts including Wintergrasp's canonical Custom 2.5h interval). Validation enforces id+name+kind+resetIntervalMs presence, no duplicate ids; warns on non-standard raidGroupSize, kind/interval mismatches (Daily kind without 24h interval, Weekly kind without 7d interval), and 0 boss kill cap (instance grants no lockout-bound progress, every visit is fresh). Wired through the cross-format table; WHLD appears in all 18 cross-format utilities. Format count 91 -> 92; CLI flag count 1062 -> 1067. --- CMakeLists.txt | 3 + include/pipeline/wowee_instance_lockouts.hpp | 121 ++++++++ src/pipeline/wowee_instance_lockouts.cpp | 252 +++++++++++++++++ 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 + .../editor/cli_instance_lockouts_catalog.cpp | 266 ++++++++++++++++++ .../editor/cli_instance_lockouts_catalog.hpp | 12 + tools/editor/cli_list_formats.cpp | 1 + 10 files changed, 670 insertions(+) create mode 100644 include/pipeline/wowee_instance_lockouts.hpp create mode 100644 src/pipeline/wowee_instance_lockouts.cpp create mode 100644 tools/editor/cli_instance_lockouts_catalog.cpp create mode 100644 tools/editor/cli_instance_lockouts_catalog.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1c5c56f5..a27cf93e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -680,6 +680,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_spell_procs.cpp src/pipeline/wowee_creature_patrols.cpp src/pipeline/wowee_boss_encounters.cpp + src/pipeline/wowee_instance_lockouts.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1523,6 +1524,7 @@ add_executable(wowee_editor tools/editor/cli_spell_procs_catalog.cpp tools/editor/cli_creature_patrols_catalog.cpp tools/editor/cli_boss_encounters_catalog.cpp + tools/editor/cli_instance_lockouts_catalog.cpp tools/editor/cli_quest_objective.cpp tools/editor/cli_quest_reward.cpp tools/editor/cli_clone.cpp @@ -1681,6 +1683,7 @@ add_executable(wowee_editor src/pipeline/wowee_spell_procs.cpp src/pipeline/wowee_creature_patrols.cpp src/pipeline/wowee_boss_encounters.cpp + src/pipeline/wowee_instance_lockouts.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_instance_lockouts.hpp b/include/pipeline/wowee_instance_lockouts.hpp new file mode 100644 index 00000000..e4250e4d --- /dev/null +++ b/include/pipeline/wowee_instance_lockouts.hpp @@ -0,0 +1,121 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +// Wowee Open Instance Lockout Schedule catalog (.whld) +// — novel replacement for the engine-side instance reset +// timer logic plus the per-map InstanceTemplate.dbc reset +// fields. Defines how often each (map × difficulty) +// combination resets its lockout, how many boss kills +// each character can claim per lockout window, and the +// number of bonus rolls available (Cataclysm+ feature +// stubbed for forward compatibility). +// +// One entry per (map × difficulty × group size) — Icecrown +// Citadel 10-Normal weekly, ICC 25-Normal weekly, ICC +// 10-Heroic weekly, and ICC 25-Heroic weekly are four +// separate entries with the same mapId but different +// difficultyId and resetIntervalMs. +// +// Cross-references with previously-added formats: +// WMS: mapId references the instance map. +// WCDF: difficultyId references the difficulty routing +// entry that maps base creature -> variants. +// WBOS: encounters in this lockout are the WBOS entries +// whose (mapId, difficultyId) pair matches. +// +// Binary layout (little-endian): +// magic[4] = "WHLD" +// version (uint32) = current 1 +// nameLen + name (catalog label) +// entryCount (uint32) +// entries (each): +// lockoutId (uint32) +// nameLen + name +// descLen + description +// mapId (uint32) +// difficultyId (uint32) +// resetIntervalMs (uint32) +// maxBossKillsPerLockout (uint8) / bonusRolls (uint8) +// raidLockoutKind (uint8) / raidGroupSize (uint8) +// iconColorRGBA (uint32) +struct WoweeInstanceLockout { + enum LockoutKind : uint8_t { + Daily = 0, // 24h reset (5-man heroics, daily quests) + Weekly = 1, // 7d reset (raid lockouts) + SemiWeekly = 2, // 3.5d reset (Cata+ split lockouts) + Custom = 3, // arbitrary intervalMs + }; + + static constexpr uint32_t kDailyMs = 86400000u; // 24 * 3600 * 1000 + static constexpr uint32_t kWeeklyMs = 604800000u; // 7 * kDailyMs + static constexpr uint32_t kSemiWeeklyMs = 302400000u; // 3.5d + + struct Entry { + uint32_t lockoutId = 0; + std::string name; + std::string description; + uint32_t mapId = 0; + uint32_t difficultyId = 0; + uint32_t resetIntervalMs = 0; + uint8_t maxBossKillsPerLockout = 0; + uint8_t bonusRolls = 0; + uint8_t raidLockoutKind = Weekly; + uint8_t raidGroupSize = 10; // 5 / 10 / 25 / 40 + uint32_t iconColorRGBA = 0xFFFFFFFFu; + }; + + std::string name; + std::vector entries; + + bool isValid() const { return !entries.empty(); } + + const Entry* findById(uint32_t lockoutId) const; + + // Returns all lockouts bound to a given map (typically + // all 4 difficulty variants of one raid). Used by the + // raid-finder UI to populate the difficulty picker. + std::vector findByMap(uint32_t mapId) const; + + // Returns the next reset wall-clock millis after the + // given current time, assuming the standard Tuesday + // reset epoch. Engines override with their server's + // chosen reset time. + uint64_t nextResetMs(uint32_t lockoutId, + uint64_t currentMs) const; + + static const char* lockoutKindName(uint8_t k); +}; + +class WoweeInstanceLockoutLoader { +public: + static bool save(const WoweeInstanceLockout& cat, + const std::string& basePath); + static WoweeInstanceLockout load(const std::string& basePath); + static bool exists(const std::string& basePath); + + // Preset emitters used by --gen-hld* variants. + // + // makeRaidWeekly — 4 raid weekly lockouts (ICC 10N / + // ICC 25N / ICC 10H / ICC 25H) with + // 12-boss kill caps and 7-day + // reset. + // makeDungeonDaily — 4 5-man daily heroic lockouts + // (HoR / FoS / PoS / TotC) with + // 24h reset and 1-boss caps each. + // makeWorldEvent — 3 special event lockouts + // (Brewfest daily / Hallow's End + // pumpkin daily / Wintergrasp + // battle 2.5h reset). + static WoweeInstanceLockout makeRaidWeekly(const std::string& catalogName); + static WoweeInstanceLockout makeDungeonDaily(const std::string& catalogName); + static WoweeInstanceLockout makeWorldEvent(const std::string& catalogName); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_instance_lockouts.cpp b/src/pipeline/wowee_instance_lockouts.cpp new file mode 100644 index 00000000..499a3052 --- /dev/null +++ b/src/pipeline/wowee_instance_lockouts.cpp @@ -0,0 +1,252 @@ +#include "pipeline/wowee_instance_lockouts.hpp" + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'H', 'L', 'D'}; +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) != ".whld") { + base += ".whld"; + } + return base; +} + +uint32_t packRgba(uint8_t r, uint8_t g, uint8_t b, uint8_t a = 0xFF) { + return (static_cast(a) << 24) | + (static_cast(b) << 16) | + (static_cast(g) << 8) | + static_cast(r); +} + +} // namespace + +const WoweeInstanceLockout::Entry* +WoweeInstanceLockout::findById(uint32_t lockoutId) const { + for (const auto& e : entries) + if (e.lockoutId == lockoutId) return &e; + return nullptr; +} + +std::vector +WoweeInstanceLockout::findByMap(uint32_t mapId) const { + std::vector out; + for (const auto& e : entries) + if (e.mapId == mapId) out.push_back(&e); + return out; +} + +uint64_t WoweeInstanceLockout::nextResetMs(uint32_t lockoutId, + uint64_t currentMs) const { + const Entry* e = findById(lockoutId); + if (!e || e->resetIntervalMs == 0) return currentMs; + // Round up to the next interval boundary. The engine + // overrides the epoch with its configured server reset + // time (typically Tuesday 8:00am server-local), but the + // catalog provides the interval shape. + uint64_t intervals = (currentMs / e->resetIntervalMs) + 1; + return intervals * e->resetIntervalMs; +} + +const char* WoweeInstanceLockout::lockoutKindName(uint8_t k) { + switch (k) { + case Daily: return "daily"; + case Weekly: return "weekly"; + case SemiWeekly: return "semi-weekly"; + case Custom: return "custom"; + default: return "unknown"; + } +} + +bool WoweeInstanceLockoutLoader::save(const WoweeInstanceLockout& 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.lockoutId); + writeStr(os, e.name); + writeStr(os, e.description); + writePOD(os, e.mapId); + writePOD(os, e.difficultyId); + writePOD(os, e.resetIntervalMs); + writePOD(os, e.maxBossKillsPerLockout); + writePOD(os, e.bonusRolls); + writePOD(os, e.raidLockoutKind); + writePOD(os, e.raidGroupSize); + writePOD(os, e.iconColorRGBA); + } + return os.good(); +} + +WoweeInstanceLockout WoweeInstanceLockoutLoader::load( + const std::string& basePath) { + WoweeInstanceLockout 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.lockoutId)) { + out.entries.clear(); return out; + } + if (!readStr(is, e.name) || !readStr(is, e.description)) { + out.entries.clear(); return out; + } + if (!readPOD(is, e.mapId) || + !readPOD(is, e.difficultyId) || + !readPOD(is, e.resetIntervalMs) || + !readPOD(is, e.maxBossKillsPerLockout) || + !readPOD(is, e.bonusRolls) || + !readPOD(is, e.raidLockoutKind) || + !readPOD(is, e.raidGroupSize) || + !readPOD(is, e.iconColorRGBA)) { + out.entries.clear(); return out; + } + } + return out; +} + +bool WoweeInstanceLockoutLoader::exists(const std::string& basePath) { + std::ifstream is(normalizePath(basePath), std::ios::binary); + return is.good(); +} + +WoweeInstanceLockout WoweeInstanceLockoutLoader::makeRaidWeekly( + const std::string& catalogName) { + using L = WoweeInstanceLockout; + WoweeInstanceLockout c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, uint32_t diff, + uint8_t size, const char* desc) { + L::Entry e; + e.lockoutId = id; e.name = name; e.description = desc; + // mapId 631 = Icecrown Citadel. + e.mapId = 631; + e.difficultyId = diff; + e.resetIntervalMs = L::kWeeklyMs; + e.maxBossKillsPerLockout = 12; // 12 bosses in ICC + e.raidLockoutKind = L::Weekly; + e.raidGroupSize = size; + e.iconColorRGBA = packRgba(220, 80, 100); // raid red + c.entries.push_back(e); + }; + add(1, "ICC10Normal", 100, 10, "ICC 10-Normal weekly lockout — 12 bosses, 10 players."); + add(2, "ICC25Normal", 101, 25, "ICC 25-Normal weekly lockout — 12 bosses, 25 players."); + add(3, "ICC10Heroic", 102, 10, "ICC 10-Heroic weekly lockout — 12 bosses, 10 players, heroic loot tier."); + add(4, "ICC25Heroic", 103, 25, "ICC 25-Heroic weekly lockout — 12 bosses, 25 players, heroic loot tier."); + return c; +} + +WoweeInstanceLockout WoweeInstanceLockoutLoader::makeDungeonDaily( + const std::string& catalogName) { + using L = WoweeInstanceLockout; + WoweeInstanceLockout c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, uint32_t map, + uint8_t bosses, const char* desc) { + L::Entry e; + e.lockoutId = id; e.name = name; e.description = desc; + e.mapId = map; + e.difficultyId = 1; // 5-man heroic + e.resetIntervalMs = L::kDailyMs; + e.maxBossKillsPerLockout = bosses; + e.raidLockoutKind = L::Daily; + e.raidGroupSize = 5; + e.iconColorRGBA = packRgba(180, 220, 100); // dungeon green + c.entries.push_back(e); + }; + // WotLK 5-man heroic dungeon mapIds. + add(100, "HallsOfReflectionH", 668, 3, + "Halls of Reflection heroic — daily lockout, 3 bosses, 5 players."); + add(101, "ForgeOfSoulsH", 632, 2, + "Forge of Souls heroic — daily lockout, 2 bosses, 5 players."); + add(102, "PitOfSaronH", 658, 3, + "Pit of Saron heroic — daily lockout, 3 bosses, 5 players."); + add(103, "TrialOfTheChampionH", 650, 4, + "Trial of the Champion heroic — daily lockout, 4 bosses, 5 players."); + return c; +} + +WoweeInstanceLockout WoweeInstanceLockoutLoader::makeWorldEvent( + const std::string& catalogName) { + using L = WoweeInstanceLockout; + WoweeInstanceLockout c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, uint32_t map, + uint32_t intervalMs, uint8_t kind, uint8_t bosses, + const char* desc) { + L::Entry e; + e.lockoutId = id; e.name = name; e.description = desc; + e.mapId = map; + e.resetIntervalMs = intervalMs; + e.raidLockoutKind = kind; + e.maxBossKillsPerLockout = bosses; + e.raidGroupSize = 5; + e.iconColorRGBA = packRgba(240, 200, 100); // event gold + c.entries.push_back(e); + }; + // World-event lockouts with non-standard intervals. + // Wintergrasp's 2.5h cycle is the canonical Custom kind. + add(200, "BrewfestRamDaily", 0, L::kDailyMs, L::Daily, 1, + "Brewfest Ram Racing — daily reset, 1 reward per day."); + add(201, "HallowsEndPumpkin", 0, L::kDailyMs, L::Daily, 1, + "Hallow's End pumpkin spawn — daily reset, 1 candy bag per day."); + add(202, "WintergraspBattle", 571, 9000000u, L::Custom, 1, + "Wintergrasp battle — 2.5h reset (9000000ms) outdoor PvP zone."); + return c; +} + +} // namespace pipeline +} // namespace wowee diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index 5d835831..c74991e5 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -282,6 +282,8 @@ const char* const kArgRequired[] = { "--gen-bos", "--gen-bos-raid10", "--gen-bos-world", "--info-wbos", "--validate-wbos", "--export-wbos-json", "--import-wbos-json", + "--gen-hld", "--gen-hld-dungeon", "--gen-hld-event", + "--info-whld", "--validate-whld", "--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 4fbf2643..3b914367 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -136,6 +136,7 @@ #include "cli_spell_procs_catalog.hpp" #include "cli_creature_patrols_catalog.hpp" #include "cli_boss_encounters_catalog.hpp" +#include "cli_instance_lockouts_catalog.hpp" #include "cli_quest_objective.hpp" #include "cli_quest_reward.hpp" #include "cli_clone.hpp" @@ -313,6 +314,7 @@ constexpr DispatchFn kDispatchTable[] = { handleSpellProcsCatalog, handleCreaturePatrolsCatalog, handleBossEncountersCatalog, + handleInstanceLockoutsCatalog, handleQuestObjective, handleQuestReward, handleClone, diff --git a/tools/editor/cli_format_table.cpp b/tools/editor/cli_format_table.cpp index a2ffc35b..4d68854d 100644 --- a/tools/editor/cli_format_table.cpp +++ b/tools/editor/cli_format_table.cpp @@ -94,6 +94,7 @@ constexpr FormatMagicEntry kFormats[] = { {{'W','S','P','S'}, ".wsps", "spells", "--info-wsps", "Spell proc trigger catalog"}, {{'W','C','M','R'}, ".wcmr", "creatures", "--info-wcmr", "Creature patrol path catalog"}, {{'W','B','O','S'}, ".wbos", "raid", "--info-wbos", "Boss encounter definition catalog"}, + {{'W','H','L','D'}, ".whld", "raid", "--info-whld", "Instance lockout 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 ee316595..22c3253f 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -2041,6 +2041,16 @@ void printUsage(const char* argv0) { std::printf(" Export binary .wbos to a human-editable JSON sidecar (defaults to .wbos.json)\n"); std::printf(" --import-wbos-json [out-base]\n"); std::printf(" Import a .wbos.json sidecar back into binary .wbos (all per-entry fields preserved verbatim)\n"); + std::printf(" --gen-hld [name]\n"); + std::printf(" Emit .whld 4 ICC raid weekly lockouts (10N / 25N / 10H / 25H) — 12 boss kill cap, 7-day reset\n"); + std::printf(" --gen-hld-dungeon [name]\n"); + std::printf(" Emit .whld 4 5-man heroic dungeon daily lockouts (HoR / FoS / PoS / TotC) — 24h reset, 5-player size\n"); + std::printf(" --gen-hld-event [name]\n"); + std::printf(" Emit .whld 3 world-event lockouts (Brewfest daily / Hallow's End daily / Wintergrasp 2.5h Custom interval)\n"); + std::printf(" --info-whld [--json]\n"); + std::printf(" Print WHLD entries (id / map / diff / kind / interval (formatted) / boss kills / size / bonus rolls / name)\n"); + std::printf(" --validate-whld [--json]\n"); + std::printf(" Static checks: id+name+kind+interval required, no duplicate ids; warns on non-standard group size, kind/interval mismatches (Daily not 24h, Weekly not 7d), 0 boss kill cap\n"); std::printf(" --gen-weather-temperate [zoneName]\n"); std::printf(" Emit .wow weather schedule: clear-dominant + occasional rain + fog (forest / grassland)\n"); std::printf(" --gen-weather-arctic [zoneName]\n"); diff --git a/tools/editor/cli_instance_lockouts_catalog.cpp b/tools/editor/cli_instance_lockouts_catalog.cpp new file mode 100644 index 00000000..7e51095e --- /dev/null +++ b/tools/editor/cli_instance_lockouts_catalog.cpp @@ -0,0 +1,266 @@ +#include "cli_instance_lockouts_catalog.hpp" +#include "cli_arg_parse.hpp" +#include "cli_box_emitter.hpp" + +#include "pipeline/wowee_instance_lockouts.hpp" +#include + +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::string stripWhldExt(std::string base) { + stripExt(base, ".whld"); + return base; +} + +bool saveOrError(const wowee::pipeline::WoweeInstanceLockout& c, + const std::string& base, const char* cmd) { + if (!wowee::pipeline::WoweeInstanceLockoutLoader::save(c, base)) { + std::fprintf(stderr, "%s: failed to save %s.whld\n", + cmd, base.c_str()); + return false; + } + return true; +} + +void printGenSummary(const wowee::pipeline::WoweeInstanceLockout& c, + const std::string& base) { + std::printf("Wrote %s.whld\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" lockouts : %zu\n", c.entries.size()); +} + +int handleGenRaidWeekly(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "RaidWeeklyLockouts"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWhldExt(base); + auto c = wowee::pipeline::WoweeInstanceLockoutLoader::makeRaidWeekly(name); + if (!saveOrError(c, base, "gen-hld")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenDungeonDaily(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "DungeonDailyLockouts"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWhldExt(base); + auto c = wowee::pipeline::WoweeInstanceLockoutLoader::makeDungeonDaily(name); + if (!saveOrError(c, base, "gen-hld-dungeon")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenWorldEvent(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "WorldEventLockouts"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWhldExt(base); + auto c = wowee::pipeline::WoweeInstanceLockoutLoader::makeWorldEvent(name); + if (!saveOrError(c, base, "gen-hld-event")) return 1; + printGenSummary(c, base); + return 0; +} + +void formatInterval(uint32_t ms, char* buf, size_t bufSize) { + if (ms == 0) { + std::snprintf(buf, bufSize, "-"); + } else if (ms % 86400000u == 0) { + std::snprintf(buf, bufSize, "%ud", ms / 86400000u); + } else if (ms % 3600000u == 0) { + std::snprintf(buf, bufSize, "%uh", ms / 3600000u); + } else if (ms % 60000u == 0) { + std::snprintf(buf, bufSize, "%um", ms / 60000u); + } else { + std::snprintf(buf, bufSize, "%ums", ms); + } +} + +int handleInfo(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWhldExt(base); + if (!wowee::pipeline::WoweeInstanceLockoutLoader::exists(base)) { + std::fprintf(stderr, "WHLD not found: %s.whld\n", base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeInstanceLockoutLoader::load(base); + if (jsonOut) { + nlohmann::json j; + j["whld"] = base + ".whld"; + j["name"] = c.name; + j["count"] = c.entries.size(); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : c.entries) { + arr.push_back({ + {"lockoutId", e.lockoutId}, + {"name", e.name}, + {"description", e.description}, + {"mapId", e.mapId}, + {"difficultyId", e.difficultyId}, + {"resetIntervalMs", e.resetIntervalMs}, + {"maxBossKillsPerLockout", e.maxBossKillsPerLockout}, + {"bonusRolls", e.bonusRolls}, + {"raidLockoutKind", e.raidLockoutKind}, + {"raidLockoutKindName", wowee::pipeline::WoweeInstanceLockout::lockoutKindName(e.raidLockoutKind)}, + {"raidGroupSize", e.raidGroupSize}, + {"iconColorRGBA", e.iconColorRGBA}, + }); + } + j["entries"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WHLD: %s.whld\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" lockouts : %zu\n", c.entries.size()); + if (c.entries.empty()) return 0; + std::printf(" id map diff kind interval bosses size bonus name\n"); + for (const auto& e : c.entries) { + char intervalBuf[16]; + formatInterval(e.resetIntervalMs, intervalBuf, sizeof(intervalBuf)); + std::printf(" %4u %4u %4u %-10s %-9s %3u %3u %3u %s\n", + e.lockoutId, e.mapId, e.difficultyId, + wowee::pipeline::WoweeInstanceLockout::lockoutKindName(e.raidLockoutKind), + intervalBuf, + e.maxBossKillsPerLockout, e.raidGroupSize, + e.bonusRolls, 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 = stripWhldExt(base); + if (!wowee::pipeline::WoweeInstanceLockoutLoader::exists(base)) { + std::fprintf(stderr, + "validate-whld: WHLD not found: %s.whld\n", base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeInstanceLockoutLoader::load(base); + std::vector errors; + std::vector warnings; + if (c.entries.empty()) { + warnings.push_back("catalog has zero entries"); + } + std::vector idsSeen; + 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.lockoutId); + if (!e.name.empty()) ctx += " " + e.name; + ctx += ")"; + if (e.lockoutId == 0) + errors.push_back(ctx + ": lockoutId is 0"); + if (e.name.empty()) + errors.push_back(ctx + ": name is empty"); + if (e.raidLockoutKind > wowee::pipeline::WoweeInstanceLockout::Custom) { + errors.push_back(ctx + ": raidLockoutKind " + + std::to_string(e.raidLockoutKind) + " not in 0..3"); + } + if (e.resetIntervalMs == 0) + errors.push_back(ctx + + ": resetIntervalMs is 0 — lockout would never reset"); + // Standard sizes are 5/10/25/40 — anything else is a + // server-custom raid size. + if (e.raidGroupSize != 5 && e.raidGroupSize != 10 && + e.raidGroupSize != 25 && e.raidGroupSize != 40) { + warnings.push_back(ctx + + ": non-standard raidGroupSize " + + std::to_string(e.raidGroupSize) + + " (canonical sizes are 5/10/25/40)"); + } + // Daily kind with non-daily interval is suspicious. + if (e.raidLockoutKind == wowee::pipeline::WoweeInstanceLockout::Daily && + e.resetIntervalMs != wowee::pipeline::WoweeInstanceLockout::kDailyMs) { + warnings.push_back(ctx + + ": Daily kind with resetIntervalMs " + + std::to_string(e.resetIntervalMs) + + " — canonical Daily is 86400000ms (24h)"); + } + // Weekly kind with non-weekly interval is suspicious. + if (e.raidLockoutKind == wowee::pipeline::WoweeInstanceLockout::Weekly && + e.resetIntervalMs != wowee::pipeline::WoweeInstanceLockout::kWeeklyMs) { + warnings.push_back(ctx + + ": Weekly kind with resetIntervalMs " + + std::to_string(e.resetIntervalMs) + + " — canonical Weekly is 604800000ms (7d)"); + } + if (e.maxBossKillsPerLockout == 0) + warnings.push_back(ctx + + ": maxBossKillsPerLockout=0 — instance grants no " + "lockout-bound kills, every visit is fresh"); + for (uint32_t prev : idsSeen) { + if (prev == e.lockoutId) { + errors.push_back(ctx + ": duplicate lockoutId"); + break; + } + } + idsSeen.push_back(e.lockoutId); + } + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["whld"] = base + ".whld"; + 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-whld: %s.whld\n", base.c_str()); + if (ok && warnings.empty()) { + std::printf(" OK — %zu lockouts, all lockoutIds unique\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 handleInstanceLockoutsCatalog(int& i, int argc, char** argv, + int& outRc) { + if (std::strcmp(argv[i], "--gen-hld") == 0 && i + 1 < argc) { + outRc = handleGenRaidWeekly(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-hld-dungeon") == 0 && i + 1 < argc) { + outRc = handleGenDungeonDaily(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-hld-event") == 0 && i + 1 < argc) { + outRc = handleGenWorldEvent(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-whld") == 0 && i + 1 < argc) { + outRc = handleInfo(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-whld") == 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_instance_lockouts_catalog.hpp b/tools/editor/cli_instance_lockouts_catalog.hpp new file mode 100644 index 00000000..9b20b2b3 --- /dev/null +++ b/tools/editor/cli_instance_lockouts_catalog.hpp @@ -0,0 +1,12 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleInstanceLockoutsCatalog(int& i, int argc, char** argv, + int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_list_formats.cpp b/tools/editor/cli_list_formats.cpp index 88525fa2..ba527130 100644 --- a/tools/editor/cli_list_formats.cpp +++ b/tools/editor/cli_list_formats.cpp @@ -116,6 +116,7 @@ constexpr FormatRow kFormats[] = { {"WSPS", ".wsps", "spells", "spell_proc_event SQL + Spell.dbc", "Spell proc trigger catalog"}, {"WCMR", ".wcmr", "creatures", "creature_movement waypoints SQL", "Creature patrol path catalog"}, {"WBOS", ".wbos", "raid", "instance_encounter SQL", "Boss encounter definition catalog"}, + {"WHLD", ".whld", "raid", "InstanceTemplate.dbc reset fields","Instance lockout schedule catalog"}, // Additional pipeline catalogs without the alternating // gen/info/validate CLI surface (loaded by the engine