From 94ce8fddd7f6578151aec1d6c4ba904854a35711 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sat, 10 Oct 2026 02:30:50 -0500 Subject: [PATCH] Launcher: edit skate.'s own settings (graphics, audio, camera, controls, replay) Settings has five more pages, under "SKATE. SETTINGS" in the rail, for the settings the game saves per PC: - GRAPHICS: window mode, resolution, HDR, brightness, UI brightness, HUD display, then the game's graphics options (frame rate limit, VSync, dynamic resolution, upscaler and its quality, antialiasing, textures, lighting, shadows, mesh, effects, post processing, global illumination, ambient occlusion, depth of field, motion blur) with buttons that set every low-to-ultra option at once. - AUDIO: volumes, voice chat, text and voice language, subtitles, party menu narration. - CAMERA: sensitivity, and the on board, off board and climbing options. - CONTROLS: vibration, trigger force feedback, show controller on HUD, highlight indicator, the experimental toggles. - REPLAY: editor speeds and sensitivity, effects, first-person keyframes. They are written straight into the game's save containers under %LOCALAPPDATA%\ReSkate\Game\Skate\data (game_settings.h): a DATA file of sections and an INDEX naming them, the body one binary object of named values. Only the values changed are rewritten, in both of the game's copies; every other byte is kept. A container that cannot be walked whole is left alone. The first change copies the untouched files to %LOCALAPPDATA%\ReSkate\game-settings-backup. Keys, kinds, ranges, steps and choices are the game's own (DingoUISettingAsset in its data, build 20260929). The labels are ours. Resolution is saved by the game as a place in the PC's list of display sizes; the launcher lists Windows' sizes smallest first, which matches the one PC it was checked on. A change is stored once the control is let go. The pages are disabled while a game this launcher started is running, and say so when the game has not saved its settings on the PC yet. Not offered: refresh rate, fullscreen monitor and audio devices (places in lists the game builds at run time), the mobile-only settings, and settings the game saves to the account rather than the PC. --- Launcher/Test/game_settings_tests.cpp | 270 ++++++++++++++ Launcher/game_settings.cpp | 516 ++++++++++++++++++++++++++ Launcher/game_settings.h | 101 +++++ Launcher/gui_internal.h | 14 +- Launcher/gui_settings.cpp | 239 +++++++++++- cmake/Apps.cmake | 5 +- 6 files changed, 1140 insertions(+), 5 deletions(-) create mode 100644 Launcher/Test/game_settings_tests.cpp create mode 100644 Launcher/game_settings.cpp create mode 100644 Launcher/game_settings.h diff --git a/Launcher/Test/game_settings_tests.cpp b/Launcher/Test/game_settings_tests.cpp new file mode 100644 index 0000000..089c022 --- /dev/null +++ b/Launcher/Test/game_settings_tests.cpp @@ -0,0 +1,270 @@ +// The game's settings in its save container: reading them, and rewriting only the ones asked for. +// With a folder as its argument, also checks every container found there (nothing is written). +#include "Launcher/game_settings.h" + +#include +#include +#include +#include +#include +#include + +using namespace dingosdk; +namespace graphics = launcher_game_settings; + +namespace { + +int failures = 0; + +void check(bool condition, const char* what) { + if (condition) return; + std::cerr << "FAIL: " << what << '\n'; + ++failures; +} + +template std::string error_of(F&& run) { + try { run(); } catch (const std::exception& failure) { return failure.what(); } + return {}; +} + +using Bytes = std::vector; + +void put(Bytes& out, std::string_view text) { out.insert(out.end(), text.begin(), text.end()); } +void put_u64(Bytes& out, std::uint64_t value) { + for (int i = 0; i < 8; ++i) out.push_back(static_cast(value >> (8 * i))); +} +void put_varint(Bytes& out, std::uint64_t value) { + do { + const auto byte = static_cast(value & 0x7f); + value >>= 7; + out.push_back(value ? static_cast(byte | 0x80) : byte); + } while (value); +} +void entry(Bytes& out, unsigned char type, std::string_view name, const Bytes& value) { + out.push_back(type); + put(out, name); + out.push_back(0); + out.insert(out.end(), value.begin(), value.end()); +} +Bytes text_value(std::string_view text) { + Bytes out; + put_varint(out, text.size() + 1); + put(out, text); + out.push_back(0); + return out; +} + +// A container as the game writes one: displayname, header and body sections, the body last, +// each with a 16-byte head (payload length, a time), and stale bytes after the end. +graphics::Container container(std::string_view options) { + Bytes entries; + entry(entries, 8, "InputDevice", {0, 0, 0, 0}); + entry(entries, 6, "HDR", {1}); + entry(entries, 9, "LevelShaderVer_BAM", {1, 2, 3, 4, 5, 6, 7, 8}); + entry(entries, 7, "TestKey-String", text_value("Value")); + entry(entries, 0xc, "TestKey-Double", {0, 0, 0, 0, 0, 0, 0, 0}); + entry(entries, 7, "TierManagerUserSettings", text_value(options)); + entry(entries, 0xb, "MinimumKeyframeGap", {0x9a, 0x99, 0x19, 0x3e}); + entries.push_back(0); + Bytes object{0x82}; + put_varint(object, entries.size()); + object.insert(object.end(), entries.begin(), entries.end()); + + graphics::Container out; + const auto section = [&](std::string_view name, const Bytes& payload) { + const auto offset = out.data.size(); + put_u64(out.data, payload.size()); + put_u64(out.data, 0x18dd17435ae2e148ULL); + out.data.insert(out.data.end(), payload.begin(), payload.end()); + Bytes row(0x50, 0); + std::memcpy(row.data(), name.data(), name.size()); + Bytes numbers; + put_u64(numbers, 1); + put_u64(numbers, out.data.size() - offset); + put_u64(numbers, offset); + put_u64(numbers, out.data.size()); + std::memcpy(row.data() + 32, numbers.data(), numbers.size()); + return row; + }; + Bytes name; + put(name, "Save Data Backup"); + name.push_back(0); + const auto display = section("displayname", name); + const auto header = section("header", {0x82, 0x01, 0x00}); + const auto body = section("body", object); + // (The game lists the body first.) + for (const auto* row : {&body, &display, &header}) out.index.insert(out.index.end(), row->begin(), row->end()); + put(out.data, std::string_view("\x00\x3e\x00\x00", 4)); + return out; +} + +graphics::Options read_options(const graphics::Container& from) { + return graphics::parse_options(std::get(graphics::read_values(from).at(graphics::options_key))); +} +graphics::Container with_options(const graphics::Container& from, const graphics::Options& options) { + return graphics::with_values(from, {{graphics::options_key, graphics::join_options(options)}}); +} + +Bytes read_file(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + return Bytes{std::istreambuf_iterator(input), std::istreambuf_iterator()}; +} + +} // namespace + +int main(int argc, char** argv) { + const std::string text = "Post Process;post_process_ultra|Global Graphics Quality;Custom|Texture Quality;texture_quality_ultra|VSync;v_sync_off"; + { + const auto options = graphics::parse_options(text); + check(options.size() == 4 && options[2] == graphics::Option{"Texture Quality", "texture_quality_ultra"}, "options parsed"); + check(graphics::join_options(options) == text, "options joined as they were"); + check(!error_of([] { graphics::parse_options("no separator"); }).empty(), "malformed options refused"); + check(!error_of([] { graphics::join_options({{"A", "b|c"}}); }).empty(), "a value with a separator refused"); + } + { + const auto before = container(text); + auto options = read_options(before); + check(graphics::join_options(options) == text, "options read from a container"); + + // Longer and shorter than before, and long enough that the sizes take another byte. + for (const std::string value : {std::string("texture_quality_low"), std::string("x"), std::string(300, 'y')}) { + options[2].value = value; + const auto after = with_options(before, options); + check(read_options(after) == options, "rewritten options read back"); + // The rest of the object is untouched: writing the old options again gives the old bytes. + const auto again = with_options(after, graphics::parse_options(text)); + check(again.index == before.index, "index restored by writing the old options"); + check(again.data.size() == before.data.size() - 4 && + std::equal(again.data.begin(), again.data.end(), before.data.begin()), "data restored, without the stale tail"); + } + // Sections before the body are byte for byte the same. + options[2].value = "texture_quality_medium"; + const auto after = with_options(before, options); + check(std::equal(after.data.begin(), after.data.begin() + 0x34, before.data.begin()), "sections before the body kept"); + check(std::equal(after.index.begin() + 0x50, after.index.end(), before.index.begin() + 0x50), "other index rows kept"); + } + { + // The other kinds of value, each changed on its own and together. + const auto before = container(text); + const auto values = graphics::read_values(before); + check(values.at("HDR") == graphics::Value(true) && values.at("InputDevice") == graphics::Value(std::int32_t{0}) && + values.at("TestKey-String") == graphics::Value(std::string("Value")) && + std::get(values.at("MinimumKeyframeGap")) > 0.149f, "values of every kind read"); + check(!values.contains("LevelShaderVer_BAM") && !values.contains("TestKey-Double"), "kinds not edited are not offered"); + const graphics::Values changes{{"HDR", false}, {"InputDevice", std::int32_t{-7}}, {"MinimumKeyframeGap", 0.25f}, + {"TestKey-String", std::string("A longer value than before")}}; + const auto after = graphics::with_values(before, changes); + const auto stored = graphics::read_values(after); + bool all = true; + for (const auto& [key, value] : changes) all = all && stored.at(key) == value; + check(all, "changed values read back"); + check(read_options(after) == graphics::parse_options(text), "values not changed are kept"); + graphics::Values back; + for (const auto& [key, value] : changes) back[key] = values.at(key); + const auto again = graphics::with_values(after, back); + check(again.index == before.index && std::equal(again.data.begin(), again.data.end(), before.data.begin()), + "changing them back gives the old bytes"); + check(!error_of([&] { graphics::with_values(before, {{"HDR", std::int32_t{1}}}); }).empty(), "a value of the wrong kind is refused"); + check(!error_of([&] { graphics::with_values(before, {{"NotASetting", true}}); }).empty(), "a setting the game has not saved is refused"); + check(graphics::with_values(before, {}).index == before.index, "no changes is the same container"); + } + { + auto broken = container(text); + broken.index[32 + 8] ^= 1; + check(!error_of([&] { read_options(broken); }).empty(), "an index that does not match is refused"); + auto none = container(text); + const std::string key = "TierManagerUserSettings"; + const auto at = std::search(none.data.begin(), none.data.end(), key.begin(), key.end()); + *at = 'X'; + check(!error_of([&] { read_options(none); }).empty(), "a container without the options is refused"); + graphics::Container empty; + check(!error_of([&] { read_options(empty); }).empty(), "an empty container is refused"); + } + { + // Every option offered has a value list, and quality levels resolve. + bool ok = true, level{}; + for (const auto& setting : graphics::graphics_options()) { + ok = ok && !setting.choices.empty() && *setting.name && *setting.label; + if (std::string_view(setting.name) == "Texture Quality") + level = std::string_view(graphics::level_value(setting, 0)) == "texture_quality_low" && + std::string_view(graphics::level_value(setting, 3)) == "texture_quality_ultra"; + if (std::string_view(setting.name) == "VSync") ok = ok && !graphics::level_value(setting, 1); + } + check(ok && level, "offered graphics options are well formed"); + bool items = true; + for (const auto& item : graphics::items()) { + items = items && *item.page && *item.section && *item.key && *item.label; + if (item.kind == graphics::Kind::whole || item.kind == graphics::Kind::real) items = items && item.most > item.least && item.step > 0; + if (item.kind == graphics::Kind::choice) items = items && !item.choices.empty(); + } + check(items && graphics::items().size() > 60, "offered settings are well formed"); + } + { + // On disk: two containers of the options and one save that is something else. + namespace fs = std::filesystem; + const auto root = fs::temp_directory_path() / "reskate-graphics-settings-test"; + fs::remove_all(root); + const auto write = [](const fs::path& file, const Bytes& bytes) { + fs::create_directories(file.parent_path()); + std::ofstream(file, std::ios::binary).write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + }; + const auto first = container(text); + for (const char* name : {"8e3010f", "ff1f3ef"}) { + write(root / "data" / "123" / "title" / name / "DATA", first.data); + write(root / "data" / "123" / "title" / name / "INDEX", first.index); + } + write(root / "data" / "123" / "title" / "other" / "DATA", {1, 2, 3}); + write(root / "data" / "123" / "title" / "other" / "INDEX", {4, 5, 6}); + auto saved = graphics::load(root / "data"); + check(saved.folders.size() == 2 && std::get(saved.values.at(graphics::options_key)) == text, "containers found on disk"); + auto options = graphics::parse_options(text); + options[3].value = "v_sync_on"; + graphics::save(saved, {{graphics::options_key, graphics::join_options(options)}, {"HDR", false}}, root / "backup"); + const auto again = graphics::load(root / "data"); + check(again.folders.size() == 2 && again.values.at("HDR") == graphics::Value(false) && + graphics::parse_options(std::get(again.values.at(graphics::options_key))) == options, "values stored in every container"); + for (const char* name : {"8e3010f", "ff1f3ef"}) + check(read_options({read_file(root / "data" / "123" / "title" / name / "DATA"), + read_file(root / "data" / "123" / "title" / name / "INDEX")}) == options, "each container holds them"); + check(read_file(root / "data" / "123" / "title" / "other" / "DATA") == Bytes{1, 2, 3}, "another save left alone"); + check(read_file(root / "backup" / "123" / "8e3010f" / "DATA") == first.data && + read_file(root / "backup" / "123" / "ff1f3ef" / "INDEX") == first.index, "untouched files backed up"); + // The backup is of the files as they first were, not of each later change. + graphics::save(again, {{"HDR", true}}, root / "backup"); + check(read_file(root / "backup" / "123" / "8e3010f" / "DATA") == first.data, "backup kept from the first change"); + check(graphics::load(root / "missing").folders.empty(), "no saves is not an error"); + fs::remove_all(root); + } + // Real containers: read, rewrite with the same options, expect the same bytes. + for (int i = 1; i < argc; ++i) { + const std::filesystem::path folder(argv[i]); + const graphics::Container real{read_file(folder / "DATA"), read_file(folder / "INDEX")}; + try { + const auto options = read_options(real); + const auto values = graphics::read_values(real); + // Every value written back as it is, at once. + const auto same = graphics::with_values(real, values); + std::size_t offered{}; + for (const auto& item : graphics::items()) offered += values.contains(item.key); + std::cout << " " << values.size() << " values, " << offered << " of " << graphics::items().size() << " offered settings saved here\n"; + for (const auto& item : graphics::items()) { + const auto found = values.find(item.key); + if (found == values.end()) { std::cout << " not saved: " << item.key << '\n'; continue; } + const bool kind = item.kind == graphics::Kind::toggle ? std::holds_alternative(found->second) + : item.kind == graphics::Kind::real ? std::holds_alternative(found->second) + : std::holds_alternative(found->second); + check(kind, item.key); + } + const bool equal = same.index == real.index && same.data.size() <= real.data.size() && + std::equal(same.data.begin(), same.data.end(), real.data.begin()); + check(equal, "a real container is rebuilt byte for byte"); + std::cout << folder.string() << ": " << options.size() << " options, " + << (equal ? "rebuilt identically" : "REBUILT DIFFERENTLY") << '\n'; + } catch (const std::exception& failure) { + check(false, failure.what()); + } + } + if (failures) return 1; + std::cout << "graphics settings tests passed\n"; + return 0; +} diff --git a/Launcher/game_settings.cpp b/Launcher/game_settings.cpp new file mode 100644 index 0000000..8be7ae4 --- /dev/null +++ b/Launcher/game_settings.cpp @@ -0,0 +1,516 @@ +#include "game_settings.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace dingosdk::launcher_game_settings { +namespace { + +namespace fs = std::filesystem; +using Bytes = std::vector; + +constexpr std::size_t index_entry = 0x50, index_name = 32; +constexpr std::size_t max_container = 16 * 1024 * 1024; + +[[noreturn]] void fail(const char* what) { throw std::runtime_error(what); } + +std::uint64_t u64_at(std::span bytes, std::size_t at) { + if (at > bytes.size() || bytes.size() - at < 8) fail("The game's settings file is cut short"); + std::uint64_t value{}; + std::memcpy(&value, bytes.data() + at, 8); + return value; +} +void put_u64(Bytes& bytes, std::size_t at, std::uint64_t value) { std::memcpy(bytes.data() + at, &value, 8); } + +// A size as the object format writes it: seven bits a byte, the low ones first. +std::uint64_t varint(std::span bytes, std::size_t& at) { + std::uint64_t value{}; + for (int shift = 0; shift < 63; shift += 7) { + if (at >= bytes.size()) fail("The game's settings file is cut short"); + const auto byte = bytes[at++]; + value |= static_cast(byte & 0x7f) << shift; + if (!(byte & 0x80)) return value; + } + fail("The game's settings file has a size that is too long"); +} +void put_varint(Bytes& out, std::uint64_t value) { + do { + const auto byte = static_cast(value & 0x7f); + value >>= 7; + out.push_back(value ? static_cast(byte | 0x80) : byte); + } while (value); +} + +// The body section of a container: where it starts in DATA, and where INDEX describes it. +struct Body { + std::size_t entry{}; // offset of its INDEX entry + std::size_t offset{}; // of the section in DATA + std::size_t size{}; // the section: a 16-byte head, then the object +}; +Body find_body(const Container& container) { + const auto& index = container.index; + if (index.empty() || index.size() % index_entry) fail("The game's settings index is not in the expected format"); + Body body; + bool found{}; + std::uint64_t last_end{}; + for (std::size_t at = 0; at < index.size(); at += index_entry) { + const auto size = u64_at(index, at + index_name + 8), offset = u64_at(index, at + index_name + 16), + end = u64_at(index, at + index_name + 24); + if (offset > container.data.size() || size > container.data.size() - offset || end != offset + size) + fail("The game's settings index does not match its data"); + last_end = std::max(last_end, end); + if (std::strncmp(reinterpret_cast(index.data() + at), "body", index_name) != 0) continue; + body = {at, static_cast(offset), static_cast(size)}; + found = true; + } + // The body is rewritten at another length, so nothing may follow it. + if (!found || body.size < 18 || body.offset + body.size != last_end) + fail("The game's settings file is not laid out as expected"); + if (u64_at(container.data, body.offset) != body.size - 16) fail("The game's settings body has the wrong length"); + return body; +} + +// One entry of the body's object: its type byte, name and the bytes of its value. +struct Entry { + unsigned type{}; + std::string_view name; + std::size_t start{}, value{}, end{}; +}; +// Every entry, in order, and where the object's entries begin. Throws on anything unknown: +// a container that cannot be walked whole is not rewritten. +struct Walked { + std::vector entries; + std::size_t begin{}; +}; +Walked walk(const Container& container, const Body& body) { + const std::span data(container.data.data(), body.offset + body.size); + std::size_t at = body.offset + 16; + // An unnamed object: its type, the size of what follows, entries, a zero. + if ((data[at++] & 0x1f) != 2) fail("The game's settings body is not an object"); + const auto object_size = varint(data, at); + if (object_size == 0 || object_size != data.size() - at) fail("The game's settings object has the wrong length"); + Walked walked; + walked.begin = at; + const auto skip = [&](std::uint64_t count) { + if (count > data.size() - at) fail("The game's settings file is cut short"); + at += static_cast(count); + }; + while (at < data.size() && data[at] != 0) { + Entry entry; + entry.start = at; + entry.type = data[at++] & 0x1f; + const auto name_end = std::find(data.begin() + static_cast(at), data.end(), static_cast(0)); + if (name_end == data.end()) fail("The game's settings file is cut short"); + entry.name = std::string_view(reinterpret_cast(data.data() + at), static_cast(name_end - data.begin()) - at); + at += entry.name.size() + 1; + entry.value = at; + switch (entry.type) { + case 1: case 2: case 0x13: skip(varint(data, at)); break; // list, object, blob + case 6: skip(1); break; // bool + case 7: { // text: length with its zero, text, zero + const auto length = varint(data, at); + if (length == 0) fail("The game's settings file has text without its end"); + skip(length); + break; + } + case 8: case 0xb: skip(4); break; // int32, float + case 9: case 0xc: skip(8); break; // int64, double + case 0xf: skip(16); break; // guid + case 0x10: skip(20); break; // sha1 + default: fail("The game's settings hold a kind of value this launcher does not know"); + } + entry.end = at; + walked.entries.push_back(entry); + } + if (at + 1 != data.size()) fail("The game's settings object does not end where it should"); + return walked; +} + +Bytes read_file(const fs::path& path) { + std::error_code error; + const auto size = fs::file_size(path, error); + if (error || size > max_container) fail("Cannot read the game's settings file"); + Bytes bytes(static_cast(size)); + std::ifstream input(path, std::ios::binary); + if (!input.read(reinterpret_cast(bytes.data()), static_cast(bytes.size())) && !bytes.empty()) + fail("Cannot read the game's settings file"); + return bytes; +} + +// Written beside the file and moved over it, so a failed write leaves the old file. +void write_file(const fs::path& path, const Bytes& bytes) { + auto temporary = path; + temporary += L".reskate-new"; + { + std::ofstream output(temporary, std::ios::binary | std::ios::trunc); + output.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + output.flush(); + if (!output) { + output.close(); + DeleteFileW(temporary.c_str()); + fail("Cannot write the game's settings file"); + } + } + if (!MoveFileExW(temporary.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { + DeleteFileW(temporary.c_str()); + fail("Cannot replace the game's settings file; close skate. first"); + } +} + +constexpr Choice quality[]{{"low", "Low"}, {"medium", "Medium"}, {"high", "High"}, {"ultra", "Ultra"}}; +#define DINGO_QUALITY(prefix) \ + {{prefix "_low", "Low"}, {prefix "_medium", "Medium"}, {prefix "_high", "High"}, {prefix "_ultra", "Ultra"}} +constexpr Choice frame_rate[]{{"target_30_present_tltr", "30"}, {"target_60_present_tltr", "60"}, + {"target_120_present", "120"}, {"target_uncapped_present_tltr", "Unlimited"}}; +constexpr Choice vsync[]{{"v_sync_off", "Off"}, {"v_sync_on", "On"}, {"v_sync_1_2", "Every 2nd refresh"}, + {"v_sync_1_3", "Every 3rd refresh"}, {"v_sync_1_4", "Every 4th refresh"}}; +constexpr Choice scaling[]{{"resolution_scaling_off", "Off"}, {"resolution_scaling_on", "On"}}; +constexpr Choice scale[]{{"resolution_scale_0_6_to_1", "60%"}, {"resolution_scale_0_7_to_1", "70%"}, + {"resolution_scale_0_8_to_1", "80%"}, {"resolution_scale_0_9_to_1", "90%"}, {"resolution_scale_1", "100%"}}; +constexpr Choice upscaler[]{{"frame_synthesis_mode_none", "Off"}, {"frame_synthesis_mode_dlss", "NVIDIA DLSS"}, + {"frame_synthesis_mode_fsr", "AMD FSR"}, {"frame_synthesis_mode_xe_ss", "Intel XeSS"}}; +constexpr Choice dlss[]{{"frame_synthesis_quality_dlss_ultra_performance", "Ultra Performance"}, + {"frame_synthesis_quality_dlss_performance", "Performance"}, {"frame_synthesis_quality_dlss_balanced", "Balanced"}, + {"frame_synthesis_quality_dlss_quality", "Quality"}, {"frame_synthesis_quality_dlss_dlaa", "DLAA (native)"}}; +constexpr Choice fsr[]{{"frame_synthesis_quality_fsr3_performance", "Performance"}, + {"frame_synthesis_quality_fsr3_balanced", "Balanced"}, {"frame_synthesis_quality_fsr3_quality", "Quality"}, + {"frame_synthesis_quality_fsr3_ultra_quality", "Ultra Quality"}, {"frame_synthesis_quality_fsr3_native", "Native"}}; +constexpr Choice xess[]{{"frame_synthesis_quality_xe_ss_ultra_performance", "Ultra Performance"}, + {"frame_synthesis_quality_xe_ss_performance", "Performance"}, {"frame_synthesis_quality_xe_ss_balanced", "Balanced"}, + {"frame_synthesis_quality_xe_ss_quality", "Quality"}, {"frame_synthesis_quality_xe_ss_ultra_quality", "Ultra Quality"}, + {"frame_synthesis_quality_xe_ss_ultra_quality_plus", "Ultra Quality Plus"}, + {"frame_synthesis_quality_xe_ss_native", "Native"}}; +constexpr Choice antialiasing[]{{"antialiasing_off", "Off"}, {"antialiasing_fxaa_medium", "FXAA"}, + {"antialiasing_temporal", "TAA"}}; +constexpr Choice texture_filtering[] DINGO_QUALITY("texture_filtering"); +constexpr Choice texture_quality[] DINGO_QUALITY("texture_quality"); +constexpr Choice gibs[]{{"gibs_off", "Off"}, {"gibs_on", "On"}}; +constexpr Choice gi_grid[] DINGO_QUALITY("gi_grid"); +constexpr Choice lighting[] DINGO_QUALITY("lighting"); +constexpr Choice shadows[] DINGO_QUALITY("shadows"); +constexpr Choice mesh[] DINGO_QUALITY("mesh_quality"); +constexpr Choice effects[] DINGO_QUALITY("effects_quality"); +constexpr Choice post_process[] DINGO_QUALITY("post_process"); +constexpr Choice occlusion[]{{"ambient_occlusion_off", "Off"}, {"ambient_occlusion_ssao", "SSAO"}, + {"ambient_occlusion_gtao_half", "GTAO, half resolution"}, {"ambient_occlusion_gtao_full", "GTAO, full resolution"}}; +constexpr Choice depth_of_field[]{{"depth_of_field_off", "Off"}, {"depth_of_field_low", "Low"}, + {"depth_of_field_medium", "Medium"}, {"depth_of_field_high", "High"}}; +constexpr Choice motion_blur[]{{"motion_blur_off", "Off"}, {"motion_blur_low", "Low"}, {"motion_blur_medium", "Medium"}, + {"motion_blur_high", "High"}, {"motion_blur_ultra", "Ultra"}}; +#undef DINGO_QUALITY + +// The game's own list (its tier manager's user settings), in its menu's order. +constexpr Setting all_options[]{ + {"FRAME RATE", "Target Framerate", "Frame rate limit", frame_rate, "", ""}, + {"FRAME RATE", "VSync", "VSync", vsync, "", ""}, + {"RESOLUTION SCALING", "Resolution Scale Enable", "Dynamic resolution", scaling, "", ""}, + {"RESOLUTION SCALING", "Resolution", "Lowest resolution", scale, "Resolution Scale Enable", "resolution_scaling_on"}, + {"UPSCALING", "Frame Synthesis Mode", "Upscaler", upscaler, "", ""}, + {"UPSCALING", "Frame Synthesis Quality DLSS", "DLSS quality", dlss, "Frame Synthesis Mode", "frame_synthesis_mode_dlss"}, + {"UPSCALING", "Frame Synthesis Quality FSR", "FSR quality", fsr, "Frame Synthesis Mode", "frame_synthesis_mode_fsr"}, + {"UPSCALING", "Frame Synthesis Quality XeSS", "XeSS quality", xess, "Frame Synthesis Mode", "frame_synthesis_mode_xe_ss"}, + {"UPSCALING", "Antialiasing", "Antialiasing", antialiasing, "Frame Synthesis Mode", "frame_synthesis_mode_none"}, + {"GRAPHICS", "Texture Filtering", "Texture filtering", texture_filtering, "", ""}, + {"GRAPHICS", "Texture Quality", "Texture quality", texture_quality, "", ""}, + {"GRAPHICS", "GIBS", "Ray-traced global illumination", gibs, "", ""}, + {"GRAPHICS", "GI Grid", "Global illumination grid", gi_grid, "GIBS", "gibs_off"}, + {"GRAPHICS", "Lighting", "Lighting", lighting, "", ""}, + {"GRAPHICS", "Shadows", "Shadows", shadows, "", ""}, + {"GRAPHICS", "Mesh Quality", "Mesh quality", mesh, "", ""}, + {"GRAPHICS", "Effects Quality", "Effects quality", effects, "", ""}, + {"GRAPHICS", "Post Process", "Post processing", post_process, "", ""}, + {"GRAPHICS", "Ambient Occlusion", "Ambient occlusion", occlusion, "", ""}, + {"GRAPHICS", "Depth of Field", "Depth of field", depth_of_field, "", ""}, + {"GRAPHICS", "Motion Blur", "Motion blur", motion_blur, "", ""}, +}; + +// The rest of the game's settings menus (ui/.../DingoUISettingAsset in its data, build +// 20260929): keys, ranges, steps and choices are the game's; the labels are ours. +constexpr Named window_mode[]{{0, "Windowed"}, {1, "Fullscreen"}, {2, "Borderless fullscreen"}}; +constexpr Named hud_display[]{{0, "1"}, {1, "2"}}; +constexpr Named text_language[]{{0, "English"}, {1, "French"}, {2, "German"}, {3, "Spanish"}, {4, "Italian"}, + {5, "Japanese"}, {6, "Traditional Chinese"}, {7, "Simplified Chinese"}, {8, "Korean"}, {9, "Brazilian Portuguese"}}; +constexpr Named voice_language[]{{0, "English"}, {1, "French"}, {2, "German"}}; +constexpr Named subtitles[]{{0, "Off"}, {1, "Narrative only"}, {2, "On"}}; +constexpr Named dead_zone[]{{0, "Default"}, {1, "Wider"}, {2, "Narrow"}, {3, "Disabled"}}; +constexpr Named keyframe_subject[]{{0, "Body"}, {1, "Head"}, {2, "Board"}}; +constexpr Item toggle(const char* page, const char* section, const char* key, const char* label) { + return {page, section, key, label, Kind::toggle, 0, 0, 0, {}}; +} +constexpr Item whole(const char* page, const char* section, const char* key, const char* label, float least, float most, float step) { + return {page, section, key, label, Kind::whole, least, most, step, {}}; +} +constexpr Item real(const char* page, const char* section, const char* key, const char* label, float least, float most, float step) { + return {page, section, key, label, Kind::real, least, most, step, {}}; +} +constexpr Item choice(const char* page, const char* section, const char* key, const char* label, std::span choices) { + return {page, section, key, label, Kind::choice, 0, 0, 0, choices}; +} +constexpr Item all_items[]{ + choice("GRAPHICS", "DISPLAY", "WindowedMode", "Window mode", window_mode), + {"GRAPHICS", "DISPLAY", "Resolution", "Resolution", Kind::resolution, 0, 0, 0, {}}, + toggle("GRAPHICS", "DISPLAY", "HDR", "HDR"), + whole("GRAPHICS", "DISPLAY", "Brightness", "Brightness", 0, 100, 5), + real("GRAPHICS", "DISPLAY", "UIBrightness", "UI brightness", -2, 2, 1), + choice("GRAPHICS", "DISPLAY", "HUDDisplay", "HUD display", hud_display), + + whole("AUDIO", "VOLUME", "AudioMainVolume_V2", "Main volume", 0, 100, 10), + whole("AUDIO", "VOLUME", "StoredGlobalAudioMusic_V2", "Music volume", 0, 100, 10), + whole("AUDIO", "VOLUME", "StoredGlobalAudioSFX_V2", "Sound effects volume", 0, 100, 10), + whole("AUDIO", "VOLUME", "StoredGlobalAudioVO_V2", "Voice-over volume", 0, 100, 10), + toggle("AUDIO", "VOICE CHAT", "PartyVoiceChatSetting", "Party voice chat"), + toggle("AUDIO", "VOICE CHAT", "ProximityVoiceChatSetting", "Proximity voice chat"), + toggle("AUDIO", "VOICE CHAT", "MyMicSetting", "Microphone on"), + whole("AUDIO", "VOICE CHAT", "VoiceChatVolumeSetting", "Voice chat volume", 0, 100, 5), + toggle("AUDIO", "VOICE CHAT", "SpeechToTextSetting", "Speech to text"), + toggle("AUDIO", "VOICE CHAT", "TextToSpeechSetting", "Text to speech"), + choice("AUDIO", "LANGUAGE AND ACCESSIBILITY", "egf_language", "Text language", text_language), + choice("AUDIO", "LANGUAGE AND ACCESSIBILITY", "LanguageVoice", "Voice language", voice_language), + choice("AUDIO", "LANGUAGE AND ACCESSIBILITY", "NPCSubtitles2", "Subtitles", subtitles), + toggle("AUDIO", "LANGUAGE AND ACCESSIBILITY", "Accessibility_Party_Menu_Narration", "Party menu narration"), + + whole("CAMERA", "GENERAL", "dingocamerasensitivity_x", "Sensitivity, horizontal", 0, 100, 1), + whole("CAMERA", "GENERAL", "dingocamerasensitivity_y", "Sensitivity, vertical", 0, 100, 1), + toggle("CAMERA", "GENERAL", "efg_camera_resetoninput", "Camera reset"), + toggle("CAMERA", "GENERAL", "egf_cameraenableheightinput", "On-the-fly camera"), + whole("CAMERA", "ON BOARD", "egf_cameraonboardfovsetting", "Field of view", 0, 14, 1), + toggle("CAMERA", "ON BOARD", "egf_OnboardFOVDistanceScale", "Field of view scaling"), + whole("CAMERA", "ON BOARD", "egf_cameraonboardshakeintensity", "Shake intensity", 0, 10, 1), + whole("CAMERA", "ON BOARD", "egf_onboardcamerafollowstrength_x", "Follow strength, horizontal", 0, 10, 1), + whole("CAMERA", "ON BOARD", "egf_onboardcamerafollowstrength_y", "Follow strength, vertical", 0, 10, 1), + whole("CAMERA", "ON BOARD", "CameraSideBiasFactor", "Side bias", 0, 10, 1), + toggle("CAMERA", "ON BOARD", "cameradropdownpitch", "Drop-down pitch"), + toggle("CAMERA", "ON BOARD", "egf_cameraallowautomirror", "Mirror switch, automatic"), + toggle("CAMERA", "ON BOARD", "egf_cameraenablemirrorinput", "Mirror switch, by input"), + whole("CAMERA", "OFF BOARD", "egf_cameraoffboardfovsetting", "Field of view", 0, 14, 1), + toggle("CAMERA", "OFF BOARD", "egf_OffboardFOVDistanceScale", "Field of view scaling"), + whole("CAMERA", "OFF BOARD", "egf_cameraoffboardshakeintensity", "Shake intensity", 0, 10, 1), + whole("CAMERA", "OFF BOARD", "egf_offboardcamerafollowstrength_x", "Follow strength, horizontal", 0, 10, 1), + whole("CAMERA", "OFF BOARD", "egf_offboardcamerafollowstrength_y", "Follow strength, vertical", 0, 10, 1), + choice("CAMERA", "OFF BOARD", "egf_cameraoffboardfollowdeadzone", "Follow dead zone", dead_zone), + whole("CAMERA", "OFF BOARD", "egf_cameraoffboardautofollowcooldown", "Auto-follow cooldown", 0, 10, 1), + whole("CAMERA", "OFF BOARD", "efg_cameradefaultpitchsetting", "Default pitch", 0, 12, 1), + whole("CAMERA", "CLIMBING", "egf_climbingcamhorizontalfollowstrength", "Follow strength, horizontal", 0, 10, 1), + whole("CAMERA", "CLIMBING", "egf_climbingcamverticalfollowstrength", "Follow strength, vertical", 0, 10, 1), + whole("CAMERA", "CLIMBING", "egf_climbingcamfollowcooldown", "Auto-follow cooldown", 0, 10, 1), + + toggle("CONTROLS", "CONTROLLER", "haptics", "Controller vibration"), + toggle("CONTROLS", "CONTROLLER", "triggerforcefeedback", "Trigger force feedback"), + toggle("CONTROLS", "CONTROLLER", "ShowControllerOnHUD", "Show controller on HUD"), + toggle("CONTROLS", "GAMEPLAY", "CRAS_HasEnableVolumeHighlightIndicator", "Highlight indicator"), + toggle("CONTROLS", "EXPERIMENTAL", "exp_experimentaldarkcatchandslide", "Dark catch and slide"), + toggle("CONTROLS", "EXPERIMENTAL", "ExperimentalMegaparkCamera", "Megapark camera"), + + whole("REPLAY", "EDITOR", "replay_sensitivity_horizontal", "Sensitivity, horizontal", 0, 100, 1), + whole("REPLAY", "EDITOR", "replay_sensitivity_vertical", "Sensitivity, vertical", 0, 100, 1), + toggle("REPLAY", "EDITOR", "replay_invert_horizontal", "Invert horizontal"), + toggle("REPLAY", "EDITOR", "replay_invert_vertical", "Invert vertical"), + real("REPLAY", "EDITOR", "replay_Move_Speed", "Camera speed", 1, 20, 1), + real("REPLAY", "EDITOR", "replay_zoom_speed", "Zoom speed", 0.1f, 4, 0.1f), + real("REPLAY", "EDITOR", "PlaybackKeyframeSetting", "Playback speed", 0.1f, 2, 0.05f), + real("REPLAY", "EDITOR", "MinimumKeyframeGap", "Keyframe snap range", 0, 0.25f, 0.01f), + toggle("REPLAY", "EFFECTS", "ReplayEffect_Enabled", "Effects"), + real("REPLAY", "EFFECTS", "ReplayEffect_Exposure_S3", "Exposure", 0, 100, 2), + real("REPLAY", "EFFECTS", "ReplayEffect_Saturation_S3", "Saturation", 0, 100, 2), + real("REPLAY", "EFFECTS", "ReplayEffect_ColorTemp_S3", "Colour temperature", 0, 100, 2), + real("REPLAY", "EFFECTS", "ReplayEffect_BarrelDistortion_S3", "Barrel distortion", 0, 100, 5), + real("REPLAY", "EFFECTS", "ReplayEffect_VignetteSize", "Vignette size", 0, 100, 2), + real("REPLAY", "EFFECTS", "ReplayEffect_VignetteSharpness", "Vignette sharpness", 0, 100, 2), + real("REPLAY", "EFFECTS", "ReplayEffect_VignetteOpacity", "Vignette opacity", 0, 100, 5), + choice("REPLAY", "FIRST-PERSON KEYFRAMES", "SubjectKeyframeSetting", "Subject", keyframe_subject), + real("REPLAY", "FIRST-PERSON KEYFRAMES", "FOVKeyframeSetting", "Field of view", 5, 140, 5), + real("REPLAY", "FIRST-PERSON KEYFRAMES", "RollKeyframeSetting", "Roll", -180, 180, 5), + real("REPLAY", "FIRST-PERSON KEYFRAMES", "TiltKeyframeSetting", "Tilt", -180, 180, 5), + real("REPLAY", "FIRST-PERSON KEYFRAMES", "XOffsetKeyframe", "Offset, sideways", -10, 10, 0.1f), + real("REPLAY", "FIRST-PERSON KEYFRAMES", "YOffsetKeyframe", "Offset, up", -10, 10, 0.1f), +}; + +} // namespace + +Options parse_options(std::string_view text) { + Options options; + for (std::size_t at = 0; at <= text.size();) { + const auto end = std::min(text.find('|', at), text.size()); + const auto part = text.substr(at, end - at); + at = end + 1; + if (part.empty()) continue; + const auto split = part.find(';'); + if (split == std::string_view::npos || split == 0) fail("The game's graphics options are not in the expected form"); + options.push_back({std::string(part.substr(0, split)), std::string(part.substr(split + 1))}); + } + return options; +} + +std::string join_options(const Options& options) { + std::string text; + for (const auto& option : options) { + if (option.name.empty() || (option.name + option.value).find_first_of(";|") != std::string::npos || + std::any_of(option.value.begin(), option.value.end(), [](unsigned char c) { return c < 0x20; }) || + std::any_of(option.name.begin(), option.name.end(), [](unsigned char c) { return c < 0x20; })) + fail("A graphics option has a name or value the game's settings cannot hold"); + if (!text.empty()) text.push_back('|'); + text += option.name + ';' + option.value; + } + return text; +} + +Values read_values(const Container& container) { + const auto body = find_body(container); + const auto& data = container.data; + Values values; + for (const auto& entry : walk(container, body).entries) { + const auto* at = data.data() + entry.value; + Value value; + switch (entry.type) { + case 6: value = *at != 0; break; + case 8: { std::int32_t number{}; std::memcpy(&number, at, 4); value = number; break; } + case 0xb: { float number{}; std::memcpy(&number, at, 4); value = number; break; } + case 7: { + std::size_t text = entry.value; + const auto length = varint(data, text); + value = std::string(reinterpret_cast(data.data() + text), static_cast(length) - 1); + break; + } + default: continue; + } + if (!values.emplace(std::string(entry.name), std::move(value)).second) fail("The game's settings hold a setting twice"); + } + return values; +} + +Container with_values(const Container& container, const Values& changes) { + const auto body = find_body(container); + const auto walked = walk(container, body); + const auto& data = container.data; + constexpr unsigned types[]{6, 8, 0xb, 7}; // in the order of Value's kinds + Bytes entries; + std::size_t applied{}; + for (const auto& entry : walked.entries) { + const auto change = changes.find(entry.name); + if (change == changes.end()) { + entries.insert(entries.end(), data.begin() + static_cast(entry.start), data.begin() + static_cast(entry.end)); + continue; + } + if (types[change->second.index()] != entry.type) fail("A setting is not of the kind the game saves it as"); + ++applied; + entries.insert(entries.end(), data.begin() + static_cast(entry.start), data.begin() + static_cast(entry.value)); + if (const auto* flag = std::get_if(&change->second)) entries.push_back(*flag ? 1 : 0); + else if (const auto* text = std::get_if(&change->second)) { + if (text->find('\0') != std::string::npos) fail("A setting's text cannot hold a zero byte"); + put_varint(entries, text->size() + 1); + entries.insert(entries.end(), text->begin(), text->end()); + entries.push_back(0); + } else { + unsigned char raw[4]; + if (const auto* number = std::get_if(&change->second)) std::memcpy(raw, number, 4); + else std::memcpy(raw, &std::get(change->second), 4); + entries.insert(entries.end(), raw, raw + 4); + } + } + if (applied != changes.size()) fail("A setting to change is not one the game has saved"); + entries.push_back(0); + Bytes object{data[body.offset + 16]}; + put_varint(object, entries.size()); + object.insert(object.end(), entries.begin(), entries.end()); + + Container out; + // Up to the body, its 16-byte head (length, then a time the game wrote), the new object. + out.data.assign(data.begin(), data.begin() + static_cast(body.offset) + 16); + put_u64(out.data, body.offset, object.size()); + out.data.insert(out.data.end(), object.begin(), object.end()); + out.index = container.index; + put_u64(out.index, body.entry + index_name + 8, object.size() + 16); + put_u64(out.index, body.entry + index_name + 24, body.offset + object.size() + 16); + return out; +} + +std::span graphics_options() { return all_options; } +std::span items() { return all_items; } + +std::vector> display_resolutions() { + std::vector> out; + DEVMODEW mode{}; + mode.dmSize = sizeof(mode); + for (DWORD index = 0; EnumDisplaySettingsW(nullptr, index, &mode); ++index) { + const std::pair size(static_cast(mode.dmPelsWidth), static_cast(mode.dmPelsHeight)); + if (std::find(out.begin(), out.end(), size) == out.end()) out.push_back(size); + } + std::sort(out.begin(), out.end()); + return out; +} + +const char* level_value(const Setting& setting, int level) { + if (setting.choices.size() != std::size(quality) || level < 0 || level >= static_cast(std::size(quality))) return nullptr; + for (std::size_t i = 0; i < std::size(quality); ++i) + if (!std::string_view(setting.choices[i].value).ends_with(std::string("_") + quality[i].value)) return nullptr; + return setting.choices[static_cast(level)].value; +} + +fs::path save_root() { + std::array local{}; + const auto length = GetEnvironmentVariableW(L"LOCALAPPDATA", local.data(), static_cast(local.size())); + if (!length || length >= local.size()) return {}; + return fs::path(local.data()) / L"ReSkate" / L"Game" / L"Skate" / L"data"; +} + +Saved load(const fs::path& root) { + Saved saved; + fs::file_time_type newest{}; + std::error_code error; + // data\\\<container>\{DATA, INDEX} + for (fs::recursive_directory_iterator it(root, fs::directory_options::skip_permission_denied, error), end; !error && it != end; + it.increment(error)) { + if (it.depth() > 2) { it.disable_recursion_pending(); continue; } + if (it.depth() != 2 || !it->is_directory(error)) continue; + const auto folder = it->path(); + std::error_code missing; + if (!fs::is_regular_file(folder / L"DATA", missing) || !fs::is_regular_file(folder / L"INDEX", missing)) continue; + try { + auto values = read_values({read_file(folder / L"DATA"), read_file(folder / L"INDEX")}); + // The game's settings, not another of its saves. + const auto options = values.find(options_key); + if (options == values.end() || !std::holds_alternative<std::string>(options->second)) continue; + const auto written = fs::last_write_time(folder / L"DATA", missing); + if (saved.folders.empty() || written > newest) saved.values = std::move(values), newest = written; + saved.folders.push_back(folder); + } catch (const std::exception&) { + // Another of the game's saves, or a format this does not know: left alone. + } + } + return saved; +} + +void save(const Saved& saved, const Values& changes, const fs::path& backup) { + if (saved.folders.empty()) fail("skate. has not saved its settings on this PC yet"); + if (changes.empty()) return; + // Everything is read and rebuilt before any file is replaced. + std::vector<Container> rebuilt; + for (const auto& folder : saved.folders) { + const Container before{read_file(folder / L"DATA"), read_file(folder / L"INDEX")}; + auto after = with_values(before, changes); + const auto stored = read_values(after); + for (const auto& [key, value] : changes) + if (stored.at(key) != value) fail("The rewritten settings did not read back as they were written"); + rebuilt.push_back(std::move(after)); + if (backup.empty()) continue; + const auto keep = backup / folder.parent_path().parent_path().filename() / folder.filename(); + std::error_code error; + if (fs::exists(keep / L"DATA", error)) continue; + fs::create_directories(keep, error); + fs::copy_file(folder / L"DATA", keep / L"DATA", fs::copy_options::overwrite_existing, error); + fs::copy_file(folder / L"INDEX", keep / L"INDEX", fs::copy_options::overwrite_existing, error); + } + for (std::size_t i = 0; i < rebuilt.size(); ++i) { + // The pair is replaced back to back: INDEX gives the length of DATA's last section. + write_file(saved.folders[i] / L"DATA", rebuilt[i].data); + write_file(saved.folders[i] / L"INDEX", rebuilt[i].index); + } +} + +} // namespace dingosdk::launcher_game_settings diff --git a/Launcher/game_settings.h b/Launcher/game_settings.h new file mode 100644 index 0000000..1ae3704 --- /dev/null +++ b/Launcher/game_settings.h @@ -0,0 +1,101 @@ +#pragma once + +#include <cstdint> +#include <filesystem> +#include <map> +#include <span> +#include <string> +#include <string_view> +#include <utility> +#include <variant> +#include <vector> + +// skate.'s own settings, edited from the launcher. The game keeps the ones it saves per PC in +// save containers under its data folder (%LOCALAPPDATA%\ReSkate\Game\Skate\data, where ReSkate +// points it): each container is a DATA file of sections and an INDEX naming them. The "body" +// section is one binary object, a setting a named value: on/off, a whole number, a number or +// text. Its "TierManagerUserSettings" text holds the graphics options as +// "Name;value|Name;value". Only the values asked for are rewritten; every other byte of a +// container is kept. +namespace dingosdk::launcher_game_settings { + +using Value = std::variant<bool, std::int32_t, float, std::string>; +using Values = std::map<std::string, Value, std::less<>>; // by the game's setting key + +struct Container { + std::vector<unsigned char> data, index; +}; + +// Every setting of those four kinds a container holds. Throws when it is not a container of +// the game's settings, or its format is not the one this was written for. +Values read_values(const Container& container); +// That container with `changes` stored. Each must be a setting the container already holds, +// of the same kind. +Container with_values(const Container& container, const Values& changes); + +// ---------------------------------------------------------------- graphics options + +inline constexpr char options_key[] = "TierManagerUserSettings"; +struct Option { + std::string name, value; // "Texture Quality", "texture_quality_ultra" + bool operator==(const Option&) const = default; +}; +using Options = std::vector<Option>; // in the game's order +// "Name;value|Name;value" and back. +Options parse_options(std::string_view text); +std::string join_options(const Options& options); + +// What the launcher offers for one option: the game's own value names, with labels. +struct Choice { const char* value; const char* label; }; +struct Setting { + const char* category; + const char* name; // the option's name in the game + const char* label; + std::span<const Choice> choices; + // Only matters while this other option has this value ("" : always). + const char* needs_name; + const char* needs_value; +}; +std::span<const Setting> graphics_options(); +// The value of `level` (0 low .. 3 ultra) for an option that has those four, else null. +const char* level_value(const Setting& setting, int level); +// Any change makes the game's preset "Custom". +inline constexpr char preset_name[] = "Global Graphics Quality"; +inline constexpr char preset_custom[] = "Custom"; + +// ---------------------------------------------------------------- every other setting + +// The game's settings menus, as its data defines them (DingoUISettingAsset: key, kind, range +// or choices), for the settings it saves per PC. +enum class Kind { toggle, whole, real, choice, resolution }; +struct Named { int value; const char* label; }; +struct Item { + const char* page; // the launcher's tab + const char* section; + const char* key; + const char* label; + Kind kind; + float least, most, step; // whole, real + std::span<const Named> choices; // choice +}; +std::span<const Item> items(); + +// The resolutions the game's Resolution setting counts through: this PC's display modes, +// smallest first. +std::vector<std::pair<int, int>> display_resolutions(); + +// ---------------------------------------------------------------- on disk + +// Where the game keeps its saves. +std::filesystem::path save_root(); +// Every container under `root` that holds the settings, and the values of the one written last. +struct Saved { + std::vector<std::filesystem::path> folders; + Values values; +}; +Saved load(const std::filesystem::path& root); +// Stores `changes` in each of those containers. The first time, the untouched files are copied +// to `backup` (a folder per container). Throws when a file cannot be read or written. +void save(const Saved& saved, const Values& changes, const std::filesystem::path& backup); + +} // namespace dingosdk::launcher_game_settings diff --git a/Launcher/gui_internal.h b/Launcher/gui_internal.h index 7a091d4..8c763fe 100644 --- a/Launcher/gui_internal.h +++ b/Launcher/gui_internal.h @@ -1,6 +1,7 @@ #pragma once #include "gamepad_input.h" +#include "game_settings.h" #include "launch.h" #include "text_encoding.h" #include "thunderstore.h" @@ -320,12 +321,23 @@ struct Changelog { } }; +// Settings > GRAPHICS, AUDIO, CAMERA, CONTROLS, REPLAY: skate.'s own settings, read from its +// save each time Settings opens (game_settings.h). +struct GamePages { + bool loaded{}; + launcher_game_settings::Saved saved; + launcher_game_settings::Values values; // as shown; stored once no control is held + std::vector<std::pair<int, int>> resolutions; // this PC's, for the Resolution setting + std::string error; // why they could not be read, or the last change not stored +}; + // Panels the main screen can show; one at a time. struct Ui { bool settings{}; + GamePages game; bool changelog{}; Changelog notes; - int settings_tab{}; // GAME, DISPLAY, KEYS, ADVANCED + int settings_tab{}; // GAME, DISPLAY, KEYS, ADVANCED, then the game's own pages int binding{}; // 1 = menu key, 2 = console key, while waiting for a press std::string key_error; bool mods{}; diff --git a/Launcher/gui_settings.cpp b/Launcher/gui_settings.cpp index 8c632c7..b434d23 100644 --- a/Launcher/gui_settings.cpp +++ b/Launcher/gui_settings.cpp @@ -1,5 +1,8 @@ #include "gui_internal.h" +#include <cmath> +#include <format> + // The Settings panel. namespace dingosdk::launcher_gui::detail { namespace { @@ -44,6 +47,216 @@ void section_caption(const Fonts& fonts, const char* 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) { @@ -125,9 +338,16 @@ void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui 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); - static constexpr std::array<const char*, 4> names{"GAME", "DISPLAY", "KEYS", "ADVANCED"}; + // 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. @@ -198,7 +418,15 @@ void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui case 2: if (key_bindings(fonts, settings, ui)) launcher.save(); break; - default: + 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.", @@ -227,6 +455,7 @@ void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui break; } ImGui::EndChild(); + store_game_settings(ui); ImGui::SetCursorPosY(ImGui::GetWindowHeight() - footer + S(8)); ImGui::PushFont(fonts.caption); @@ -239,7 +468,11 @@ void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui 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(); + if (!open) { + launcher.save(); + // Read again the next time: the game may have run in between. + ui.game.loaded = false; + } } } // namespace dingosdk::launcher_gui::detail diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index f4e0f7a..c8958bf 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -2,7 +2,7 @@ if(WIN32) add_executable(dingosdk_launcher WIN32 Launcher/main.cpp Launcher/launch.cpp Launcher/gui.cpp Launcher/gui_launcher.cpp Launcher/gui_renderer.cpp Launcher/gui_home.cpp Launcher/gui_settings.cpp Launcher/gui_sign_in.cpp Launcher/gui_mods.cpp Launcher/gui_mods_browse.cpp - Launcher/gui_gamepad.cpp Launcher/gamepad_input.cpp Launcher/gui_changelog.cpp + Launcher/gui_gamepad.cpp Launcher/gamepad_input.cpp Launcher/gui_changelog.cpp Launcher/game_settings.cpp Launcher/updater.cpp Launcher/mod_manager.cpp Launcher/thunderstore.cpp Launcher/problem.h) target_link_libraries(dingosdk_launcher PRIVATE dingosdk_logging dingosdk_content_cache_install dingosdk_world_layer_scan dingosdk_launcher_support dingosdk_initfs dingosdk_mod_list dingosdk_mods dingosdk_json dingosdk_miniz dingosdk_imgui dingosdk_playstation_input winhttp shell32 dwmapi windowscodecs ole32) @@ -20,6 +20,9 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) target_link_libraries(dingosdk_thunderstore_tests PRIVATE dingosdk_json dingosdk_miniz) target_include_directories(dingosdk_thunderstore_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME launcher_thunderstore COMMAND dingosdk_thunderstore_tests) + add_executable(dingosdk_game_settings_tests Launcher/Test/game_settings_tests.cpp Launcher/game_settings.cpp) + target_include_directories(dingosdk_game_settings_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME launcher_game_settings COMMAND dingosdk_game_settings_tests) add_executable(dingosdk_gamepad_input_tests Launcher/Test/gamepad_input_tests.cpp Launcher/gamepad_input.cpp) target_link_libraries(dingosdk_gamepad_input_tests PRIVATE dingosdk_imgui) target_include_directories(dingosdk_gamepad_input_tests PRIVATE "${PROJECT_SOURCE_DIR}")