Files
AyeZeeBB 36ce7cc263 Launcher: "Test pre-releases" only shows when there is one, and either update switch checks again at once
The update check reads GitHub's release list either way (one call, as before) and notes whether a
pre-release newer than the latest release is there; the setting is offered only then, or while it
is on so it can be turned off. Unreachable GitHub keeps what the last check found.

Flipping "Install ReSkate updates" or "Test pre-releases" saves and checks again straight away, so
PLAY or the update button changes to match: updates off while one is offered goes back to PLAY,
pre-releases on offers the pre-release, off the latest release. A flip while a check is running is
checked as soon as it ends (each frame asks; it only compares two settings).
2026-10-17 01:50:50 -05:00

501 lines
24 KiB
C++

#include "gui_internal.h"
#include <cmath>
#include <format>
// The Settings panel.
namespace dingosdk::launcher_gui::detail {
namespace {
// A muted explanation line.
void setting_note(const char* text) {
ImGui::PushTextWrapPos(0);
ImGui::TextDisabled("%s", text);
ImGui::PopTextWrapPos();
ImGui::Spacing();
}
// A checkbox with its label and a muted explanation stacked beside the box,
// so the explanation sits right under the label instead of under the box.
void setting_check(const char* label, const char* note, bool& value) {
ImGui::PushID(label);
const auto start = ImGui::GetCursorPos();
ImGui::Checkbox("##check", &value);
const float below_box = ImGui::GetCursorPosY();
// Not SameLine: that would drop the text to the box's frame padding. The
// label's top lines up with the box's top.
ImGui::SetCursorPos(ImVec2(start.x + ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.x, start.y));
ImGui::BeginGroup();
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, S(1)));
ImGui::TextUnformatted(label);
if (ImGui::IsItemClicked()) value = !value;
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::PushTextWrapPos(0);
ImGui::TextDisabled("%s", note);
ImGui::PopTextWrapPos();
ImGui::PopStyleVar();
ImGui::EndGroup();
if (ImGui::GetCursorPosY() < below_box) ImGui::SetCursorPosY(below_box);
ImGui::PopID();
ImGui::Spacing();
ImGui::Spacing();
}
void section_caption(const Fonts& fonts, const char* text) {
ImGui::PushFont(fonts.caption);
ImGui::TextDisabled("%s", text);
ImGui::PopFont();
}
// ---------------------------------------------------------------- skate.'s own settings
namespace game = launcher_game_settings;
constexpr float game_control_x = 300, game_control_width = 260;
// Reads the game's settings the first time a page of them is drawn after Settings opens, and
// says why when there are none to show. False: nothing to draw.
bool game_settings_ready(Launcher& launcher, Ui& ui, bool& running) {
auto& pages = ui.game;
if (!pages.loaded) {
pages.loaded = true;
pages.error.clear();
try {
pages.saved = game::load(game::save_root());
} catch (const std::exception& failure) {
pages.saved = {};
pages.error = failure.what();
}
pages.values = pages.saved.values;
pages.resolutions = game::display_resolutions();
}
if (pages.saved.folders.empty()) {
setting_note(pages.error.empty() ? "skate. has not saved its settings on this PC yet. Start the game once and they "
"appear here." : pages.error.c_str());
return false;
}
running = launcher.game() != 0 || launcher.busy();
setting_note("skate.'s own settings, the ones in its Settings menu. The game reads them when it starts.");
if (running) setting_note("Skate is running: close it to change these here.");
if (!pages.error.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(color::danger));
ImGui::PushTextWrapPos(0);
ImGui::TextUnformatted(pages.error.c_str());
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
ImGui::Spacing();
}
return true;
}
// Stores what the pages show once it differs from the game's save and no control is held (a
// slider being dragged writes when it is let go).
void store_game_settings(Ui& ui) {
auto& pages = ui.game;
if (!pages.loaded || pages.saved.folders.empty() || ImGui::IsAnyItemActive()) return;
game::Values changes;
for (const auto& [key, value] : pages.values) {
const auto before = pages.saved.values.find(key);
if (before != pages.saved.values.end() && before->second != value) changes.emplace(key, value);
}
if (changes.empty()) return;
try {
// (...\ReSkate\Game\Skate\data -> ...\ReSkate\game-settings-backup)
game::save(pages.saved, changes, game::save_root().parent_path().parent_path().parent_path() / L"game-settings-backup");
pages.saved.values = pages.values;
pages.error.clear();
} catch (const std::exception& failure) {
pages.error = std::string("Not saved: ") + failure.what() + ".";
pages.values = pages.saved.values;
}
}
// One of the game's settings pages: each setting a label and its control, under its section.
void game_settings_page(const Fonts& fonts, Ui& ui, std::string_view page, bool running) {
auto& pages = ui.game;
const char* section = "";
ImGui::BeginDisabled(running);
for (const auto& item : game::items()) {
if (page != item.page) continue;
// A setting this game version does not save is not offered.
const auto found = pages.values.find(item.key);
if (found == pages.values.end()) continue;
if (std::string_view(section) != item.section) {
if (*section) ImGui::Spacing(), ImGui::Spacing();
section = item.section;
section_caption(fonts, section);
}
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(item.label);
ImGui::SameLine(S(game_control_x));
ImGui::PushID(item.key);
ImGui::SetNextItemWidth(S(game_control_width));
const auto steps = [&](float value) {
const float snapped = item.least + std::round((value - item.least) / item.step) * item.step;
return std::clamp(snapped, item.least, item.most);
};
switch (item.kind) {
case game::Kind::toggle:
if (auto* on = std::get_if<bool>(&found->second)) toggle("##value", on);
break;
case game::Kind::whole:
if (auto* number = std::get_if<std::int32_t>(&found->second)) {
int value = *number;
if (ImGui::SliderInt("##value", &value, static_cast<int>(item.least), static_cast<int>(item.most), "%d",
ImGuiSliderFlags_AlwaysClamp))
*number = static_cast<std::int32_t>(steps(static_cast<float>(value)));
}
break;
case game::Kind::real:
if (auto* number = std::get_if<float>(&found->second)) {
float value = *number;
if (ImGui::SliderFloat("##value", &value, item.least, item.most,
item.step >= 1 ? "%.0f" : item.step >= 0.1f ? "%.1f" : "%.2f", ImGuiSliderFlags_AlwaysClamp))
*number = steps(value);
}
break;
case game::Kind::choice:
if (auto* number = std::get_if<std::int32_t>(&found->second)) {
// A value the launcher has no name for shows as its number.
std::string preview = std::to_string(*number);
for (const auto& choice : item.choices)
if (choice.value == *number) preview = choice.label;
if (ImGui::BeginCombo("##value", preview.c_str())) {
for (const auto& choice : item.choices)
if (ImGui::Selectable(choice.label, choice.value == *number)) *number = choice.value;
ImGui::EndCombo();
}
}
break;
case game::Kind::resolution:
// The game saves a place in this PC's list of display sizes.
if (auto* number = std::get_if<std::int32_t>(&found->second)) {
const auto name = [&](int index) {
if (index < 0 || index >= static_cast<int>(pages.resolutions.size())) return std::format("Mode {}", index);
const auto [width, height] = pages.resolutions[static_cast<std::size_t>(index)];
return std::format("{} x {}", width, height);
};
if (ImGui::BeginCombo("##value", name(*number).c_str())) {
for (int index = 0; index < static_cast<int>(pages.resolutions.size()); ++index)
if (ImGui::Selectable(name(index).c_str(), index == *number)) *number = index;
ImGui::EndCombo();
}
}
break;
}
ImGui::PopID();
}
ImGui::EndDisabled();
}
// Settings > GRAPHICS, under the display settings: the game's graphics options, which it
// saves together as one text.
void graphics_options(const Fonts& fonts, Ui& ui, bool running) {
auto& pages = ui.game;
const auto text = pages.values.find(game::options_key);
if (text == pages.values.end() || !std::holds_alternative<std::string>(text->second)) return;
game::Options options;
try {
options = game::parse_options(std::get<std::string>(text->second));
} catch (const std::exception&) {
return;
}
const auto value_of = [&](std::string_view name) -> std::string* {
for (auto& option : options)
if (option.name == name) return &option.value;
return nullptr;
};
bool changed = false;
ImGui::BeginDisabled(running);
ImGui::Spacing();
ImGui::Spacing();
section_caption(fonts, "SET EVERY QUALITY OPTION TO");
static constexpr std::array<const char*, 4> levels{"Low", "Medium", "High", "Ultra"};
for (int level = 0; level < static_cast<int>(levels.size()); ++level) {
if (level) ImGui::SameLine();
if (!ImGui::Button(levels[static_cast<std::size_t>(level)], ImVec2(S(110), 0))) continue;
for (const auto& setting : game::graphics_options())
if (const auto* wanted = game::level_value(setting, level))
if (auto* value = value_of(setting.name)) *value = wanted, changed = true;
}
const char* category = "";
for (const auto& setting : game::graphics_options()) {
// An option this game version does not save is not offered.
auto* value = value_of(setting.name);
if (!value) continue;
if (std::string_view(category) != setting.category) {
category = setting.category;
ImGui::Spacing();
ImGui::Spacing();
section_caption(fonts, category);
}
const auto* needed = *setting.needs_name ? value_of(setting.needs_name) : nullptr;
ImGui::BeginDisabled(*setting.needs_name && (!needed || *needed != setting.needs_value));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(setting.label);
ImGui::SameLine(S(game_control_x));
ImGui::SetNextItemWidth(S(game_control_width));
// A value the launcher has no name for (set in game, or by a newer game) shows as it is.
const char* preview = value->c_str();
for (const auto& choice : setting.choices)
if (*value == choice.value) preview = choice.label;
ImGui::PushID(setting.name);
if (ImGui::BeginCombo("##value", preview)) {
for (const auto& choice : setting.choices)
if (ImGui::Selectable(choice.label, *value == choice.value) && *value != choice.value)
*value = choice.value, changed = true;
ImGui::EndCombo();
}
ImGui::PopID();
ImGui::EndDisabled();
}
ImGui::EndDisabled();
if (!changed) return;
// The game's preset no longer describes them.
if (auto* preset = value_of(game::preset_name)) *preset = game::preset_custom;
try {
text->second = game::join_options(options);
} catch (const std::exception&) {}
}
// Settings > KEYS: the keys that open ReSkate's menu and console in game.
// Returns true when a key changed, so the caller can save straight away.
bool key_bindings(const Fonts& fonts, Settings& settings, Ui& ui) {
bool changed = false;
if (ui.binding && !g_capturing_key) {
const auto key = g_captured_key.exchange(0);
int& target = ui.binding == 1 ? settings.menu_key : settings.console_key;
const int other = ui.binding == 1 ? settings.console_key : settings.menu_key;
if (key == VK_ESCAPE) ui.key_error.clear();
else if (!launcher::bindable_key(key))
ui.key_error = launcher::key_name(key) + " can't be used. Pick another key.";
else if (static_cast<int>(key) == other)
ui.key_error = launcher::key_name(key) + " already opens the " + (ui.binding == 1 ? "console." : "menu.");
else { changed = target != static_cast<int>(key); target = static_cast<int>(key); ui.key_error.clear(); }
ui.binding = 0;
}
section_caption(fonts, "IN-GAME KEYS");
setting_note("Click a key, then press the one you want. Esc cancels.");
const auto row = [&](int which, const char* label, const char* detail, int key) {
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(label);
ImGui::SameLine(S(200));
const bool waiting = ui.binding == which;
const auto text = (waiting ? std::string("Press a key...")
: launcher::key_name(static_cast<unsigned>(key))) + "##key" + std::to_string(which);
if (waiting) push_primary_button();
if (ImGui::Button(text.c_str(), ImVec2(-1, 0)) && !waiting) {
ui.binding = which;
ui.key_error.clear();
g_captured_key = 0;
g_capturing_key = true;
}
if (waiting) pop_primary_button();
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + S(200));
ImGui::TextDisabled("%s", detail);
ImGui::Spacing();
};
row(1, "ReSkate menu", "Opens and closes the ReSkate menu.", settings.menu_key);
row(2, "Console", "Opens and closes the command console.", settings.console_key);
if (!ui.key_error.empty())
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::danger), "%s", ui.key_error.c_str());
ImGui::Spacing();
const bool defaults = settings.menu_key == static_cast<int>(launcher::default_menu_key) &&
settings.console_key == static_cast<int>(launcher::default_console_key);
ImGui::BeginDisabled(defaults || ui.binding);
if (ImGui::Button("Reset to defaults")) {
settings.menu_key = static_cast<int>(launcher::default_menu_key);
settings.console_key = static_cast<int>(launcher::default_console_key);
ui.key_error.clear();
changed = true;
}
ImGui::EndDisabled();
ImGui::Spacing();
setting_note("The chosen keys stop reaching the game while it runs, so avoid keys you skate with.");
return changed;
}
} // namespace
void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, HWND window) {
bool& open = ui.settings;
// A page, like the mod manager: the same rail, the same way out.
const auto frame = begin_page("##settings_panel", size);
auto* draw = ImGui::GetWindowDrawList();
auto& settings = launcher.settings();
const auto& paths = launcher.session().paths;
const bool busy = launcher.busy();
const bool binding = ui.binding != 0;
window_buttons(draw, window, frame);
const float rail_x = S(28), rail_width = S(236);
const float content_x = rail_x + rail_width + S(26);
const float top = S(52), bottom = frame.y - S(24);
const float title_size = S(44);
page_title(draw, fonts, ImVec2(rail_x + S(2), top - S(4)), "SETTINGS", title_size);
// ------------------------------------------------ rail
const float rail_top = top + title_size + S(20);
ImGui::SetCursorPos(ImVec2(rail_x, rail_top));
// Flattened, so a controller's D-pad crosses from the rail into the page and back.
ImGui::BeginChild("##settings_rail", ImVec2(rail_width, bottom - rail_top), ImGuiChildFlags_NavFlattened);
// The launcher's own pages, then the game's (game_settings.h).
static constexpr std::array<const char*, 9> names{"GAME", "DISPLAY", "KEYS", "ADVANCED",
"GRAPHICS", "AUDIO", "CAMERA", "CONTROLS", "REPLAY"};
constexpr int game_pages = 4;
ImGui::BeginDisabled(binding);
for (int i = 0; i < static_cast<int>(names.size()); ++i) {
if (i == game_pages) {
ImGui::Dummy(ImVec2(0, S(4)));
section_caption(fonts, "SKATE. SETTINGS");
}
if (nav_tile(fonts, rail_width, names[static_cast<std::size_t>(i)], ui.settings_tab == i))
ui.settings_tab = i;
// A controller starts on the open tab's tile.
if (ui.settings_tab == i) default_focus();
}
ImGui::SetCursorPosY(ImGui::GetWindowHeight() - S(38));
push_primary_button();
if (ImGui::Button("\xe2\x86\x90 BACK", ImVec2(-1, S(34)))) open = false;
pop_primary_button();
ImGui::EndDisabled();
ImGui::EndChild();
// ------------------------------------------------ the open tab
// Kept to a readable column: settings are sentences, not a table.
ImGui::SetCursorPos(ImVec2(content_x, top));
ImGui::BeginChild("##settings_content",
ImVec2(std::min(S(760), frame.x - content_x - S(28)), bottom - top), ImGuiChildFlags_NavFlattened);
const float footer = ImGui::GetTextLineHeight() + S(16);
ImGui::BeginChild("##settings_page", ImVec2(0, ImGui::GetWindowHeight() - footer), ImGuiChildFlags_NavFlattened);
switch (ui.settings_tab) {
case 0: {
section_caption(fonts, "GAME FOLDER");
ImGui::PushTextWrapPos(0);
ImGui::TextUnformatted(utf8(paths.directory.wstring()).c_str());
ImGui::PopTextWrapPos();
if (ImGui::Button("Open folder")) open_path(paths.directory);
ImGui::SameLine();
ImGui::BeginDisabled(busy);
if (ImGui::Button("Verify game files")) { open = false; open_sign_in(launcher, ui, true); }
ImGui::EndDisabled();
ImGui::Spacing();
ImGui::Spacing();
section_caption(fonts, "PLAY");
setting_check("Offline mode", "Play without Steam as Unknown Player; multiplayer is hidden. "
"Used automatically whenever Steam isn't running.", settings.offline);
setting_check("Loose files", "Export the game's scripts/ and config/ beside Skate.exe so you can edit them.",
settings.loose_files);
setting_check("Discord status", "Show on your Discord profile where you are skating: the map, the server or lobby "
"and how many are in it. Off, the game does not talk to Discord at all.", settings.discord_status);
ImGui::Spacing();
section_caption(fonts, "EXTRA GAME ARGUMENTS");
std::array<char, 1024> arguments{};
std::copy_n(settings.arguments.begin(), std::min(settings.arguments.size(), arguments.size() - 1), arguments.begin());
ImGui::SetNextItemWidth(-1);
if (ImGui::InputTextWithHint("##arguments", "Arguments for Skate.exe", arguments.data(), arguments.size()))
settings.arguments = arguments.data();
setting_note("Passed to Skate.exe as they are.");
break;
}
case 1:
section_caption(fonts, "WINDOW");
setting_check("Windowed", "Start in a window of this size instead of the game's saved display settings.",
settings.windowed);
ImGui::BeginDisabled(!settings.windowed);
ImGui::Indent(ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.x);
ImGui::SetNextItemWidth(S(110));
ImGui::InputInt("##width", &settings.width, 0);
ImGui::SameLine();
ImGui::TextDisabled("x");
ImGui::SameLine();
ImGui::SetNextItemWidth(S(110));
ImGui::InputInt("##height", &settings.height, 0);
ImGui::Unindent(ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.x);
ImGui::EndDisabled();
settings.width = std::clamp(settings.width, 320, 16384);
settings.height = std::clamp(settings.height, 200, 16384);
break;
case 2:
if (key_bindings(fonts, settings, ui)) launcher.save();
break;
default: {
bool running{};
if (!game_settings_ready(launcher, ui, running)) break;
const std::string_view page = names[static_cast<std::size_t>(std::clamp(ui.settings_tab, game_pages, static_cast<int>(names.size()) - 1))];
game_settings_page(fonts, ui, page, running);
if (page == "GRAPHICS") graphics_options(fonts, ui, running);
break;
}
case 3:
section_caption(fonts, "LAUNCHER");
setting_check("Keep the launcher open after launch",
"Hide while you play and return when Skate closes. Turn off to close after a successful launch.",
settings.keep_open_after_launch);
{
const bool updates_were = settings.updates, prereleases_were = settings.prereleases;
setting_check("Install ReSkate updates",
"Turn off to keep the ReSkate.dll and launcher you have, such as a test build someone gave you. "
"Skate's own Steam updates are not affected.",
settings.updates);
// Only while there is a pre-release to test (one newer than the latest release), or while
// it is on, so it can always be turned off.
if (settings.prereleases || launcher.prerelease_available())
setting_check("Test pre-releases",
"Update to the newest pre-release on GitHub, to try what is coming before it is released. "
"Pre-releases are less tested. Turn off to go back to the latest release.",
settings.prereleases);
// Either flipped: checked again now, so PLAY or the update button changes to match.
if (settings.updates != updates_were || settings.prereleases != prereleases_were) {
launcher.save();
launcher.check_again_if_update_choices_changed();
}
}
ImGui::Spacing();
section_caption(fonts, "TROUBLESHOOTING");
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted("Log level");
ImGui::SameLine(S(200));
ImGui::SetNextItemWidth(S(160));
ImGui::Combo("##log_level", &settings.log_level, log_levels.data(), static_cast<int>(log_levels.size()));
ImGui::SameLine();
if (ImGui::Button("Open logs")) open_path(paths.logs);
setting_note("How much ReSkate.log records. Use debug or trace when reporting a problem.");
ImGui::Spacing();
setting_check("GPU crash diagnostics (DRED)", "Records extra detail when the graphics driver crashes. Slightly slower.",
settings.gpu_diagnostics);
setting_check("Send crash reports",
"When the launcher or Skate crashes, upload a crash dump and that session's log so the ReSkate "
"developers can fix it. The launcher picks a change up when it next starts.",
settings.crash_reports);
setting_check("Send performance timings",
"Send how long downloads, mod installs, the mod merge, level loads and joining a "
"session took, and each level's average frame rate, so slow spots can be found and fixed. Nothing "
"about your account is included. Needs crash reports on; the launcher picks a change up when it "
"next starts.",
settings.performance_timings);
break;
}
ImGui::EndChild();
store_game_settings(ui);
ImGui::SetCursorPosY(ImGui::GetWindowHeight() - footer + S(8));
ImGui::PushFont(fonts.caption);
ImGui::TextDisabled("%s", !update::binary_updates_enabled() ? "Development build: ReSkate files are never replaced."
: settings.updates ? "Release build: updates install automatically."
: "Updates are off: ReSkate files are never replaced.");
ImGui::PopFont();
ImGui::EndChild();
if (!binding && !ImGui::IsAnyItemActive() && ImGui::IsKeyPressed(ImGuiKey_Escape, false)) open = false;
g_drag_allowed = !ImGui::IsAnyItemHovered() && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId);
ImGui::End();
if (!open) {
launcher.save();
launcher.check_again_if_update_choices_changed();
// Read again the next time: the game may have run in between.
ui.game.loaded = false;
}
}
} // namespace dingosdk::launcher_gui::detail