diff --git a/GWToolboxdll/CMakeLists.txt b/GWToolboxdll/CMakeLists.txt index 00f2fb294..8afb0e0f2 100644 --- a/GWToolboxdll/CMakeLists.txt +++ b/GWToolboxdll/CMakeLists.txt @@ -20,8 +20,10 @@ file(GLOB SOURCES CONFIGURE_DEPENDS "Windows/Pathfinding/*.h" "Windows/Pathfinding/*.hpp" "Windows/Pathfinding/*.cpp" + "Windows/Splits/*.h" + "Windows/Splits/*.cpp" ) - + source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES ${SOURCES}) target_sources(GWToolboxdll PRIVATE ${SOURCES}) diff --git a/GWToolboxdll/Modules/ToolboxSettings.cpp b/GWToolboxdll/Modules/ToolboxSettings.cpp index e749a1308..ffc4600b0 100644 --- a/GWToolboxdll/Modules/ToolboxSettings.cpp +++ b/GWToolboxdll/Modules/ToolboxSettings.cpp @@ -51,6 +51,7 @@ #include #include #include +#include #include #include #include @@ -208,6 +209,7 @@ namespace { TradeWindow::Instance(), NotePadWindow::Instance(), ObjectiveTimerWindow::Instance(), + SplitsWindow::Instance(), FactionLeaderboardWindow::Instance(), DailyQuests::Instance(), FriendListWindow::Instance(), diff --git a/GWToolboxdll/Windows/Splits/GoalClock.cpp b/GWToolboxdll/Windows/Splits/GoalClock.cpp new file mode 100644 index 000000000..7e53eab77 --- /dev/null +++ b/GWToolboxdll/Windows/Splits/GoalClock.cpp @@ -0,0 +1,32 @@ +#include "stdafx.h" +#include "GoalClock.h" + +void GoalClock::Start() { running_ = true; } +void GoalClock::Pause() { running_ = false; } +void GoalClock::Resume() { running_ = true; } + +void GoalClock::Reset() +{ + running_ = false; + real_elapsed_ = 0.0; + game_elapsed_ = 0.0; +} + +void GoalClock::AddRealTime(double delta) +{ + if (running_ && delta > 0.0) + real_elapsed_ += delta; +} + +void GoalClock::AddGameTime(double delta) +{ + if (running_ && delta > 0.0) + game_elapsed_ += delta; +} + +void GoalClock::Restore(double real_elapsed, double game_elapsed) +{ + real_elapsed_ = real_elapsed; + game_elapsed_ = game_elapsed; + running_ = true; +} diff --git a/GWToolboxdll/Windows/Splits/GoalClock.h b/GWToolboxdll/Windows/Splits/GoalClock.h new file mode 100644 index 000000000..fb14056fb --- /dev/null +++ b/GWToolboxdll/Windows/Splits/GoalClock.h @@ -0,0 +1,31 @@ +#pragma once + +// --------------------------------------------------------------------------- +// GoalClock — two independent timers running simultaneously. +// +// real_time — wall-clock elapsed; pauses on loading screens / cinematics. +// game_time — explorable-only time accumulated via AddGameTime(). +// --------------------------------------------------------------------------- +class GoalClock { +public: + void Start(); + void Pause(); + void Resume(); + void Reset(); + + void AddGameTime(double delta); + void AddRealTime(double delta); + + // Restore clock state (crash-protection resume). + void Restore(double real_elapsed, double game_elapsed); + + [[nodiscard]] bool IsRunning() const { return running_; } + [[nodiscard]] double RealTime() const { return real_elapsed_; } + [[nodiscard]] double GameTime() const { return game_elapsed_; } + [[nodiscard]] double TownTime() const { return real_elapsed_ - game_elapsed_; } + +private: + bool running_ = false; + double real_elapsed_ = 0.0; + double game_elapsed_ = 0.0; +}; diff --git a/GWToolboxdll/Windows/Splits/GoalEngine.cpp b/GWToolboxdll/Windows/Splits/GoalEngine.cpp new file mode 100644 index 000000000..1dc89039b --- /dev/null +++ b/GWToolboxdll/Windows/Splits/GoalEngine.cpp @@ -0,0 +1,164 @@ +#include "stdafx.h" +#include "GoalEngine.h" + +#include +#include +#include +#include + +void GoalEngine::Attach(GoalList* list) +{ + list_ = list; + Reset(); +} + +void GoalEngine::Detach() +{ + list_ = nullptr; + started_ = false; +} + +void GoalEngine::Reset() +{ + started_ = false; + prev_map_ = GW::Constants::MapID::None; + last_real_ = 0.0; + last_game_ = 0.0; + mission_complete_map_ = GW::Constants::MapID::None; + mission_bonus_map_ = GW::Constants::MapID::None; + if (list_) list_->ResetRunState(); +} + +void GoalEngine::NotifyMissionComplete(GW::Constants::MapID map) +{ + mission_complete_map_ = map; +} + +void GoalEngine::NotifyMissionBonus(GW::Constants::MapID map) +{ + mission_bonus_map_ = map; +} + +int GoalEngine::Update(const GoalClock& clock, + GW::Constants::MapID current_map, + bool just_entered_map, + bool came_from_explorable, + GW::Constants::InstanceType instance_type, + bool vq_complete, + int player_level) +{ + const bool is_explorable = (instance_type == GW::Constants::InstanceType::Explorable); + + if (!list_ || list_->goals.empty()) { + prev_map_ = current_map; + mission_complete_map_ = GW::Constants::MapID::None; + mission_bonus_map_ = GW::Constants::MapID::None; + return -1; + } + + if (!started_ && just_entered_map) + started_ = true; + + int fired = -1; + + if (started_) { + for (int i = 0; i < static_cast(list_->goals.size()); ++i) { + GoalEntry& g = list_->goals[i]; + if (g.completed) continue; + + bool fire = false; + const GoalTrigger& t = g.trigger; + + switch (t.type) { + case GoalTrigger::Type::MapEnter: + fire = just_entered_map && (current_map == t.map_id); + break; + + case GoalTrigger::Type::EnterExplorable: + fire = just_entered_map && is_explorable && (current_map == t.map_id); + break; + + case GoalTrigger::Type::ExitExplorable: + fire = just_entered_map && came_from_explorable && (prev_map_ == t.map_id); + break; + + case GoalTrigger::Type::VanquishComplete: + fire = vq_complete && (current_map == t.map_id); + break; + + case GoalTrigger::Type::MissionComplete: + fire = (mission_complete_map_ == t.map_id) && + (!t.hard_mode || GW::PartyMgr::GetIsPartyInHardMode()); + break; + + case GoalTrigger::Type::MissionBonus: + fire = (mission_bonus_map_ == t.map_id) && + (!t.hard_mode || GW::PartyMgr::GetIsPartyInHardMode()); + break; + + case GoalTrigger::Type::ReachLevel: + fire = (player_level >= t.level); + break; + + case GoalTrigger::Type::ExitOutpost: + fire = just_entered_map && (prev_map_ == t.map_id); + break; + + case GoalTrigger::Type::ReachTitleRank: { + const GW::Title* title = GW::PlayerMgr::GetTitleTrack(t.title_id); + fire = title && title->current_title_tier_index >= static_cast(t.level); + break; + } + + case GoalTrigger::Type::Manual: + break; + + default: + break; + } + + if (fire) { + FireGoal(i, clock); + fired = i; + break; + } + } + } + + mission_complete_map_ = GW::Constants::MapID::None; + mission_bonus_map_ = GW::Constants::MapID::None; + if (current_map != GW::Constants::MapID::None) + prev_map_ = current_map; + + return fired; +} + +void GoalEngine::ForceStarted() +{ + started_ = true; +} + +void GoalEngine::TriggerManual(const GoalClock& clock) +{ + if (!list_) return; + for (int i = 0; i < static_cast(list_->goals.size()); ++i) { + GoalEntry& g = list_->goals[i]; + if (!g.completed && g.trigger.type == GoalTrigger::Type::Manual) { + if (!started_) started_ = true; + FireGoal(i, clock); + return; + } + } +} + +void GoalEngine::FireGoal(int index, const GoalClock& clock) +{ + GoalEntry& g = list_->goals[index]; + g.completed = true; + g.split.real_time = clock.RealTime(); + g.split.game_time = clock.GameTime(); + g.split.segment_real = clock.RealTime() - last_real_; + g.split.segment_game = clock.GameTime() - last_game_; + last_real_ = clock.RealTime(); + last_game_ = clock.GameTime(); +} diff --git a/GWToolboxdll/Windows/Splits/GoalEngine.h b/GWToolboxdll/Windows/Splits/GoalEngine.h new file mode 100644 index 000000000..4ea862e5c --- /dev/null +++ b/GWToolboxdll/Windows/Splits/GoalEngine.h @@ -0,0 +1,51 @@ +#pragma once + +#include "GoalList.h" +#include "GoalClock.h" + +#include +#include + +// --------------------------------------------------------------------------- +// GoalEngine — checks conditions each frame, fires splits, tracks state. +// --------------------------------------------------------------------------- +class GoalEngine { +public: + void Attach(GoalList* list); + void Detach(); + + // Returns the index of a goal that just completed this frame, or -1. + int Update(const GoalClock& clock, + GW::Constants::MapID current_map, + bool just_entered_map, + bool came_from_explorable, + GW::Constants::InstanceType instance_type, + bool vq_complete, + int player_level); + + void TriggerManual(const GoalClock& clock); + + void NotifyMissionComplete(GW::Constants::MapID map); + void NotifyMissionBonus(GW::Constants::MapID map); + + void Reset(); + void ForceStarted(); + + [[nodiscard]] bool HasList() const { return list_ != nullptr; } + [[nodiscard]] bool IsStarted() const { return started_; } + [[nodiscard]] GoalList* List() const { return list_; } + +private: + void FireGoal(int index, const GoalClock& clock); + + GoalList* list_ = nullptr; + bool started_ = false; + + GW::Constants::MapID prev_map_ = GW::Constants::MapID::None; + + double last_real_ = 0.0; + double last_game_ = 0.0; + + GW::Constants::MapID mission_complete_map_ = GW::Constants::MapID::None; + GW::Constants::MapID mission_bonus_map_ = GW::Constants::MapID::None; +}; diff --git a/GWToolboxdll/Windows/Splits/GoalEntry.h b/GWToolboxdll/Windows/Splits/GoalEntry.h new file mode 100644 index 000000000..840ff79a5 --- /dev/null +++ b/GWToolboxdll/Windows/Splits/GoalEntry.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include + +// --------------------------------------------------------------------------- +// GoalTrigger — describes the condition that completes a split. +// --------------------------------------------------------------------------- +struct GoalTrigger { + enum class Type : uint8_t { + Manual = 0, + MapEnter = 1, + EnterExplorable = 2, + ExitExplorable = 3, + VanquishComplete = 4, + MissionComplete = 5, + MissionBonus = 6, + ReachLevel = 7, + ExitOutpost = 8, + ReachTitleRank = 9 + }; + + Type type = Type::Manual; + bool hard_mode = false; // MissionComplete/Bonus: require hard mode + GW::Constants::MapID map_id = GW::Constants::MapID::None; + int level = 0; + GW::Constants::TitleID title_id = GW::Constants::TitleID::None; +}; + +// --------------------------------------------------------------------------- +// CompletedSplit — time data recorded when a goal fires. +// --------------------------------------------------------------------------- +struct CompletedSplit { + double real_time = 0.0; + double game_time = 0.0; + double segment_real = 0.0; + double segment_game = 0.0; +}; + +// --------------------------------------------------------------------------- +// GoalEntry — one row in a split list. +// --------------------------------------------------------------------------- +struct GoalEntry { + std::string label; + GoalTrigger trigger; + bool completed = false; + CompletedSplit split = {}; +}; diff --git a/GWToolboxdll/Windows/Splits/GoalList.cpp b/GWToolboxdll/Windows/Splits/GoalList.cpp new file mode 100644 index 000000000..2d5451502 --- /dev/null +++ b/GWToolboxdll/Windows/Splits/GoalList.cpp @@ -0,0 +1,108 @@ +#include "stdafx.h" +#include "GoalList.h" + +using json = nlohmann::json; + +// --------------------------------------------------------------------------- +// Serialization helpers +// --------------------------------------------------------------------------- +static std::string TriggerTypeName(GoalTrigger::Type t) +{ + switch (t) { + case GoalTrigger::Type::MapEnter: return "MapEnter"; + case GoalTrigger::Type::EnterExplorable: return "EnterExplorable"; + case GoalTrigger::Type::ExitExplorable: return "ExitExplorable"; + case GoalTrigger::Type::VanquishComplete: return "VanquishComplete"; + case GoalTrigger::Type::MissionComplete: return "MissionComplete"; + case GoalTrigger::Type::MissionBonus: return "MissionBonus"; + case GoalTrigger::Type::ReachLevel: return "ReachLevel"; + case GoalTrigger::Type::ExitOutpost: return "ExitOutpost"; + case GoalTrigger::Type::ReachTitleRank: return "ReachTitleRank"; + default: return "Manual"; + } +} + +static GoalTrigger::Type TriggerTypeFromString(const std::string& s) +{ + if (s == "MapEnter") return GoalTrigger::Type::MapEnter; + if (s == "EnterExplorable") return GoalTrigger::Type::EnterExplorable; + if (s == "ExitExplorable") return GoalTrigger::Type::ExitExplorable; + if (s == "VanquishComplete") return GoalTrigger::Type::VanquishComplete; + if (s == "MissionComplete") return GoalTrigger::Type::MissionComplete; + if (s == "MissionBonus") return GoalTrigger::Type::MissionBonus; + if (s == "ReachLevel") return GoalTrigger::Type::ReachLevel; + if (s == "ExitOutpost") return GoalTrigger::Type::ExitOutpost; + if (s == "ReachTitleRank") return GoalTrigger::Type::ReachTitleRank; + return GoalTrigger::Type::Manual; +} + +// --------------------------------------------------------------------------- +void GoalList::ResetRunState() +{ + for (auto& g : goals) { + g.completed = false; + g.split = {}; + } +} + +bool GoalList::SaveToFile(const std::wstring& path) const +{ + json j; + j["name"] = name; + + json jgoals = json::array(); + for (const auto& g : goals) { + json jg; + jg["label"] = g.label; + jg["trigger_type"] = TriggerTypeName(g.trigger.type); + jg["map_id"] = static_cast(g.trigger.map_id); + jg["level"] = g.trigger.level; + jg["title_id"] = static_cast(g.trigger.title_id); + if (g.trigger.hard_mode) jg["hard_mode"] = true; + jgoals.push_back(std::move(jg)); + } + j["goals"] = std::move(jgoals); + + std::ofstream f(path); + if (!f.is_open()) return false; + f << j.dump(2); + return true; +} + +bool GoalList::LoadFromFile(const std::wstring& path) +{ + std::ifstream f(path); + if (!f.is_open()) return false; + + json j; + try { j = json::parse(f); } + catch (...) { return false; } + + name = j.value("name", ""); + goals.clear(); + + for (const auto& jg : j.value("goals", json::array())) { + GoalEntry g; + g.label = jg.value("label", ""); + g.trigger.type = TriggerTypeFromString(jg.value("trigger_type", "Manual")); + g.trigger.map_id = static_cast(jg.value("map_id", 0)); + g.trigger.level = jg.value("level", 0); + g.trigger.title_id = static_cast(jg.value("title_id", 0xffu)); + g.trigger.hard_mode = jg.value("hard_mode", false); + goals.push_back(std::move(g)); + } + return true; +} + +std::vector> +GoalList::ListSaved(const std::wstring& folder) +{ + std::vector> result; + std::error_code ec; + for (const auto& entry : std::filesystem::directory_iterator(folder, ec)) { + if (entry.path().extension() != L".json") continue; + if (entry.path().stem() == L"resume") continue; + result.emplace_back(entry.path().stem().string(), entry.path().wstring()); + } + return result; +} diff --git a/GWToolboxdll/Windows/Splits/GoalList.h b/GWToolboxdll/Windows/Splits/GoalList.h new file mode 100644 index 000000000..df1a81f26 --- /dev/null +++ b/GWToolboxdll/Windows/Splits/GoalList.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +#include "GoalEntry.h" + +// --------------------------------------------------------------------------- +// GoalList — a named, ordered collection of goals. +// --------------------------------------------------------------------------- +struct GoalList { + std::string name; + std::vector goals; + + void ResetRunState(); + + bool SaveToFile(const std::wstring& path) const; + bool LoadFromFile(const std::wstring& path); + + static std::vector> + ListSaved(const std::wstring& folder); +}; diff --git a/GWToolboxdll/Windows/Splits/MapNames.cpp b/GWToolboxdll/Windows/Splits/MapNames.cpp new file mode 100644 index 000000000..d86dfec5a --- /dev/null +++ b/GWToolboxdll/Windows/Splits/MapNames.cpp @@ -0,0 +1,93 @@ +#include "stdafx.h" +#include "MapNames.h" + +#include +#include +#include + +namespace MapNames { + +static std::unordered_map s_cache; +static bool s_initialized = false; + +struct DecodeParam { int map_id; }; + +static void OnDecoded(void* param, const wchar_t* decoded) +{ + auto* p = static_cast(param); + if (decoded && decoded[0]) { + char buf[256] = {}; + WideCharToMultiByte(CP_UTF8, 0, decoded, -1, buf, sizeof(buf), nullptr, nullptr); + if (buf[0]) s_cache[p->map_id] = buf; + } + delete p; +} + +void Init() +{ + if (s_initialized) return; + s_initialized = true; + + for (int id = 1; id < static_cast(GW::Constants::MapID::Count); ++id) { + const auto* info = GW::Map::GetMapInfo(static_cast(id)); + if (!info || !info->name_id) continue; + + wchar_t enc[8] = {}; + if (!GW::UI::UInt32ToEncStr(info->name_id, enc, _countof(enc))) continue; + + GW::UI::AsyncDecodeStr(enc, OnDecoded, new DecodeParam{id}); + } +} + +const std::string& Get(GW::Constants::MapID id) +{ + auto it = s_cache.find(static_cast(id)); + if (it != s_cache.end()) return it->second; + + static std::unordered_map s_fallback; + auto& fb = s_fallback[static_cast(id)]; + if (fb.empty()) { + char tmp[32]; + snprintf(tmp, sizeof(tmp), "Map %d", static_cast(id)); + fb = tmp; + } + return fb; +} + +std::vector> Filter( + const char* query, + std::initializer_list types) +{ + std::string q = query ? query : ""; + for (char& c : q) c = static_cast(std::tolower(static_cast(c))); + + std::vector> result; + result.reserve(128); + + for (const auto& [id, name] : s_cache) { + if (types.size() > 0) { + const auto* info = GW::Map::GetMapInfo(static_cast(id)); + if (!info) continue; + bool type_ok = false; + for (const auto t : types) { + if (info->type == t) { type_ok = true; break; } + } + if (!type_ok) continue; + } + + if (q.empty()) { + result.emplace_back(id, name); + } else { + std::string lower = name; + for (char& c : lower) c = static_cast(std::tolower(static_cast(c))); + if (lower.find(q) != std::string::npos) + result.emplace_back(id, name); + } + } + + std::sort(result.begin(), result.end(), + [](const auto& a, const auto& b) { return a.second < b.second; }); + return result; +} + +} // namespace MapNames diff --git a/GWToolboxdll/Windows/Splits/MapNames.h b/GWToolboxdll/Windows/Splits/MapNames.h new file mode 100644 index 000000000..9fc145837 --- /dev/null +++ b/GWToolboxdll/Windows/Splits/MapNames.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +// --------------------------------------------------------------------------- +// MapNames — async-decoded cache of GW map names. +// --------------------------------------------------------------------------- +namespace MapNames { + + void Init(); + const std::string& Get(GW::Constants::MapID id); + + std::vector> Filter( + const char* query, + std::initializer_list types = {}); + +} // namespace MapNames diff --git a/GWToolboxdll/Windows/Splits/SplitsGoalListWindow.cpp b/GWToolboxdll/Windows/Splits/SplitsGoalListWindow.cpp new file mode 100644 index 000000000..1f3f14d5c --- /dev/null +++ b/GWToolboxdll/Windows/Splits/SplitsGoalListWindow.cpp @@ -0,0 +1,1228 @@ +#include "stdafx.h" +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Formatting helpers +// --------------------------------------------------------------------------- +static void FormatTime(char* buf, int bufsz, double seconds) +{ + const int h = static_cast(seconds) / 3600; + const int m = (static_cast(seconds) % 3600) / 60; + const int s = static_cast(seconds) % 60; + const int cs = static_cast(seconds * 100.0) % 100; + if (h > 0) + snprintf(buf, bufsz, "%d:%02d:%02d.%02d", h, m, s, cs); + else + snprintf(buf, bufsz, "%02d:%02d.%02d", m, s, cs); +} + +static void DrawPBDelta(double actual, double pb_split) +{ + if (std::isnan(pb_split) || std::isnan(actual)) return; + const double delta = actual - pb_split; + char dbuf[32]; + FormatTime(dbuf, sizeof(dbuf), std::abs(delta)); + if (delta < 0.0) + ImGui::TextColored({1.f, 0.85f, 0.0f, 1.f}, "-%s", dbuf); + else + ImGui::TextColored({1.f, 0.4f, 0.4f, 1.f}, "+%s", dbuf); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +static const char* CampaignName(GW::Constants::Campaign c) +{ + switch (c) { + case GW::Constants::Campaign::Prophecies: return "Prophecies"; + case GW::Constants::Campaign::Factions: return "Factions"; + case GW::Constants::Campaign::Nightfall: return "Nightfall"; + case GW::Constants::Campaign::EyeOfTheNorth: return "Eye of the North"; + default: return nullptr; + } +} + +static const char* RegionName(GW::Region r) +{ + switch (r) { + case GW::Region_Ascalon: return "Ascalon"; + case GW::Region_NorthernShiverpeaks: return "Northern Shiverpeaks"; + case GW::Region_Kryta: return "Kryta"; + case GW::Region_Maguuma: return "Maguuma Jungle"; + case GW::Region_CrystalDesert: return "Crystal Desert"; + case GW::Region_FissureOfWoe: return "Southern Shiverpeaks"; + case GW::Region_ShingJea: return "Shing Jea Island"; + case GW::Region_Kaineng: return "Kaineng City"; + case GW::Region_Kurzick: return "Echovald Forest"; + case GW::Region_Luxon: return "The Jade Sea"; + case GW::Region_Istan: return "Istan"; + case GW::Region_Kourna: return "Kourna"; + case GW::Region_Vaabi: return "Vabbi"; + case GW::Region_Desolation: return "The Desolation"; + case GW::Region_DomainOfAnguish: return "Realm of Torment"; + case GW::Region_TarnishedCoast: return "Tarnished Coast"; + case GW::Region_DepthsOfTyria: return "Depths of Tyria"; + case GW::Region_FarShiverpeaks: return "Far Shiverpeaks"; + case GW::Region_CharrHomelands: return "Charr Homelands"; + default: return nullptr; + } +} + +// --------------------------------------------------------------------------- +// Main window Draw +// --------------------------------------------------------------------------- +void SplitsGoalListWindow::Draw(SplitsWindow& plugin) +{ + const bool is_open = ImGui::Begin(plugin.Name(), plugin.GetVisiblePtr(), plugin.GetWinFlags()); + + // Title bar buttons + { + const bool running = plugin.Clock().IsRunning(); + const char* lbl0 = running ? "Pause" : "Start"; + const float fp_x = ImGui::GetStyle().FramePadding.x; + const float sp = ImGui::GetStyle().ItemSpacing.x; + const float bh = ImGui::GetFrameHeight() - 4.f; + const float bw0 = ImGui::CalcTextSize(lbl0).x + fp_x * 2.f; + const float bw1 = ImGui::CalcTextSize("Reset").x + fp_x * 2.f; + const float bw2 = ImGui::CalcTextSize("Split").x + fp_x * 2.f; + const float total_w = bw0 + bw1 + bw2 + sp * 2.f; + + const ImVec2 win_pos = ImGui::GetWindowPos(); + const float win_w = ImGui::GetWindowWidth(); + const float title_h = ImGui::GetFrameHeight(); + + const ImVec2 saved_cursor = ImGui::GetCursorPos(); + ImGui::PushClipRect(win_pos, {win_pos.x + win_w, win_pos.y + title_h}, false); + ImGui::SetCursorScreenPos({win_pos.x + win_w - total_w - 8.f, win_pos.y + 2.f}); + + if (ImGui::Button(lbl0, {bw0, bh})) plugin.StartRun(); + ImGui::SameLine(0, sp); + if (ImGui::Button("Reset", {bw1, bh})) plugin.ResetRun(); + ImGui::SameLine(0, sp); + if (ImGui::Button("Split", {bw2, bh})) plugin.TriggerManualSplit(); + + ImGui::PopClipRect(); + ImGui::SetCursorPos(saved_cursor); + } + + if (!is_open) { + ImGui::End(); + return; + } + + // Resume modal + if (plugin.HasPendingResume()) + ImGui::OpenPopup("Resume Run?"); + if (ImGui::BeginPopupModal("Resume Run?", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::Text("Run '%s' was interrupted.", plugin.PendingResumeName()); + ImGui::Text("Resume from where you left off?"); + ImGui::Spacing(); + if (ImGui::Button("Resume", {110, 0})) { plugin.ApplyResume(); ImGui::CloseCurrentPopup(); } + ImGui::SameLine(); + if (ImGui::Button("Discard", {110, 0})) { plugin.DiscardResume(); ImGui::CloseCurrentPopup(); } + ImGui::EndPopup(); + } + + const GoalClock& clock = plugin.Clock(); + const GoalList* list = plugin.List(); + + // Header clocks + char real_buf[32], game_buf[32], town_buf[32]; + FormatTime(real_buf, sizeof(real_buf), clock.RealTime()); + FormatTime(game_buf, sizeof(game_buf), clock.GameTime()); + FormatTime(town_buf, sizeof(town_buf), clock.TownTime()); + + { + const float avail = ImGui::GetContentRegionAvail().x; + const float spacing = ImGui::CalcTextSize(" ").x; + const float w_real = ImGui::CalcTextSize("Real: ").x + ImGui::CalcTextSize(real_buf).x; + const float w_game = ImGui::CalcTextSize("Game: ").x + ImGui::CalcTextSize(game_buf).x; + const float w_town = show_town_time_ ? ImGui::CalcTextSize("Town: ").x + ImGui::CalcTextSize(town_buf).x : 0.f; + const float total_w = w_real + spacing + w_game + (show_town_time_ ? spacing + w_town : 0.f); + const float start_x = ImGui::GetCursorPosX() + (avail - total_w) * 0.5f; + ImGui::SetCursorPosX(start_x < ImGui::GetCursorPosX() ? ImGui::GetCursorPosX() : start_x); + + ImGui::TextColored({0.9f, 0.9f, 0.9f, 1.f}, "Real: %s", real_buf); + ImGui::SameLine(0, spacing); + ImGui::TextColored({0.6f, 0.85f, 1.f, 1.f}, "Game: %s", game_buf); + if (show_town_time_) { + ImGui::SameLine(0, spacing); + ImGui::TextColored({1.f, 0.75f, 0.4f, 1.f}, "Town: %s", town_buf); + } + } + + ImGui::Separator(); + + if (plugin.RunComplete()) { + const float avail = ImGui::GetContentRegionAvail().x; + static const char* kMsg = "Run complete! Results saved."; + const float tw = ImGui::CalcTextSize(kMsg).x; + const float cx = ImGui::GetCursorPosX() + (avail - tw) * 0.5f; + ImGui::SetCursorPosX(cx > ImGui::GetCursorPosX() ? cx : ImGui::GetCursorPosX()); + ImGui::TextColored({0.4f, 1.f, 0.4f, 1.f}, "%s", kMsg); + ImGui::Separator(); + } + + if (!list || list->goals.empty()) { + ImGui::TextDisabled("No goal list loaded. Open Settings to create one."); + ImGui::End(); + return; + } + + int current_idx = -1; + for (int i = 0; i < static_cast(list->goals.size()); ++i) { + if (!list->goals[i].completed) { current_idx = i; break; } + } + + const std::vector& pb = (pb_basis_ == 1) ? plugin.PBSplitsGame() : plugin.PBSplits(); + auto pb_for = [&](int idx) -> double { + if (idx < 0 || idx >= static_cast(pb.size())) + return std::numeric_limits::quiet_NaN(); + return pb[static_cast(idx)]; + }; + + ImDrawList* draw_list = ImGui::GetWindowDrawList(); + const float pad_x = 2.f; + const float pad_y = 1.f; + const float win_x = ImGui::GetWindowPos().x; + const float avail_w = ImGui::GetContentRegionAvail().x; + + int completed_before = 0; + + for (int i = 0; i < static_cast(list->goals.size()); ) { + const auto& g = list->goals[i]; + const bool is_mis = g.trigger.type == GoalTrigger::Type::MissionComplete; + const bool is_bon = g.trigger.type == GoalTrigger::Type::MissionBonus; + + const GoalEntry* bon = nullptr; + if (is_mis && i + 1 < static_cast(list->goals.size())) { + const auto& next = list->goals[i + 1]; + if (next.trigger.type == GoalTrigger::Type::MissionBonus && + next.trigger.map_id == g.trigger.map_id) + bon = &next; + } + + const bool has_prior_split = (completed_before > 0); + const float row_y0 = ImGui::GetCursorScreenPos().y - pad_y; + + if (is_mis || is_bon) { + const bool row_current = (i == current_idx) || (bon && (i + 1) == current_idx); + DrawMissionRow(g, bon, clock, row_current, has_prior_split, + pb_for(i), bon ? pb_for(i + 1) : std::numeric_limits::quiet_NaN()); + if (g.completed) ++completed_before; + if (bon && bon->completed) ++completed_before; + i += bon ? 2 : 1; + } else { + DrawGoalRow(g, clock, i, i == current_idx, has_prior_split, pb_for(i)); + if (g.completed) ++completed_before; + ++i; + } + + const float row_y1 = ImGui::GetCursorScreenPos().y + pad_y; + const ImVec2 r_min = { win_x + pad_x, row_y0 }; + const ImVec2 r_max = { win_x + avail_w - pad_x, row_y1 }; + draw_list->AddRect(r_min, r_max, IM_COL32(80, 80, 80, 140), 2.f); + } + + ImGui::End(); +} + +void SplitsGoalListWindow::DrawMissionRow(const GoalEntry& mis, const GoalEntry* bon, + const GoalClock& /*clock*/, bool is_current, + bool has_prior_split, + double pb_split_mis, double pb_split_bon) +{ + const bool either_done = mis.completed || (bon && bon->completed); + ImVec4 color; + if (either_done) color = {0.5f, 1.f, 0.5f, 1.f}; + else if (is_current) color = {1.f, 1.f, 0.6f, 1.f}; + else color = {0.5f, 0.5f, 0.5f, 1.f}; + + const bool mis_is_bonus = (mis.trigger.type == GoalTrigger::Type::MissionBonus); + + if (!ImGui::BeginTable("##misrow", 3, ImGuiTableFlags_None)) return; + ImGui::TableSetupColumn("name", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("col2", ImGuiTableColumnFlags_WidthFixed, 110.f); + ImGui::TableSetupColumn("col3", ImGuiTableColumnFlags_WidthFixed, 110.f); + ImGui::TableNextRow(); + + ImGui::TableSetColumnIndex(0); + ImGui::TextColored(color, "%s", mis.label.c_str()); + + if (!mis_is_bonus) { + ImGui::TableSetColumnIndex(1); + if (mis.completed) { + char buf[32], seg[32]; + FormatTime(buf, sizeof(buf), mis.split.game_time); + FormatTime(seg, sizeof(seg), mis.split.segment_game); + ImGui::TextColored({0.6f, 0.85f, 1.f, 1.f}, "%s", buf); + if (show_segment_ && has_prior_split) ImGui::TextDisabled("+%s", seg); + DrawPBDelta(pb_basis_ == 1 ? mis.split.game_time : mis.split.real_time, pb_split_mis); + } else { + ImGui::TextDisabled("---"); + } + + ImGui::TableSetColumnIndex(2); + if (bon) { + if (bon->completed) { + char buf[32], seg[32]; + FormatTime(buf, sizeof(buf), bon->split.game_time); + FormatTime(seg, sizeof(seg), bon->split.segment_game); + ImGui::TextColored({1.f, 0.85f, 0.4f, 1.f}, "%s", buf); + if (show_segment_ && has_prior_split) ImGui::TextDisabled("+%s", seg); + DrawPBDelta(pb_basis_ == 1 ? bon->split.game_time : bon->split.real_time, pb_split_bon); + } else { + ImGui::TextDisabled("---"); + } + } + } else { + ImGui::TableSetColumnIndex(2); + if (mis.completed) { + char buf[32], seg[32]; + FormatTime(buf, sizeof(buf), mis.split.game_time); + FormatTime(seg, sizeof(seg), mis.split.segment_game); + ImGui::TextColored({1.f, 0.85f, 0.4f, 1.f}, "%s", buf); + if (show_segment_ && has_prior_split) ImGui::TextDisabled("+%s", seg); + DrawPBDelta(pb_basis_ == 1 ? mis.split.game_time : mis.split.real_time, pb_split_mis); + } else { + ImGui::TextDisabled("---"); + } + } + + ImGui::EndTable(); +} + +void SplitsGoalListWindow::DrawGoalRow(const GoalEntry& g, const GoalClock& /*clock*/, + int /*index*/, bool is_current, + bool has_prior_split, double pb_split) +{ + ImVec4 color; + if (g.completed) color = {0.5f, 1.f, 0.5f, 1.f}; + else if (is_current) color = {1.f, 1.f, 0.6f, 1.f}; + else color = {0.5f, 0.5f, 0.5f, 1.f}; + + if (!ImGui::BeginTable("##goalrow", 3, ImGuiTableFlags_None)) return; + ImGui::TableSetupColumn("name", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("game", ImGuiTableColumnFlags_WidthFixed, show_game_time_ ? 110.f : 0.f); + ImGui::TableSetupColumn("real", ImGuiTableColumnFlags_WidthFixed, show_real_time_ ? 110.f : 0.f); + ImGui::TableNextRow(); + + ImGui::TableSetColumnIndex(0); + ImGui::TextColored(color, "%s", g.label.c_str()); + + if (!g.completed && g.trigger.type == GoalTrigger::Type::ReachTitleRank) { + auto* title = GW::PlayerMgr::GetTitleTrack(g.trigger.title_id); + if (title) { + char prog_buf[64]; + if (title->is_percentage_based()) { + snprintf(prog_buf, sizeof(prog_buf), "%.1f%% / 100%%", + title->max_title_rank > 0 + ? static_cast(title->current_points) / static_cast(title->max_title_rank) * 100.f + : 0.f); + } else { + snprintf(prog_buf, sizeof(prog_buf), "%u / %u", + title->current_points, + title->points_needed_next_rank); + } + ImGui::TextDisabled("%s", prog_buf); + } + } + + if (show_game_time_) { + ImGui::TableSetColumnIndex(1); + if (g.completed) { + char buf[32], seg[32]; + FormatTime(buf, sizeof(buf), g.split.game_time); + FormatTime(seg, sizeof(seg), g.split.segment_game); + ImGui::TextColored({0.6f, 0.85f, 1.f, 1.f}, "%s", buf); + if (show_segment_ && has_prior_split) ImGui::TextDisabled("+%s", seg); + if (pb_basis_ == 1) DrawPBDelta(g.split.game_time, pb_split); + } else if (is_current) { + ImGui::TextDisabled("---"); + } + } + + if (show_real_time_) { + ImGui::TableSetColumnIndex(2); + if (g.completed) { + char buf[32], seg[32]; + FormatTime(buf, sizeof(buf), g.split.real_time); + FormatTime(seg, sizeof(seg), g.split.segment_real); + ImGui::TextColored({0.9f, 0.9f, 0.9f, 1.f}, "%s", buf); + if (show_segment_ && has_prior_split) ImGui::TextDisabled("+%s", seg); + if (pb_basis_ == 0) DrawPBDelta(g.split.real_time, pb_split); + } else if (is_current) { + ImGui::TextDisabled("---"); + } + } + + ImGui::EndTable(); +} + +// --------------------------------------------------------------------------- +// Settings panel +// --------------------------------------------------------------------------- +void SplitsGoalListWindow::DrawSettings(SplitsWindow& plugin) +{ + auto vk_name = [](int vk) -> const char* { + if (vk <= 0) return "None"; + static char buf[32]; + const LONG scan = MapVirtualKeyA(static_cast(vk), MAPVK_VK_TO_VSC) << 16; + if (scan && GetKeyNameTextA(scan, buf, sizeof(buf)) > 0) return buf; + snprintf(buf, sizeof(buf), "VK %d", vk); + return buf; + }; + + static int* capturing_key = nullptr; + static bool capturing_active = false; + + auto draw_keybind = [&](const char* label, int& key) { + char btn_lbl[64]; + snprintf(btn_lbl, sizeof(btn_lbl), "%s##kb_%s", vk_name(key), label); + const bool is_capturing = (capturing_key == &key); + if (is_capturing) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.7f, 0.3f, 0.3f, 1.f)); + ImGui::Button("Press key..."); + ImGui::PopStyleColor(); + for (int vk = 1; vk < 256; ++vk) { + if (vk == VK_LBUTTON || vk == VK_RBUTTON || vk == VK_MBUTTON) continue; + if (GetAsyncKeyState(vk) & 0x8000) { + key = (vk == VK_ESCAPE) ? 0 : vk; + capturing_key = nullptr; + capturing_active = false; + break; + } + } + } else { + if (ImGui::Button(btn_lbl)) { + capturing_key = &key; + capturing_active = true; + } + } + ImGui::SameLine(); + if (ImGui::SmallButton(("x##clr_" + std::string(label)).c_str())) key = 0; + ImGui::SameLine(); + ImGui::TextUnformatted(label); + }; + + GoalList* list = plugin.List(); + if (list_name_buf_[0] == '\0' && !list->name.empty()) + snprintf(list_name_buf_, sizeof(list_name_buf_), "%s", list->name.c_str()); + + ImGui::Columns(3, "settings_cols", false); + + // Col 0: display toggles + PB basis + ImGui::Checkbox("Show real time column", &show_real_time_); + ImGui::Checkbox("Show game time column", &show_game_time_); + ImGui::Checkbox("Show segment column", &show_segment_); + ImGui::Checkbox("Show town time clock", &show_town_time_); + ImGui::Spacing(); + ImGui::TextUnformatted("Compare PB by:"); + ImGui::SameLine(); + ImGui::RadioButton("Real", &pb_basis_, 0); + ImGui::SameLine(); + ImGui::RadioButton("Game", &pb_basis_, 1); + + ImGui::NextColumn(); + + // Col 1: keybinds + ImGui::TextUnformatted("Keybinds:"); + draw_keybind("Start", plugin.KeyStart()); + draw_keybind("Reset", plugin.KeyReset()); + draw_keybind("Split", plugin.KeySplit()); + if (capturing_active) + ImGui::TextDisabled("(Esc to clear)"); + + ImGui::NextColumn(); + + // Col 2: list management + ImGui::TextUnformatted("Goal List"); + ImGui::SetNextItemWidth(130.f); + ImGui::InputText("##listname", list_name_buf_, sizeof(list_name_buf_)); + ImGui::SameLine(); + if (ImGui::Button("New")) { + plugin.NewActiveList(list_name_buf_); + list = plugin.List(); + } + ImGui::SameLine(); + if (ImGui::Button("Save")) { + list->name = list_name_buf_; + plugin.SaveActiveList(); + } + + const auto saved = plugin.GetSavedLists(); + if (!saved.empty()) { + ImGui::TextUnformatted("Load:"); + ImGui::Indent(); + for (const auto& [display, path] : saved) { + if (ImGui::Button(display.c_str())) { + plugin.LoadActiveList(path); + list = plugin.List(); + snprintf(list_name_buf_, sizeof(list_name_buf_), "%s", list->name.c_str()); + } + } + ImGui::Unindent(); + } + + ImGui::Columns(1); + ImGui::Separator(); + + // Existing goals list + ImGui::TextUnformatted("Goals:"); + list = plugin.List(); + bool erased = false; + for (int i = 0; i < static_cast(list->goals.size()) && !erased; ++i) { + const auto& g = list->goals[i]; + ImGui::PushID(i); + + const char* tname = "Man"; + switch (g.trigger.type) { + case GoalTrigger::Type::MapEnter: tname = "Map"; break; + case GoalTrigger::Type::EnterExplorable: tname = "Exp"; break; + case GoalTrigger::Type::ExitExplorable: tname = "Exit"; break; + case GoalTrigger::Type::VanquishComplete: tname = "VQ"; break; + case GoalTrigger::Type::MissionComplete: tname = g.trigger.hard_mode ? "HM" : "Mis"; break; + case GoalTrigger::Type::MissionBonus: tname = g.trigger.hard_mode ? "HMB" : "Bon"; break; + case GoalTrigger::Type::ReachLevel: tname = "Lv"; break; + default: break; + } + ImGui::TextDisabled("[%s]", tname); + ImGui::SameLine(); + ImGui::TextUnformatted(g.label.c_str()); + ImGui::SameLine(); + if (ImGui::SmallButton("X")) { + list->goals.erase(list->goals.begin() + i); + erased = true; + } + ImGui::PopID(); + } + + ImGui::Separator(); + + // Add goal form + ImGui::TextUnformatted("Add Goal:"); + + struct TriggerOption { const char* label; GoalTrigger::Type type; }; + static const TriggerOption trigger_opts[] = { + { "Manual", GoalTrigger::Type::Manual }, + { "Missions", GoalTrigger::Type::MissionComplete }, + { "Explorables", GoalTrigger::Type::MapEnter }, + { "Towns", GoalTrigger::Type::EnterExplorable }, + { "Titles", GoalTrigger::Type::ReachTitleRank }, + { "Reach Level", GoalTrigger::Type::ReachLevel }, + }; + const char* current_trigger_label = trigger_opts[0].label; + for (const auto& opt : trigger_opts) + if (static_cast(opt.type) == edit_trigger_type_) { current_trigger_label = opt.label; break; } + ImGui::SetNextItemWidth(220.f); + if (ImGui::BeginCombo("Trigger##add", current_trigger_label)) { + for (const auto& opt : trigger_opts) { + const bool selected = (static_cast(opt.type) == edit_trigger_type_); + if (ImGui::Selectable(opt.label, selected)) + edit_trigger_type_ = static_cast(opt.type); + if (selected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + + const bool is_mission_batch = (edit_trigger_type_ == static_cast(GoalTrigger::Type::MissionComplete) || + edit_trigger_type_ == static_cast(GoalTrigger::Type::MissionBonus)); + const bool is_explorable_batch = (edit_trigger_type_ == static_cast(GoalTrigger::Type::MapEnter)); + const bool is_town_batch = (edit_trigger_type_ == static_cast(GoalTrigger::Type::EnterExplorable)); + const bool is_title_picker = (edit_trigger_type_ == static_cast(GoalTrigger::Type::ReachTitleRank)); + const bool needs_level = (edit_trigger_type_ == static_cast(GoalTrigger::Type::ReachLevel)); + + if (is_mission_batch) { + DrawMissionBatchPicker(plugin); + } else if (is_explorable_batch) { + DrawExplorableBatchPicker(plugin); + } else if (is_town_batch) { + DrawTownBatchPicker(plugin); + } else if (is_title_picker) { + DrawTitlePicker(plugin); + } else { + ImGui::SetNextItemWidth(200.f); + ImGui::InputText("Label##add", edit_label_, sizeof(edit_label_)); + if (needs_level) { + ImGui::SetNextItemWidth(100.f); + ImGui::InputInt("Level##add", &edit_level_); + if (edit_level_ < 1) edit_level_ = 1; + if (edit_level_ > 20) edit_level_ = 20; + } + + const bool can_add = edit_label_[0] != '\0'; + if (!can_add) ImGui::BeginDisabled(); + if (ImGui::Button("Add Goal")) { + GoalEntry new_g; + new_g.label = edit_label_; + new_g.trigger.type = static_cast(edit_trigger_type_); + new_g.trigger.map_id = GW::Constants::MapID::None; + new_g.trigger.level = edit_level_; + list->goals.push_back(std::move(new_g)); + edit_label_[0] = '\0'; + } + if (!can_add) ImGui::EndDisabled(); + } +} + +// --------------------------------------------------------------------------- +// Title picker +// --------------------------------------------------------------------------- +void SplitsGoalListWindow::DrawTitlePicker(SplitsWindow& plugin) +{ + using TitleID = GW::Constants::TitleID; + + struct TitleEntry { const char* name; TitleID id; const char* group; }; + static const TitleEntry s_titles[] = { + { "Cartographer (Tyria)", TitleID::TyrianCarto, "Character \xe2\x80\x94 Core" }, + { "Cartographer (Cantha)", TitleID::CanthanCarto, "Character \xe2\x80\x94 Core" }, + { "Cartographer (Elona)", TitleID::ElonianCarto, "Character \xe2\x80\x94 Core" }, + { "Drunkard", TitleID::Drunkard, "Character \xe2\x80\x94 Core" }, + { "Guardian (Tyria)", TitleID::GuardianTyria, "Character \xe2\x80\x94 Core" }, + { "Guardian (Cantha)", TitleID::GuardianCantha, "Character \xe2\x80\x94 Core" }, + { "Guardian (Elona)", TitleID::GuardianElona, "Character \xe2\x80\x94 Core" }, + { "Maxed Titles", TitleID::KoaBD, "Character \xe2\x80\x94 Core" }, + { "Party Animal", TitleID::Party, "Character \xe2\x80\x94 Core" }, + { "Protector (Tyria)", TitleID::ProtectorTyria, "Character \xe2\x80\x94 Core" }, + { "Protector (Cantha)", TitleID::ProtectorCantha, "Character \xe2\x80\x94 Core" }, + { "Protector (Elona)", TitleID::ProtectorElona, "Character \xe2\x80\x94 Core" }, + { "Skill Hunter (Tyria)", TitleID::SkillHunterTyria, "Character \xe2\x80\x94 Core" }, + { "Skill Hunter (Cantha)", TitleID::SkillHunterCantha, "Character \xe2\x80\x94 Core" }, + { "Skill Hunter (Elona)", TitleID::SkillHunterElona, "Character \xe2\x80\x94 Core" }, + { "Survivor", TitleID::Survivor, "Character \xe2\x80\x94 Core" }, + { "Sweet Tooth", TitleID::Sweets, "Character \xe2\x80\x94 Core" }, + { "Vanquisher (Tyria)", TitleID::VanquisherTyria, "Character \xe2\x80\x94 Core" }, + { "Vanquisher (Cantha)", TitleID::VanquisherCantha, "Character \xe2\x80\x94 Core" }, + { "Vanquisher (Elona)", TitleID::VanquisherElona, "Character \xe2\x80\x94 Core" }, + { "Legendary Defender of Ascalon", TitleID::LDoA, "Character \xe2\x80\x94 Prophecies" }, + { "Lightbringer", TitleID::Lightbringer, "Character \xe2\x80\x94 Nightfall" }, + { "Sunspear", TitleID::Sunspear, "Character \xe2\x80\x94 Nightfall" }, + { "Asura", TitleID::Asuran, "Character \xe2\x80\x94 Eye of the North" }, + { "Deldrimor", TitleID::Deldrimor, "Character \xe2\x80\x94 Eye of the North" }, + { "Ebon Vanguard", TitleID::Vanguard, "Character \xe2\x80\x94 Eye of the North" }, + { "Master of the North", TitleID::MasterOfTheNorth, "Character \xe2\x80\x94 Eye of the North" }, + { "Norn", TitleID::Norn, "Character \xe2\x80\x94 Eye of the North" }, + { "Legendary Cartographer", TitleID::LegendaryCarto, "Character \xe2\x80\x94 All Campaigns" }, + { "Legendary Guardian", TitleID::LegendaryGuardian, "Character \xe2\x80\x94 All Campaigns" }, + { "Legendary Skill Hunter", TitleID::LegendarySkillHunter, "Character \xe2\x80\x94 All Campaigns" }, + { "Legendary Vanquisher", TitleID::LegendaryVanquisher, "Character \xe2\x80\x94 All Campaigns" }, + { "Champion", TitleID::Champion, "Account \xe2\x80\x94 Core" }, + { "Codex", TitleID::Codex, "Account \xe2\x80\x94 Core" }, + { "Gamer", TitleID::Gamer, "Account \xe2\x80\x94 Core" }, + { "Gladiator", TitleID::Gladiator, "Account \xe2\x80\x94 Core" }, + { "Hero", TitleID::Hero, "Account \xe2\x80\x94 Core" }, + { "Lucky", TitleID::Lucky, "Account \xe2\x80\x94 Core" }, + { "Treasure Hunter", TitleID::TreasureHunter, "Account \xe2\x80\x94 Core" }, + { "Unlucky", TitleID::Unlucky, "Account \xe2\x80\x94 Core" }, + { "Wisdom", TitleID::Wisdom, "Account \xe2\x80\x94 Core" }, + { "Zaishen", TitleID::Zaishen, "Account \xe2\x80\x94 Core" }, + { "Kurzick", TitleID::Kurzick, "Account \xe2\x80\x94 Factions" }, + { "Luxon", TitleID::Luxon, "Account \xe2\x80\x94 Factions" }, + { "Commander", TitleID::Commander, "Account \xe2\x80\x94 Nightfall / EotN" }, + }; + constexpr int NUM_TITLES = static_cast(sizeof(s_titles) / sizeof(s_titles[0])); + + ImGui::SetNextItemWidth(-1.f); + ImGui::InputText("##titlefilter", title_filter_buf_, sizeof(title_filter_buf_)); + ImGui::SameLine(); if (ImGui::SmallButton("x##titlefx")) title_filter_buf_[0] = '\0'; + + const char* prev_group = nullptr; + const bool searching = title_filter_buf_[0] != '\0'; + + if (ImGui::BeginListBox("##titlelist", { -1.f, 180.f })) { + for (int i = 0; i < NUM_TITLES; ++i) { + const TitleEntry& e = s_titles[i]; + if (searching) { + auto it = std::search(e.name, e.name + strlen(e.name), + title_filter_buf_, title_filter_buf_ + strlen(title_filter_buf_), + [](char a, char b) { return tolower((unsigned char)a) == tolower((unsigned char)b); }); + if (it == e.name + strlen(e.name)) continue; + } + if (!searching && e.group != prev_group) { + prev_group = e.group; + ImGui::SeparatorText(e.group); + } + const bool selected = (edit_title_id_ == static_cast(e.id)); + ImGui::Indent(searching ? 0.f : 8.f); + if (ImGui::Selectable(e.name, selected)) + edit_title_id_ = static_cast(e.id); + ImGui::Unindent(searching ? 0.f : 8.f); + if (selected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndListBox(); + } + + const char* sel_title_name = nullptr; + for (int i = 0; i < NUM_TITLES; ++i) { + if (static_cast(s_titles[i].id) == edit_title_id_) { + sel_title_name = s_titles[i].name; + break; + } + } + + auto* live_title = GW::PlayerMgr::GetTitleTrack(static_cast(edit_title_id_)); + const bool can_add = (sel_title_name != nullptr); + + if (!can_add) ImGui::BeginDisabled(); + if (ImGui::Button("Add Goal##titleadd")) { + GoalEntry g; + g.label = sel_title_name; + g.trigger.type = GoalTrigger::Type::ReachTitleRank; + g.trigger.title_id = static_cast(edit_title_id_); + g.trigger.level = live_title ? static_cast(live_title->max_title_tier_index) : 99; + g.trigger.map_id = GW::Constants::MapID::None; + + GoalList* list = plugin.List(); + const bool dup = std::any_of(list->goals.begin(), list->goals.end(), + [&g](const GoalEntry& e) { + return e.trigger.type == g.trigger.type && e.trigger.title_id == g.trigger.title_id; + }); + if (!dup) list->goals.push_back(std::move(g)); + } + if (!can_add) ImGui::EndDisabled(); +} + +// --------------------------------------------------------------------------- +// Town batch picker +// --------------------------------------------------------------------------- +void SplitsGoalListWindow::DrawTownBatchPicker(SplitsWindow& plugin) +{ + using MapID = GW::Constants::MapID; + using Camp = GW::Constants::Campaign; + + static const GW::RegionType s_town_types[] = { + GW::RegionType::City, + GW::RegionType::Outpost, + GW::RegionType::MissionOutpost, + GW::RegionType::Challenge, + GW::RegionType::Marketplace, + GW::RegionType::HeroBattleOutpost, + GW::RegionType::ZaishenBattle, + }; + static const Camp s_camps[] = { Camp::Prophecies, Camp::Factions, Camp::Nightfall, Camp::EyeOfTheNorth }; + static const char* s_camp_labels[] = { "Prophecies", "Factions", "Nightfall", "EotN" }; + constexpr int NUM_CAMPS = 4; + + struct TownRow { int id; std::string name; GW::Region region; }; + + auto build_town_list = [](Camp camp) -> std::vector { + struct Cand { int id; GW::Region region; }; + std::unordered_map best; + for (int id = 1; id < static_cast(MapID::Count); ++id) { + const auto mid = static_cast(id); + const auto* inf = GW::Map::GetMapInfo(mid); + if (!inf || !inf->name_id || !inf->GetIsOnWorldMap() || inf->campaign != camp) continue; + bool is_town = false; + for (auto t : s_town_types) if (inf->type == t) { is_town = true; break; } + if (!is_town) continue; + if (best.find(inf->name_id) == best.end()) + best[inf->name_id] = { id, inf->region }; + } + std::vector out; + out.reserve(best.size()); + for (const auto& kv : best) + out.push_back({ kv.second.id, MapNames::Get(static_cast(kv.second.id)), kv.second.region }); + std::sort(out.begin(), out.end(), [](const TownRow& a, const TownRow& b) { + if (a.region != b.region) return a.region < b.region; + return a.name < b.name; + }); + return out; + }; + + ImGui::SetNextItemWidth(-1.f); + ImGui::InputText("##townfilter", town_filter_buf_, sizeof(town_filter_buf_)); + ImGui::SameLine(); if (ImGui::SmallButton("x##townfx")) town_filter_buf_[0] = '\0'; + + constexpr uint8_t BIT_ENTER = 1; + constexpr uint8_t BIT_LEAVE = 2; + + if (ImGui::BeginTabBar("##town_campaigns")) { + for (int ci = 0; ci < NUM_CAMPS; ++ci) { + if (!ImGui::BeginTabItem(s_camp_labels[ci])) continue; + const auto rows = build_town_list(s_camps[ci]); + + std::vector filtered; + for (const auto& r : rows) { + if (town_filter_buf_[0] != '\0') { + auto it = std::search(r.name.begin(), r.name.end(), + town_filter_buf_, town_filter_buf_ + strlen(town_filter_buf_), + [](char a, char b) { return tolower((unsigned char)a) == tolower((unsigned char)b); }); + if (it == r.name.end()) continue; + } + filtered.push_back(&r); + } + + if (ImGui::SmallButton("All En")) { for (auto* r : filtered) batch_town_checked_[r->id] |= BIT_ENTER; } + ImGui::SameLine(); + if (ImGui::SmallButton("None En")) { for (auto* r : filtered) batch_town_checked_[r->id] &= ~BIT_ENTER; } + ImGui::SameLine(0, 10); + if (ImGui::SmallButton("All Lv")) { for (auto* r : filtered) batch_town_checked_[r->id] |= BIT_LEAVE; } + ImGui::SameLine(); + if (ImGui::SmallButton("None Lv")) { for (auto* r : filtered) batch_town_checked_[r->id] &= ~BIT_LEAVE; } + + constexpr ImGuiTableFlags tflags = ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY; + if (ImGui::BeginTable("##towntbl", 4, tflags, { -1.f, 220.f })) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Town / Outpost", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Enter", ImGuiTableColumnFlags_WidthFixed, 40.f); + ImGui::TableSetupColumn("Leave", ImGuiTableColumnFlags_WidthFixed, 40.f); + ImGui::TableSetupColumn("##cur", ImGuiTableColumnFlags_WidthFixed, 24.f); + ImGui::TableHeadersRow(); + + GW::Region prev_reg = static_cast(0xFFFFFFFFu); + for (const auto* r : filtered) { + ImGui::PushID(r->id); + if (town_filter_buf_[0] == '\0' && r->region != prev_reg) { + prev_reg = r->region; + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + const char* rname = RegionName(r->region); + if (rname) ImGui::TextDisabled("%s", rname); + } + ImGui::TableNextRow(); + uint8_t& bits = batch_town_checked_[r->id]; + + ImGui::TableSetColumnIndex(0); ImGui::TextUnformatted(r->name.c_str()); + ImGui::TableSetColumnIndex(1); + bool en = (bits & BIT_ENTER) != 0; + if (ImGui::Checkbox("##en", &en)) bits = en ? (bits | BIT_ENTER) : (bits & ~BIT_ENTER); + ImGui::TableSetColumnIndex(2); + bool lv = (bits & BIT_LEAVE) != 0; + if (ImGui::Checkbox("##lv", &lv)) bits = lv ? (bits | BIT_LEAVE) : (bits & ~BIT_LEAVE); + ImGui::TableSetColumnIndex(3); + if (ImGui::SmallButton("@")) { + const int cur = static_cast(plugin.CurrentMap()); + if (cur == r->id) bits |= (BIT_ENTER | BIT_LEAVE); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Current map?"); + ImGui::PopID(); + } + ImGui::EndTable(); + } + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } + + int total = 0; + for (const auto& [id, bits] : batch_town_checked_) { + if (bits & BIT_ENTER) ++total; + if (bits & BIT_LEAVE) ++total; + } + + if (total == 0) ImGui::BeginDisabled(); + char add_lbl[48]; + snprintf(add_lbl, sizeof(add_lbl), "Add %d Goal%s##townadd", total, total == 1 ? "" : "s"); + if (ImGui::Button(add_lbl)) { + GoalList* list = plugin.List(); + struct Item { int id; GoalTrigger::Type type; Camp camp; GW::Region region; std::string name; }; + std::vector items; + items.reserve(total); + for (const auto& [id, bits] : batch_town_checked_) { + if (!bits) continue; + const auto mid = static_cast(id); + const auto* inf = GW::Map::GetMapInfo(mid); + const Camp c = inf ? inf->campaign : Camp::Prophecies; + const GW::Region reg = inf ? inf->region : static_cast(0); + const std::string nm = MapNames::Get(mid); + if (bits & BIT_ENTER) items.push_back({ id, GoalTrigger::Type::MapEnter, c, reg, nm }); + if (bits & BIT_LEAVE) items.push_back({ id, GoalTrigger::Type::ExitOutpost, c, reg, nm }); + } + std::sort(items.begin(), items.end(), [](const Item& a, const Item& b) { + if (a.camp != b.camp) return a.camp < b.camp; + if (a.region != b.region) return a.region < b.region; + if (a.name != b.name) return a.name < b.name; + return a.type < b.type; + }); + for (const auto& item : items) { + const bool dup = std::any_of(list->goals.begin(), list->goals.end(), + [&](const GoalEntry& e) { + return e.trigger.type == item.type && e.trigger.map_id == static_cast(item.id); + }); + if (dup) continue; + GoalEntry g; + g.label = (item.type == GoalTrigger::Type::ExitOutpost) ? "Leave " + item.name : "Enter " + item.name; + g.trigger.type = item.type; + g.trigger.map_id = static_cast(item.id); + list->goals.push_back(std::move(g)); + } + batch_town_checked_.clear(); + } + if (total == 0) ImGui::EndDisabled(); +} + +// --------------------------------------------------------------------------- +// Explorable batch picker +// --------------------------------------------------------------------------- +void SplitsGoalListWindow::DrawExplorableBatchPicker(SplitsWindow& plugin) +{ + using MapID = GW::Constants::MapID; + using Camp = GW::Constants::Campaign; + + static const Camp s_camps[] = { Camp::Prophecies, Camp::Factions, Camp::Nightfall, Camp::EyeOfTheNorth }; + static const char* s_camp_labels[] = { "Prophecies", "Factions", "Nightfall", "EotN" }; + constexpr int NUM_CAMPS = 4; + + struct ExpRow { int id; std::string name; GW::Region region; }; + + auto build_exp_list = [](Camp camp) -> std::vector { + struct Cand { int id; int type_rank; GW::Region region; }; + std::unordered_map best; + for (int id = 1; id < static_cast(MapID::Count); ++id) { + const auto mid = static_cast(id); + const auto* info = GW::Map::GetMapInfo(mid); + if (!info || !info->name_id || !info->GetIsOnWorldMap() || info->campaign != camp) continue; + if (info->type != GW::RegionType::ExplorableZone && info->type != GW::RegionType::MissionArea) continue; + const int rank = (info->type == GW::RegionType::ExplorableZone) ? 0 : 1; + auto it = best.find(info->name_id); + if (it == best.end() || rank < it->second.type_rank) + best[info->name_id] = { id, rank, info->region }; + } + std::vector out; + out.reserve(best.size()); + for (const auto& kv : best) + out.push_back({ kv.second.id, MapNames::Get(static_cast(kv.second.id)), kv.second.region }); + std::sort(out.begin(), out.end(), [](const ExpRow& a, const ExpRow& b) { + if (a.region != b.region) return a.region < b.region; + return a.name < b.name; + }); + return out; + }; + + ImGui::SetNextItemWidth(-1.f); + ImGui::InputText("##expfilter", exp_filter_buf_, sizeof(exp_filter_buf_)); + ImGui::SameLine(); if (ImGui::SmallButton("x##expfx")) exp_filter_buf_[0] = '\0'; + + constexpr uint8_t BIT_ENTER = 1; + constexpr uint8_t BIT_VQ = 2; + constexpr uint8_t BIT_LEAVE = 4; + + if (ImGui::BeginTabBar("##exp_campaigns")) { + for (int ci = 0; ci < NUM_CAMPS; ++ci) { + if (!ImGui::BeginTabItem(s_camp_labels[ci])) continue; + const auto rows = build_exp_list(s_camps[ci]); + + std::vector filtered; + for (const auto& r : rows) { + if (exp_filter_buf_[0] != '\0') { + auto it = std::search(r.name.begin(), r.name.end(), + exp_filter_buf_, exp_filter_buf_ + strlen(exp_filter_buf_), + [](char a, char b) { return tolower((unsigned char)a) == tolower((unsigned char)b); }); + if (it == r.name.end()) continue; + } + filtered.push_back(&r); + } + + if (ImGui::SmallButton("All En")) { for (auto* r : filtered) batch_exp_checked_[r->id] |= BIT_ENTER; } + ImGui::SameLine(); + if (ImGui::SmallButton("None En")) { for (auto* r : filtered) batch_exp_checked_[r->id] &= ~BIT_ENTER; } + ImGui::SameLine(0, 10); + if (ImGui::SmallButton("All VQ")) { for (auto* r : filtered) batch_exp_checked_[r->id] |= BIT_VQ; } + ImGui::SameLine(); + if (ImGui::SmallButton("None VQ")) { for (auto* r : filtered) batch_exp_checked_[r->id] &= ~BIT_VQ; } + ImGui::SameLine(0, 10); + if (ImGui::SmallButton("All Lv")) { for (auto* r : filtered) batch_exp_checked_[r->id] |= BIT_LEAVE; } + ImGui::SameLine(); + if (ImGui::SmallButton("None Lv")) { for (auto* r : filtered) batch_exp_checked_[r->id] &= ~BIT_LEAVE; } + + constexpr ImGuiTableFlags tflags = ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY; + if (ImGui::BeginTable("##exptbl", 5, tflags, { -1.f, 220.f })) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Explorable", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Enter", ImGuiTableColumnFlags_WidthFixed, 40.f); + ImGui::TableSetupColumn("VQ", ImGuiTableColumnFlags_WidthFixed, 30.f); + ImGui::TableSetupColumn("Leave", ImGuiTableColumnFlags_WidthFixed, 40.f); + ImGui::TableSetupColumn("##cur", ImGuiTableColumnFlags_WidthFixed, 24.f); + ImGui::TableHeadersRow(); + + GW::Region prev_reg = static_cast(0xFFFFFFFFu); + for (const auto* r : filtered) { + ImGui::PushID(r->id); + if (exp_filter_buf_[0] == '\0' && r->region != prev_reg) { + prev_reg = r->region; + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + const char* rname = RegionName(r->region); + if (rname) ImGui::TextDisabled("%s", rname); + } + ImGui::TableNextRow(); + uint8_t& bits = batch_exp_checked_[r->id]; + + ImGui::TableSetColumnIndex(0); ImGui::TextUnformatted(r->name.c_str()); + ImGui::TableSetColumnIndex(1); + bool en = (bits & BIT_ENTER) != 0; + if (ImGui::Checkbox("##en", &en)) bits = en ? (bits | BIT_ENTER) : (bits & ~BIT_ENTER); + ImGui::TableSetColumnIndex(2); + bool vq = (bits & BIT_VQ) != 0; + if (ImGui::Checkbox("##vq", &vq)) bits = vq ? (bits | BIT_VQ) : (bits & ~BIT_VQ); + ImGui::TableSetColumnIndex(3); + bool lv = (bits & BIT_LEAVE) != 0; + if (ImGui::Checkbox("##lv", &lv)) bits = lv ? (bits | BIT_LEAVE) : (bits & ~BIT_LEAVE); + ImGui::TableSetColumnIndex(4); + if (ImGui::SmallButton("@")) { + const int cur = static_cast(plugin.CurrentMap()); + if (cur == r->id) bits |= (BIT_ENTER | BIT_VQ | BIT_LEAVE); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Current map?"); + ImGui::PopID(); + } + ImGui::EndTable(); + } + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } + + int total = 0; + for (const auto& [id, bits] : batch_exp_checked_) { + if (bits & BIT_ENTER) ++total; + if (bits & BIT_VQ) ++total; + if (bits & BIT_LEAVE) ++total; + } + + if (total == 0) ImGui::BeginDisabled(); + char add_lbl[48]; + snprintf(add_lbl, sizeof(add_lbl), "Add %d Goal%s##expadd", total, total == 1 ? "" : "s"); + if (ImGui::Button(add_lbl)) { + struct Item { int id; GoalTrigger::Type type; Camp camp; GW::Region region; std::string name; }; + std::vector items; + items.reserve(total); + for (const auto& [id, bits] : batch_exp_checked_) { + if (!bits) continue; + const auto mid = static_cast(id); + const auto* inf = GW::Map::GetMapInfo(mid); + const Camp c = inf ? inf->campaign : Camp::Prophecies; + const GW::Region reg = inf ? inf->region : static_cast(0); + const std::string nm = MapNames::Get(mid); + if (bits & BIT_ENTER) items.push_back({ id, GoalTrigger::Type::MapEnter, c, reg, nm }); + if (bits & BIT_VQ) items.push_back({ id, GoalTrigger::Type::VanquishComplete, c, reg, nm }); + if (bits & BIT_LEAVE) items.push_back({ id, GoalTrigger::Type::ExitExplorable, c, reg, nm }); + } + auto type_order = [](GoalTrigger::Type t) { + switch (t) { + case GoalTrigger::Type::MapEnter: return 0; + case GoalTrigger::Type::VanquishComplete:return 1; + case GoalTrigger::Type::ExitExplorable: return 2; + default: return 3; + } + }; + std::sort(items.begin(), items.end(), [&](const Item& a, const Item& b) { + if (a.camp != b.camp) return a.camp < b.camp; + if (a.region != b.region) return a.region < b.region; + if (a.name != b.name) return a.name < b.name; + return type_order(a.type) < type_order(b.type); + }); + GoalList* list = plugin.List(); + for (const auto& item : items) { + const bool dup = std::any_of(list->goals.begin(), list->goals.end(), + [&](const GoalEntry& e) { + return e.trigger.type == item.type && e.trigger.map_id == static_cast(item.id); + }); + if (dup) continue; + GoalEntry g; + if (item.type == GoalTrigger::Type::MapEnter) g.label = "Enter " + item.name; + else if (item.type == GoalTrigger::Type::VanquishComplete) g.label = "VQ " + item.name; + else if (item.type == GoalTrigger::Type::ExitExplorable) g.label = "Leave " + item.name; + else g.label = item.name; + g.trigger.type = item.type; + g.trigger.map_id = static_cast(item.id); + list->goals.push_back(std::move(g)); + } + batch_exp_checked_.clear(); + } + if (total == 0) ImGui::EndDisabled(); +} + +// --------------------------------------------------------------------------- +// Mission batch picker +// --------------------------------------------------------------------------- +void SplitsGoalListWindow::DrawMissionBatchPicker(SplitsWindow& plugin) +{ + using MapID = GW::Constants::MapID; + using Camp = GW::Constants::Campaign; + + struct MissionRow { int id; std::string name; uint32_t chron; }; + + static const Camp s_camps[] = { Camp::Prophecies, Camp::Factions, Camp::Nightfall, Camp::EyeOfTheNorth }; + static const char* s_camp_labels[] = { "Prophecies", "Factions", "Nightfall", "EotN" }; + constexpr int NUM_CAMPS = 3; // EotN skipped + + auto build_list = [](Camp camp) -> std::vector { + if (camp == Camp::Nightfall) { + static const MapID order[] = { + MapID::Chahbek_Village, MapID::Jokanur_Diggings, MapID::Blacktide_Den, + MapID::Consulate_Docks, MapID::Venta_Cemetery, MapID::Kodonur_Crossroads, + MapID::Pogahn_Passage, MapID::Rilohn_Refuge, MapID::Moddok_Crevice, + MapID::Tihark_Orchard, MapID::Dasha_Vestibule, MapID::Dzagonur_Bastion, + MapID::Grand_Court_of_Sebelkeh, MapID::Jennurs_Horde, MapID::Nundu_Bay, + MapID::Gate_of_Desolation, MapID::Ruins_of_Morah, MapID::Gate_of_Pain, + MapID::Gate_of_Madness, MapID::Abaddons_Gate, + }; + std::vector out; + out.reserve(std::size(order)); + for (uint32_t i = 0; i < static_cast(std::size(order)); ++i) + out.push_back({ static_cast(order[i]), MapNames::Get(order[i]), i + 1 }); + return out; + } + if (camp == Camp::Factions) { + static const MapID order[] = { + MapID::Minister_Chos_Estate_outpost_mission, MapID::Zen_Daijun_outpost_mission, + MapID::Vizunah_Square_mission, MapID::Nahpui_Quarter_outpost_mission, + MapID::Tahnnakai_Temple_outpost_mission, MapID::Arborstone_outpost_mission, + MapID::Boreas_Seabed_outpost_mission, MapID::Sunjiang_District_outpost_mission, + MapID::The_Eternal_Grove_outpost_mission, MapID::Gyala_Hatchery_outpost_mission, + MapID::Unwaking_Waters_Kurzick_outpost, MapID::Raisu_Palace_outpost_mission, + MapID::Imperial_Sanctum_outpost_mission, + }; + std::vector out; + out.reserve(std::size(order)); + for (uint32_t i = 0; i < static_cast(std::size(order)); ++i) + out.push_back({ static_cast(order[i]), MapNames::Get(order[i]), i + 1 }); + return out; + } + if (camp == Camp::Prophecies) { + static const MapID order[] = { + MapID::The_Great_Northern_Wall, MapID::Fort_Ranik, MapID::Ruins_of_Surmia, + MapID::Nolani_Academy, MapID::Borlis_Pass, MapID::The_Frost_Gate, + MapID::Gates_of_Kryta, MapID::DAlessio_Seaboard, MapID::Divinity_Coast, + MapID::The_Wilds, MapID::Bloodstone_Fen, MapID::Aurora_Glade, + MapID::Riverside_Province, MapID::Sanctum_Cay, MapID::Dunes_of_Despair, + MapID::Thirsty_River, MapID::Elona_Reach, MapID::Augury_Rock_outpost, + MapID::The_Dragons_Lair, MapID::Ice_Caves_of_Sorrow, MapID::Iron_Mines_of_Moladune, + MapID::Thunderhead_Keep, MapID::Ring_of_Fire, MapID::Abaddons_Mouth, + MapID::Hells_Precipice, + }; + std::vector out; + out.reserve(std::size(order)); + for (uint32_t i = 0; i < static_cast(std::size(order)); ++i) + out.push_back({ static_cast(order[i]), MapNames::Get(order[i]), i + 1 }); + return out; + } + // Dynamic scan for other campaigns + auto type_rank = [](GW::RegionType t) { + switch (t) { + case GW::RegionType::EotnMission: return 0; + case GW::RegionType::CooperativeMission: return 1; + case GW::RegionType::EliteMission: return 1; + case GW::RegionType::MissionOutpost: return 2; + case GW::RegionType::Dungeon: return 3; + default: return 99; + } + }; + struct Candidate { int id; uint32_t chron; int rank; }; + std::unordered_map best; + for (int id = 1; id < static_cast(MapID::Count); ++id) { + const auto mid = static_cast(id); + const auto* info = GW::Map::GetMapInfo(mid); + if (!info || !info->name_id || !info->GetIsOnWorldMap() || info->campaign != camp) continue; + if (info->mission_chronology == 0) continue; + int rank = type_rank(info->type); + if (rank >= 99) continue; + auto it = best.find(info->name_id); + if (it == best.end() || rank < it->second.rank) + best[info->name_id] = { id, info->mission_chronology, rank }; + } + std::vector out; + out.reserve(best.size()); + for (const auto& kv : best) + out.push_back({ kv.second.id, MapNames::Get(static_cast(kv.second.id)), kv.second.chron }); + std::sort(out.begin(), out.end(), + [](const MissionRow& a, const MissionRow& b) { return a.chron < b.chron; }); + return out; + }; + + if (ImGui::BeginTabBar("##batch_campaigns")) { + for (int ci = 0; ci < NUM_CAMPS; ++ci) { + if (!ImGui::BeginTabItem(s_camp_labels[ci])) continue; + const auto missions = build_list(s_camps[ci]); + + if (ImGui::SmallButton("All M")) { for (const auto& m : missions) batch_mis_checked_.insert(m.id); } + ImGui::SameLine(); + if (ImGui::SmallButton("None M")) { for (const auto& m : missions) batch_mis_checked_.erase(m.id); } + ImGui::SameLine(0, 16); + if (ImGui::SmallButton("All B")) { for (const auto& m : missions) batch_bon_checked_.insert(m.id); } + ImGui::SameLine(); + if (ImGui::SmallButton("None B")) { for (const auto& m : missions) batch_bon_checked_.erase(m.id); } + ImGui::SameLine(0, 16); + ImGui::Checkbox("Hard Mode", &batch_hm_); + + constexpr ImGuiTableFlags tflags = ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY; + if (ImGui::BeginTable("##missiontbl", 4, tflags, { -1.f, 220.f })) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("#", ImGuiTableColumnFlags_WidthFixed, 28.f); + ImGui::TableSetupColumn("Mission", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("M", ImGuiTableColumnFlags_WidthFixed, 28.f); + ImGui::TableSetupColumn("B", ImGuiTableColumnFlags_WidthFixed, 28.f); + ImGui::TableHeadersRow(); + + for (int mi = 0; mi < static_cast(missions.size()); ++mi) { + const auto& row = missions[mi]; + ImGui::PushID(row.id); + ImGui::TableNextRow(); + + ImGui::TableSetColumnIndex(0); ImGui::TextDisabled("%d", mi + 1); + ImGui::TableSetColumnIndex(1); ImGui::TextUnformatted(row.name.c_str()); + ImGui::TableSetColumnIndex(2); + bool mc = batch_mis_checked_.count(row.id) > 0; + if (ImGui::Checkbox("##m", &mc)) { if (mc) batch_mis_checked_.insert(row.id); else batch_mis_checked_.erase(row.id); } + ImGui::TableSetColumnIndex(3); + bool bc = batch_bon_checked_.count(row.id) > 0; + if (ImGui::Checkbox("##b", &bc)) { if (bc) batch_bon_checked_.insert(row.id); else batch_bon_checked_.erase(row.id); } + ImGui::PopID(); + } + ImGui::EndTable(); + } + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } + + const int total = static_cast(batch_mis_checked_.size() + batch_bon_checked_.size()); + if (total == 0) ImGui::BeginDisabled(); + char add_lbl[48]; + snprintf(add_lbl, sizeof(add_lbl), "Add %d Goal%s##batchadd", total, total == 1 ? "" : "s"); + if (ImGui::Button(add_lbl)) { + struct BatchItem { int id; bool bonus; Camp camp; uint32_t chron; }; + std::vector items; + items.reserve(total); + for (int id : batch_mis_checked_) { + const auto* info = GW::Map::GetMapInfo(static_cast(id)); + if (info) items.push_back({ id, false, info->campaign, info->mission_chronology }); + } + for (int id : batch_bon_checked_) { + const auto* info = GW::Map::GetMapInfo(static_cast(id)); + if (info) items.push_back({ id, true, info->campaign, info->mission_chronology }); + } + std::sort(items.begin(), items.end(), [](const BatchItem& a, const BatchItem& b) { + if (a.camp != b.camp) return a.camp < b.camp; + if (a.chron != b.chron) return a.chron < b.chron; + return !a.bonus; + }); + GoalList* list = plugin.List(); + for (const auto& item : items) { + GoalEntry g; + g.label = MapNames::Get(static_cast(item.id)); + if (item.bonus) g.label += " - Bonus"; + if (batch_hm_) g.label += " (HM)"; + g.trigger.type = item.bonus ? GoalTrigger::Type::MissionBonus : GoalTrigger::Type::MissionComplete; + g.trigger.map_id = static_cast(item.id); + g.trigger.hard_mode = batch_hm_; + list->goals.push_back(std::move(g)); + } + batch_mis_checked_.clear(); + batch_bon_checked_.clear(); + } + if (total == 0) ImGui::EndDisabled(); +} diff --git a/GWToolboxdll/Windows/Splits/SplitsGoalListWindow.h b/GWToolboxdll/Windows/Splits/SplitsGoalListWindow.h new file mode 100644 index 000000000..1da537229 --- /dev/null +++ b/GWToolboxdll/Windows/Splits/SplitsGoalListWindow.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include + +// Forward declarations +class SplitsWindow; +struct GoalList; +struct GoalEntry; +class GoalClock; + +// --------------------------------------------------------------------------- +// SplitsGoalListWindow — runtime display + settings UI for the Splits window. +// --------------------------------------------------------------------------- +class SplitsGoalListWindow { +public: + void Draw(SplitsWindow& window); + void DrawSettings(SplitsWindow& window); + +private: + void DrawMissionRow(const GoalEntry& mis, const GoalEntry* bon, const GoalClock& clock, + bool is_current, bool has_prior_split, + double pb_split_mis, double pb_split_bon); + void DrawGoalRow(const GoalEntry& g, const GoalClock& clock, int index, + bool is_current, bool has_prior_split, double pb_split); + void DrawMissionBatchPicker(SplitsWindow& window); + void DrawExplorableBatchPicker(SplitsWindow& window); + void DrawTownBatchPicker(SplitsWindow& window); + void DrawTitlePicker(SplitsWindow& window); + + bool show_game_time_ = true; + bool show_real_time_ = true; + bool show_segment_ = true; + bool show_town_time_ = true; + int pb_basis_ = 0; // 0 = real, 1 = game + + char edit_label_[128] = {}; + int edit_trigger_type_ = 0; + int edit_map_id_ = 0; + int edit_level_ = 1; + char list_name_buf_[64] = {}; + char map_filter_buf_[128] = {}; + + std::set batch_mis_checked_; + std::set batch_bon_checked_; + bool batch_hm_ = false; + + std::map batch_exp_checked_; + char exp_filter_buf_[128] = {}; + + std::map batch_town_checked_; + char town_filter_buf_[128] = {}; + + int edit_title_id_ = 0xff; + char title_filter_buf_[128] = {}; +}; diff --git a/GWToolboxdll/Windows/SplitsWindow.cpp b/GWToolboxdll/Windows/SplitsWindow.cpp new file mode 100644 index 000000000..e678c92b8 --- /dev/null +++ b/GWToolboxdll/Windows/SplitsWindow.cpp @@ -0,0 +1,476 @@ +#include "stdafx.h" + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +// --------------------------------------------------------------------------- +// Initialize / Terminate +// --------------------------------------------------------------------------- +void SplitsWindow::Initialize() +{ + ToolboxWindow::Initialize(); + // MapNames::Init() deferred to first Update() — GW UI context may not be ready yet. + + GW::UI::RegisterUIMessageCallback( + &on_mission_complete_hook_, + GW::UI::UIMessage::kMissionComplete, + [this](GW::HookStatus*, GW::UI::UIMessage, void*, void*) { + engine_.NotifyMissionComplete(GW::Map::GetMapID()); + }); + + GW::UI::RegisterUIMessageCallback( + &on_objective_complete_hook_, + GW::UI::UIMessage::kObjectiveComplete, + [this](GW::HookStatus*, GW::UI::UIMessage, void*, void*) { + engine_.NotifyMissionBonus(GW::Map::GetMapID()); + }); +} + +void SplitsWindow::Terminate() +{ + GW::UI::RemoveUIMessageCallback(&on_mission_complete_hook_); + GW::UI::RemoveUIMessageCallback(&on_objective_complete_hook_); + engine_.Detach(); + ToolboxWindow::Terminate(); +} + +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- +void SplitsWindow::LoadSettings(ToolboxIni* ini) +{ + ToolboxWindow::LoadSettings(ini); + + // Establish data folders + const auto splits_path = Resources::GetPath(L"splits"); + const auto runs_path = Resources::GetPath(L"splits\\runs"); + Resources::EnsureFolderExists(splits_path); + Resources::EnsureFolderExists(runs_path); + splits_folder_ = splits_path.wstring() + L"\\"; + runs_folder_ = runs_path.wstring() + L"\\"; + + key_start_ = ini->GetLongValue(Name(), "key_start", 0); + key_reset_ = ini->GetLongValue(Name(), "key_reset", 0); + key_split_ = ini->GetLongValue(Name(), "key_split", 0); + + engine_.Attach(&active_list_); + + // Crash-protection resume check + const std::wstring resume_path = splits_folder_ + L"resume.json"; + std::ifstream rf(resume_path); + if (rf.is_open()) { + try { + json j = json::parse(rf); + rf.close(); + const std::string lname = j.value("list_name", ""); + if (!lname.empty()) { + const std::wstring list_path = splits_folder_ + + std::wstring(lname.begin(), lname.end()) + L".json"; + if (std::filesystem::exists(list_path)) { + pending_resume_name_ = lname; + pending_resume_data_ = j.dump(); + pending_resume_ = true; + } + } + } catch (...) {} + } +} + +void SplitsWindow::SaveSettings(ToolboxIni* ini) +{ + ini->SetLongValue(Name(), "key_start", key_start_); + ini->SetLongValue(Name(), "key_reset", key_reset_); + ini->SetLongValue(Name(), "key_split", key_split_); + ToolboxWindow::SaveSettings(ini); +} + +// --------------------------------------------------------------------------- +// Goal list management +// --------------------------------------------------------------------------- +void SplitsWindow::NewActiveList(const char* name) +{ + engine_.Detach(); + active_list_ = GoalList{}; + active_list_.name = name ? name : "New List"; + clock_.Reset(); + engine_.Attach(&active_list_); +} + +void SplitsWindow::SaveActiveList() +{ + if (splits_folder_.empty() || active_list_.name.empty()) return; + const std::wstring wname(active_list_.name.begin(), active_list_.name.end()); + active_list_.SaveToFile(splits_folder_ + wname + L".json"); +} + +void SplitsWindow::LoadActiveList(const std::wstring& path) +{ + DeleteResumeState(); + engine_.Detach(); + active_list_.LoadFromFile(path); + clock_.Reset(); + engine_.Attach(&active_list_); + LoadPB(); +} + +void SplitsWindow::LoadPB() +{ + pb_splits_.clear(); + pb_splits_game_.clear(); + pb_total_real_ = std::numeric_limits::quiet_NaN(); + + if (runs_folder_.empty() || active_list_.name.empty()) return; + + std::wstring safe_name(active_list_.name.begin(), active_list_.name.end()); + for (auto& c : safe_name) { + if (c == L' ') c = L'_'; + else if (c == L'/' || c == L'\\' || c == L':' || c == L'*' || + c == L'?' || c == L'"' || c == L'<' || c == L'>' || c == L'|') + c = L'-'; + } + const std::wstring runs_path = runs_folder_ + safe_name + L".json"; + std::ifstream rf(runs_path); + if (!rf.is_open()) return; + + json runs; + try { runs = json::parse(rf); } catch (...) { return; } + if (!runs.is_array()) return; + + const int goal_count = static_cast(active_list_.goals.size()); + double best = std::numeric_limits::infinity(); + const json* best_run = nullptr; + for (const auto& run : runs) { + if (!run.contains("total_real") || !run.contains("splits")) continue; + if (static_cast(run["splits"].size()) < goal_count) continue; + const double t = run["total_real"].get(); + if (t < best) { best = t; best_run = &run; } + } + if (!best_run) return; + + pb_total_real_ = best; + const auto& jsplits = (*best_run)["splits"]; + const auto nan = std::numeric_limits::quiet_NaN(); + pb_splits_.resize(static_cast(goal_count), nan); + pb_splits_game_.resize(static_cast(goal_count), nan); + for (int i = 0; i < goal_count && i < static_cast(jsplits.size()); ++i) { + pb_splits_[static_cast(i)] = jsplits[i].value("real_time", nan); + pb_splits_game_[static_cast(i)] = jsplits[i].value("game_time", nan); + } +} + +std::vector> SplitsWindow::GetSavedLists() const +{ + if (splits_folder_.empty()) return {}; + return GoalList::ListSaved(splits_folder_); +} + +// --------------------------------------------------------------------------- +// Crash protection +// --------------------------------------------------------------------------- +void SplitsWindow::SaveResumeState() +{ + if (splits_folder_.empty() || !clock_.IsRunning() || active_list_.name.empty()) return; + + json j; + j["list_name"] = active_list_.name; + j["real_time"] = clock_.RealTime(); + j["game_time"] = clock_.GameTime(); + + json jgoals = json::array(); + for (const auto& g : active_list_.goals) { + if (!g.completed) { + jgoals.push_back(nullptr); + } else { + json js; + js["real_time"] = g.split.real_time; + js["game_time"] = g.split.game_time; + js["segment_real"] = g.split.segment_real; + js["segment_game"] = g.split.segment_game; + jgoals.push_back(std::move(js)); + } + } + j["goals"] = std::move(jgoals); + + std::ofstream f(splits_folder_ + L"resume.json"); + if (f.is_open()) f << j.dump(2); +} + +void SplitsWindow::DeleteResumeState() +{ + pending_resume_ = false; + pending_resume_name_.clear(); + pending_resume_data_.clear(); + if (splits_folder_.empty()) return; + std::error_code ec; + std::filesystem::remove(splits_folder_ + L"resume.json", ec); +} + +void SplitsWindow::ApplyResume() +{ + if (!pending_resume_) return; + pending_resume_ = false; + + json j; + try { j = json::parse(pending_resume_data_); } catch (...) { return; } + pending_resume_data_.clear(); + + const std::string lname = j.value("list_name", ""); + if (lname.empty()) return; + const double real_time = j.value("real_time", 0.0); + const double game_time = j.value("game_time", 0.0); + + const std::wstring list_path = splits_folder_ + + std::wstring(lname.begin(), lname.end()) + L".json"; + + engine_.Detach(); + active_list_.LoadFromFile(list_path); + engine_.Attach(&active_list_); + + const auto& jgoals = j.value("goals", json::array()); + for (int i = 0; i < static_cast(jgoals.size()) && + i < static_cast(active_list_.goals.size()); ++i) { + const auto& jg = jgoals[i]; + if (jg.is_null()) continue; + auto& g = active_list_.goals[i]; + g.completed = true; + g.split.real_time = jg.value("real_time", 0.0); + g.split.game_time = jg.value("game_time", 0.0); + g.split.segment_real = jg.value("segment_real", 0.0); + g.split.segment_game = jg.value("segment_game", 0.0); + } + + clock_.Restore(real_time, game_time); + engine_.ForceStarted(); + last_map_ = GW::Constants::MapID::None; + last_instance_time_ = 0; +} + +void SplitsWindow::DiscardResume() +{ + pending_resume_ = false; + pending_resume_data_.clear(); + DeleteResumeState(); +} + +// --------------------------------------------------------------------------- +// SaveCompletedRun +// --------------------------------------------------------------------------- +void SplitsWindow::SaveCompletedRun() +{ + run_complete_ = true; + clock_.Pause(); + DeleteResumeState(); + + if (runs_folder_.empty()) return; + + const std::time_t now = std::time(nullptr); + struct tm tm_local{}; + localtime_s(&tm_local, &now); + char ts_display[32]; + std::strftime(ts_display, sizeof(ts_display), "%Y-%m-%d %H:%M:%S", &tm_local); + + json attempt; + attempt["date"] = ts_display; + attempt["character"] = run_char_name_; + attempt["level"] = run_char_level_; + attempt["total_real"] = clock_.RealTime(); + attempt["total_game"] = clock_.GameTime(); + + json jsplits = json::array(); + for (const auto& g : active_list_.goals) { + json js; + js["label"] = g.label; + js["real_time"] = g.split.real_time; + js["game_time"] = g.split.game_time; + js["segment_real"] = g.split.segment_real; + js["segment_game"] = g.split.segment_game; + jsplits.push_back(std::move(js)); + } + attempt["splits"] = std::move(jsplits); + + std::wstring safe_name(active_list_.name.begin(), active_list_.name.end()); + for (auto& c : safe_name) { + if (c == L' ') c = L'_'; + else if (c == L'/' || c == L'\\' || c == L':' || c == L'*' || + c == L'?' || c == L'"' || c == L'<' || c == L'>' || c == L'|') + c = L'-'; + } + + std::error_code ec; + std::filesystem::create_directories(runs_folder_, ec); + const std::wstring out_path = runs_folder_ + safe_name + L".json"; + + json runs = json::array(); + { + std::ifstream rf(out_path); + if (rf.is_open()) { + try { runs = json::parse(rf); } catch (...) { runs = json::array(); } + if (!runs.is_array()) runs = json::array(); + } + } + runs.push_back(std::move(attempt)); + + std::ofstream f(out_path); + if (f.is_open()) f << runs.dump(2); + + // Update in-memory PB if this run beats it + const double total = clock_.RealTime(); + if (std::isnan(pb_total_real_) || total < pb_total_real_) { + pb_total_real_ = total; + const int n = static_cast(active_list_.goals.size()); + pb_splits_.resize(static_cast(n)); + pb_splits_game_.resize(static_cast(n)); + for (int i = 0; i < n; ++i) { + pb_splits_[static_cast(i)] = active_list_.goals[static_cast(i)].split.real_time; + pb_splits_game_[static_cast(i)] = active_list_.goals[static_cast(i)].split.game_time; + } + } +} + +// --------------------------------------------------------------------------- +// Update — called every frame +// --------------------------------------------------------------------------- +void SplitsWindow::Update(float delta) +{ + MapNames::Init(); // no-op after first successful call; deferred here so GW UI is ready + + const auto instance_type = GW::Map::GetInstanceType(); + const bool is_explorable = (instance_type == GW::Constants::InstanceType::Explorable); + const GW::Constants::MapID current_map = GW::Map::GetMapID(); + + const bool map_changed = (current_map != last_map_) && current_map != GW::Constants::MapID::None; + const bool just_entered_map = map_changed; + const bool came_from_explorable = map_changed && last_was_explorable_; + + const bool is_loading = (instance_type == GW::Constants::InstanceType::Loading); + const bool in_cinematic = GW::Map::GetIsInCinematic(); + const bool time_paused = is_loading || in_cinematic; + + if (!time_paused) + clock_.AddRealTime(static_cast(delta)); + + if (is_explorable && !time_paused) { + const uint32_t inst = GW::Map::GetInstanceTime(); + if (last_instance_time_ > 0 && inst > last_instance_time_) + clock_.AddGameTime((inst - last_instance_time_) / 1000.0); + last_instance_time_ = inst; + } else { + last_instance_time_ = 0; + } + + vq_complete_ = false; + if (is_explorable) { + const uint32_t foes = GW::Map::GetFoesToKill(); + const uint32_t killed = GW::Map::GetFoesKilled(); + vq_complete_ = (foes > 0 || killed > 0) && foes == 0; + } + + int player_level = 0; + if (const auto* player = GW::Agents::GetControlledCharacter()) + player_level = static_cast(player->level); + + const int fired = engine_.Update(clock_, current_map, just_entered_map, + came_from_explorable, instance_type, + vq_complete_, player_level); + + if (clock_.IsRunning()) { + resume_save_timer_ += static_cast(delta); + if (fired >= 0 || resume_save_timer_ >= 1.0f) { + SaveResumeState(); + resume_save_timer_ = 0.f; + } + } + + if (!run_complete_ && clock_.IsRunning() && !active_list_.goals.empty()) { + bool all_done = true; + for (const auto& g : active_list_.goals) { + if (!g.completed) { all_done = false; break; } + } + if (all_done) SaveCompletedRun(); + } + + last_map_ = current_map; + if (!is_loading) + last_was_explorable_ = is_explorable; + + // Keybind edge detection + auto poll_key = [](int vk, bool& prev) -> bool { + if (vk <= 0) { prev = false; return false; } + const bool held = (GetAsyncKeyState(vk) & 0x8000) != 0; + const bool fired2 = held && !prev; + prev = held; + return fired2; + }; + if (poll_key(key_start_, key_start_prev_)) StartRun(); + if (poll_key(key_reset_, key_reset_prev_)) ResetRun(); + if (poll_key(key_split_, key_split_prev_)) TriggerManualSplit(); +} + +// --------------------------------------------------------------------------- +// Draw +// --------------------------------------------------------------------------- +void SplitsWindow::Draw(IDirect3DDevice9*) +{ + if (!visible) return; + window_.Draw(*this); +} + +void SplitsWindow::DrawSettingsInternal() +{ + window_.DrawSettings(*this); +} + +// --------------------------------------------------------------------------- +// Controls +// --------------------------------------------------------------------------- +void SplitsWindow::StartRun() +{ + engine_.Attach(&active_list_); + if (clock_.IsRunning()) { + clock_.Pause(); + } else { + auto wide_to_utf8 = [](const wchar_t* ws) -> std::string { + if (!ws || !*ws) return {}; + const int sz = WideCharToMultiByte(CP_UTF8, 0, ws, -1, nullptr, 0, nullptr, nullptr); + if (sz <= 1) return {}; + std::string s(static_cast(sz) - 1, '\0'); + WideCharToMultiByte(CP_UTF8, 0, ws, -1, s.data(), sz, nullptr, nullptr); + return s; + }; + run_char_name_.clear(); + run_char_level_ = 0; + if (const wchar_t* wname = GW::PlayerMgr::GetPlayerName()) + run_char_name_ = wide_to_utf8(wname); + if (const auto* agent = GW::Agents::GetControlledCharacter()) + run_char_level_ = static_cast(agent->level); + clock_.Start(); + } + last_map_ = GW::Constants::MapID::None; + last_instance_time_ = 0; +} + +void SplitsWindow::ResetRun() +{ + DeleteResumeState(); + run_complete_ = false; + engine_.Reset(); + clock_.Reset(); + last_map_ = GW::Constants::MapID::None; + last_instance_time_ = 0; +} + +void SplitsWindow::TriggerManualSplit() +{ + engine_.TriggerManual(clock_); +} diff --git a/GWToolboxdll/Windows/SplitsWindow.h b/GWToolboxdll/Windows/SplitsWindow.h new file mode 100644 index 000000000..a9468e9af --- /dev/null +++ b/GWToolboxdll/Windows/SplitsWindow.h @@ -0,0 +1,111 @@ +#pragma once + +#include + +#include + +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// SplitsWindow — speedrun split timer, ported from the GWSplits plugin. +// --------------------------------------------------------------------------- +class SplitsWindow : public ToolboxWindow { + SplitsWindow() = default; + ~SplitsWindow() override = default; + +public: + static SplitsWindow& Instance() + { + static SplitsWindow instance; + return instance; + } + + [[nodiscard]] const char* Name() const override { return "Splits"; } + [[nodiscard]] const char* Icon() const override { return ICON_FA_STOPWATCH; } + + void Initialize() override; + void Terminate() override; + + void Update(float delta) override; + void Draw(IDirect3DDevice9* device) override; + void DrawSettingsInternal() override; + + void LoadSettings(ToolboxIni* ini) override; + void SaveSettings(ToolboxIni* ini) override; + + // Called by UI + void StartRun(); + void ResetRun(); + void TriggerManualSplit(); + + // Keybind accessors (VK codes; 0 = unbound) + int& KeyStart() { return key_start_; } + int& KeyReset() { return key_reset_; } + int& KeySplit() { return key_split_; } + + // Read-only accessors for UI + [[nodiscard]] const GoalClock& Clock() const { return clock_; } + [[nodiscard]] const GoalEngine& Engine() const { return engine_; } + [[nodiscard]] GoalList* List() { return &active_list_; } + [[nodiscard]] GW::Constants::MapID CurrentMap() const { return last_map_; } + [[nodiscard]] bool RunComplete() const { return run_complete_; } + + void NewActiveList(const char* name); + void SaveActiveList(); + void LoadActiveList(const std::wstring& path); + [[nodiscard]] std::vector> GetSavedLists() const; + + [[nodiscard]] const std::vector& PBSplits() const { return pb_splits_; } + [[nodiscard]] const std::vector& PBSplitsGame() const { return pb_splits_game_; } + [[nodiscard]] double PBTotal() const { return pb_total_real_; } + + [[nodiscard]] bool HasPendingResume() const { return pending_resume_; } + [[nodiscard]] const char* PendingResumeName() const { return pending_resume_name_.c_str(); } + void ApplyResume(); + void DiscardResume(); + +private: + GoalClock clock_; + GoalEngine engine_; + GoalList active_list_; + SplitsGoalListWindow window_; + + GW::Constants::MapID last_map_ = GW::Constants::MapID::None; + uint32_t last_instance_time_ = 0; + bool vq_complete_ = false; + bool last_was_explorable_ = false; + + int key_start_ = 0; + int key_reset_ = 0; + int key_split_ = 0; + bool key_start_prev_ = false; + bool key_reset_prev_ = false; + bool key_split_prev_ = false; + + GW::HookEntry on_mission_complete_hook_; + GW::HookEntry on_objective_complete_hook_; + + std::wstring splits_folder_; // e.g. /splits/ + std::wstring runs_folder_; // e.g. /splits/runs/ + + void SaveResumeState(); + void DeleteResumeState(); + void SaveCompletedRun(); + void LoadPB(); + + bool run_complete_ = false; + std::string run_char_name_; + int run_char_level_ = 0; + + std::vector pb_splits_; + std::vector pb_splits_game_; + double pb_total_real_ = std::numeric_limits::quiet_NaN(); + + float resume_save_timer_ = 0.f; + bool pending_resume_ = false; + std::string pending_resume_name_; + std::string pending_resume_data_; +};