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}")