From 1004e012bfe84c6c209233334fe2d51a535242a2 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sat, 3 Oct 2026 17:36:12 -0500 Subject: [PATCH 1/3] Write paths as UTF-8, not the ANSI code page A player whose game sat under a folder named U+200E (left-to-right mark, an invisible directory on their Desktop) could not launch: the status tile read "No mapping for the Unicode character exists in the target multi-byte code page". Nothing was wrong with their mods, and the merge in their log had just succeeded. fs::path::string() converts to the process's ANSI code page and throws exactly that when a character has no mapping there. initfs.cpp called it on the normal path, not in an error handler, so exporting loose files threw on every launch. Their log stops right where that export begins. Cyrillic, CJK or accented characters in an install path do the same on a code page that cannot hold them. - path_utf8() in Engine/Core/Platform/path_text.h: UTF-8 represents every path Windows allows, so it never throws. - Every path that becomes a narrow string uses it -- 35 of them across 13 files. Most are log lines and error messages, which were a second trap: reporting a problem with a path threw this instead of the problem. - The ~45 .string() calls on Json values are untouched; those are not paths. Nothing changes for an ASCII path, where both encodings agree, and a path that is not ASCII had no working behaviour to preserve. Reported with a log by a player on Discord. Moving the game to an ordinary folder works around it on an unpatched build. --- Engine/Core/Platform/path_text.h | 25 +++++++++++++++++++++++++ Engine/Vfs/game_archives.cpp | 7 ++++--- Engine/Vfs/game_bundles.cpp | 5 +++-- Engine/Vfs/initfs.cpp | 21 +++++++++++---------- Engine/Vfs/item_thumbnails.cpp | 3 ++- Engine/Vfs/mod_list.cpp | 11 ++++++----- Engine/Vfs/mod_merge_cas_store.cpp | 3 ++- Engine/Vfs/mod_merge_combine.cpp | 3 ++- Engine/Vfs/mod_merge_files.cpp | 25 +++++++++++++------------ Engine/Vfs/mod_merge_grid.cpp | 5 +++-- Engine/Vfs/mod_merge_load_screens.cpp | 3 ++- Engine/Vfs/mod_merge_stamp.cpp | 7 ++++--- Engine/Vfs/world_layer_scan.cpp | 3 ++- Launcher/updater.cpp | 3 ++- 14 files changed, 81 insertions(+), 43 deletions(-) create mode 100644 Engine/Core/Platform/path_text.h diff --git a/Engine/Core/Platform/path_text.h b/Engine/Core/Platform/path_text.h new file mode 100644 index 0000000..11bded8 --- /dev/null +++ b/Engine/Core/Platform/path_text.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +namespace dingosdk { + +// A path as narrow text. +// +// std::filesystem::path::string() converts to the process's ANSI code page and +// throws when a character has no mapping there: "No mapping for the Unicode +// character exists in the target multi-byte code page". A folder named in +// Cyrillic or CJK is enough, and so is an invisible one -- a directory called +// U+200E (left-to-right mark) has been seen in the wild. The failure lands far +// from the cause, because the path is usually only being written into a log or +// an error message at the time. +// +// UTF-8 can represent every path Windows allows, so every path that becomes a +// narrow string goes through here instead. +inline std::string path_utf8(const std::filesystem::path& path) { + const auto text = path.u8string(); + return {text.begin(), text.end()}; +} + +} // namespace dingosdk diff --git a/Engine/Vfs/game_archives.cpp b/Engine/Vfs/game_archives.cpp index 2770a66..f816703 100644 --- a/Engine/Vfs/game_archives.cpp +++ b/Engine/Vfs/game_archives.cpp @@ -1,5 +1,6 @@ #include "game_archives.h" #include "Engine/Resource/cas_codec.h" +#include "Engine/Core/Platform/path_text.h" #include #include #include @@ -26,7 +27,7 @@ Layout read_layout(const fs::path& path) { Layout layout{{std::istreambuf_iterator(input), std::istreambuf_iterator()}, {}}; if (layout.buffer.size() <= native_db::envelope_size || std::memcmp(layout.buffer.data(), native_db::magic, sizeof(native_db::magic))) - throw std::runtime_error("Unrecognized native envelope in " + path.string()); + throw std::runtime_error("Unrecognized native envelope in " + path_utf8(path)); layout.root = native_db::read(std::span(layout.buffer).subspan(native_db::envelope_size), "layout.toc", nullptr, {.unique_fields = false}); return layout; @@ -108,11 +109,11 @@ std::vector GameArchives::read(const fs::path& root, const fb::CasIde const auto path = root / L"Win32" / fs::path(directory(location.installChunk)) / fs::path(archive_file(location.archive)); std::ifstream input(path, std::ios::binary); - if (!input) throw std::runtime_error("Cannot open " + path.string()); + if (!input) throw std::runtime_error("Cannot open " + path_utf8(path)); input.seekg(static_cast(offset)); std::vector bytes(size); if (size && !input.read(reinterpret_cast(bytes.data()), static_cast(size))) - throw std::runtime_error("Cannot read " + path.string()); + throw std::runtime_error("Cannot read " + path_utf8(path)); return bytes; } diff --git a/Engine/Vfs/game_bundles.cpp b/Engine/Vfs/game_bundles.cpp index 3f05489..14de075 100644 --- a/Engine/Vfs/game_bundles.cpp +++ b/Engine/Vfs/game_bundles.cpp @@ -1,5 +1,6 @@ #include "game_bundles.h" #include "Engine/Resource/cas_codec.h" +#include "Engine/Core/Platform/path_text.h" #include #include @@ -50,10 +51,10 @@ GameData::GameData(fs::path gameRoot) fb::TocDocument GameData::read_toc(std::string_view relative) const { const auto path = gameRoot_ / L"Data" / fs::path(relative); std::ifstream input(path, std::ios::binary); - if (!input) throw std::runtime_error("Cannot open " + path.string()); + if (!input) throw std::runtime_error("Cannot open " + path_utf8(path)); std::vector bytes(static_cast(fs::file_size(path))); if (!input.read(reinterpret_cast(bytes.data()), static_cast(bytes.size()))) - throw std::runtime_error("Cannot read " + path.string()); + throw std::runtime_error("Cannot read " + path_utf8(path)); return fb::read_toc(bytes); } diff --git a/Engine/Vfs/initfs.cpp b/Engine/Vfs/initfs.cpp index 3d57d29..9fca0bf 100644 --- a/Engine/Vfs/initfs.cpp +++ b/Engine/Vfs/initfs.cpp @@ -5,6 +5,7 @@ #include "Engine/Game/Build/supported_build.h" #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/initfs.h" +#include "Engine/Core/Platform/path_text.h" #include #include @@ -37,13 +38,13 @@ std::string lower(std::string_view text) { } Bytes read_file(const fs::path& path, std::size_t maximum) { std::ifstream input(path, std::ios::binary | std::ios::ate); - if (!input) fail("Cannot open " + path.string()); + if (!input) fail("Cannot open " + path_utf8(path)); const auto length = input.tellg(); - if (length < 0 || static_cast(length) > maximum) fail("File exceeds size limit: " + path.string()); + if (length < 0 || static_cast(length) > maximum) fail("File exceeds size limit: " + path_utf8(path)); Bytes bytes(static_cast(length)); input.seekg(0); if (!bytes.empty() && !input.read(reinterpret_cast(bytes.data()), static_cast(bytes.size()))) - fail("Cannot read " + path.string()); + fail("Cannot read " + path_utf8(path)); return bytes; } @@ -128,7 +129,7 @@ void apply_archive(const fs::path& path, const Secret& key, Entries& effective, auto input = read_file(path, maximum_archive); if (input.size() <= native_db::envelope_size || std::memcmp(input.data(), native_db::magic, sizeof(native_db::magic))) - fail("Unrecognized native envelope in " + path.string()); + fail("Unrecognized native envelope in " + path_utf8(path)); const auto body = std::span(input).subspan(native_db::envelope_size); std::size_t consumed{}; auto root = native_db::read(body, "InitFS", &consumed); @@ -160,12 +161,12 @@ void apply_archive(const fs::path& path, const Secret& key, Entries& effective, if (const auto* file_system = file->field("fs"); file_system && (file_system->type != 7 || (!file_system->text.empty() && file_system->text != "/data"))) fail("Unsupported InitFS filesystem destination for " + name->text); - Entry entry{name->text, path.string(), Bytes(payload->bytes.begin(), payload->bytes.end())}; + Entry entry{name->text, path_utf8(path), Bytes(payload->bytes.begin(), payload->bytes.end())}; if (!entries.emplace(lower(name->text), std::move(entry)).second) fail("Duplicate InitFS virtual path"); } if (!inherit) effective.clear(); for (auto& [name, entry] : entries) effective.insert_or_assign(name, std::move(entry)); - sources.push_back(Json{{"path", path.string()}, {"sha256", launcher::sha256_file(path)}, {"inherit", inherit}}); + sources.push_back(Json{{"path", path_utf8(path)}, {"sha256", launcher::sha256_file(path)}, {"inherit", inherit}}); } bool within(const fs::path& root, const fs::path& path) { @@ -178,7 +179,7 @@ bool within(const fs::path& root, const fs::path& path) { bool write_atomic(const fs::path& root, const fs::path& path, std::span bytes, bool replace) { if (!within(root, fs::weakly_canonical(path))) fail("Output path escapes the export directory"); if (!replace && fs::exists(path)) { - if (!fs::is_regular_file(path)) fail("Output path is not a regular file: " + path.string()); + if (!fs::is_regular_file(path)) fail("Output path is not a regular file: " + path_utf8(path)); return false; } fs::create_directories(path.parent_path()); @@ -186,17 +187,17 @@ bool write_atomic(const fs::path& root, const fs::path& path, std::span(bytes.size()), &written, nullptr) && written == bytes.size() && FlushFileBuffers(file); CloseHandle(file); - if (!ready) { DeleteFileW(temporary.c_str()); fail("Cannot write output file: " + path.string()); } + if (!ready) { DeleteFileW(temporary.c_str()); fail("Cannot write output file: " + path_utf8(path)); } if (MoveFileExW(temporary.c_str(), path.c_str(), MOVEFILE_WRITE_THROUGH | (replace ? MOVEFILE_REPLACE_EXISTING : 0))) return true; const auto error = GetLastError(); DeleteFileW(temporary.c_str()); if (!replace && (error == ERROR_ALREADY_EXISTS || error == ERROR_FILE_EXISTS) && fs::is_regular_file(path)) return false; - fail("Cannot publish output file: " + path.string()); + fail("Cannot publish output file: " + path_utf8(path)); } Json read_preferences(const fs::path& root) { const auto path = root / L"ReSkate.settings.json"; diff --git a/Engine/Vfs/item_thumbnails.cpp b/Engine/Vfs/item_thumbnails.cpp index ebd011e..09daeae 100644 --- a/Engine/Vfs/item_thumbnails.cpp +++ b/Engine/Vfs/item_thumbnails.cpp @@ -1,5 +1,6 @@ #include "item_thumbnails.h" #include "game_bundles.h" +#include "Engine/Core/Platform/path_text.h" #include #include @@ -32,7 +33,7 @@ ThumbnailRead read_build_kit_thumbnails(const std::filesystem::path& gameRoot, s bundle = data.read_bundle(toc, bundle_name); } catch (const std::exception& failure) { if (problems.size() < 600) - problems += "; " + it->path().filename().string() + ": " + failure.what(); + problems += "; " + path_utf8(it->path().filename()) + ": " + failure.what(); } } if (!bundle) throw std::runtime_error("The game's thumbnail bundle was not found" + problems); diff --git a/Engine/Vfs/mod_list.cpp b/Engine/Vfs/mod_list.cpp index fb9dd25..3d68090 100644 --- a/Engine/Vfs/mod_list.cpp +++ b/Engine/Vfs/mod_list.cpp @@ -2,6 +2,7 @@ #include "Engine/Game/Build/supported_build.h" #include "Engine/Core/Json/json.h" +#include "Engine/Core/Platform/path_text.h" #include @@ -35,15 +36,15 @@ std::string lower(std::string_view text) { std::vector read_file(const fs::path& path, std::size_t maximum) { std::ifstream input(path, std::ios::binary | std::ios::ate); - if (!input) throw std::runtime_error("Cannot open " + path.string()); + if (!input) throw std::runtime_error("Cannot open " + path_utf8(path)); const auto length = input.tellg(); if (length < 0 || static_cast(length) > maximum) - throw std::runtime_error("File exceeds size limit: " + path.string()); + throw std::runtime_error("File exceeds size limit: " + path_utf8(path)); std::vector bytes(static_cast(length)); input.seekg(0); if (!bytes.empty() && !input.read(reinterpret_cast(bytes.data()), static_cast(bytes.size()))) - throw std::runtime_error("Cannot read " + path.string()); + throw std::runtime_error("Cannot read " + path_utf8(path)); return bytes; } @@ -420,12 +421,12 @@ void save_mod_order(const std::filesystem::path& mods_root, const std::vector(text.size())) || !output.flush()) - throw std::runtime_error("Cannot write " + temporary.string()); + throw std::runtime_error("Cannot write " + path_utf8(temporary)); } if (!MoveFileExW(temporary.c_str(), target.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { std::error_code ignored; fs::remove(temporary, ignored); - throw std::runtime_error("Cannot replace " + target.string() + " (Windows error " + + throw std::runtime_error("Cannot replace " + path_utf8(target) + " (Windows error " + std::to_string(GetLastError()) + ")"); } } diff --git a/Engine/Vfs/mod_merge_cas_store.cpp b/Engine/Vfs/mod_merge_cas_store.cpp index e4f56e9..9223420 100644 --- a/Engine/Vfs/mod_merge_cas_store.cpp +++ b/Engine/Vfs/mod_merge_cas_store.cpp @@ -1,4 +1,5 @@ #include "mod_merge_internal.h" +#include "Engine/Core/Platform/path_text.h" #include #include @@ -24,7 +25,7 @@ fb::BundleFileInfo CasStore::write(std::uint32_t installChunk, std::uint16_t arc std::ofstream out(path, std::ios::binary | std::ios::app); if (!out || !out.write(reinterpret_cast(encoded.data()), static_cast(encoded.size()))) - throw std::runtime_error("Cannot append to " + path.string()); + throw std::runtime_error("Cannot append to " + path_utf8(path)); fb::BundleFileInfo info{{true, installChunk, archive}, static_cast(offset), static_cast(encoded.size())}; offset += encoded.size(); diff --git a/Engine/Vfs/mod_merge_combine.cpp b/Engine/Vfs/mod_merge_combine.cpp index 76b4e10..d3a5640 100644 --- a/Engine/Vfs/mod_merge_combine.cpp +++ b/Engine/Vfs/mod_merge_combine.cpp @@ -5,6 +5,7 @@ #include "Engine/Resource/ebx_merge.h" #include "Engine/Resource/ebx_writer.h" #include "Engine/Resource/shader_lookup.h" +#include "Engine/Core/Platform/path_text.h" #include #include @@ -153,7 +154,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, auto region = fb::read_bundle_region(bundle.region); const auto provider = providers.find(key); const auto shared = provider != providers.end() && provider->second > 1; - const auto modName = isBase ? std::string("base") : root.filename().string(); + const auto modName = isBase ? std::string("base") : path_utf8(root.filename()); // The bundle holding the live material grid is rebuilt to carry the // combined one, and a mod whose surfaces moved has its collision // renumbered; both need the asset list even where one mod ships it. diff --git a/Engine/Vfs/mod_merge_files.cpp b/Engine/Vfs/mod_merge_files.cpp index c7e5147..a169b9e 100644 --- a/Engine/Vfs/mod_merge_files.cpp +++ b/Engine/Vfs/mod_merge_files.cpp @@ -1,5 +1,6 @@ #include "mod_merge_internal.h" #include "Engine/Core/Json/json.h" +#include "Engine/Core/Platform/path_text.h" #include #include @@ -21,15 +22,15 @@ std::string lower(std::string_view text) { std::vector read_file(const fs::path& path) { std::ifstream input(path, std::ios::binary | std::ios::ate); - if (!input) throw std::runtime_error("Cannot open " + path.string()); + if (!input) throw std::runtime_error("Cannot open " + path_utf8(path)); const auto length = input.tellg(); if (length < 0 || static_cast(length) > maximumTocBytes) - throw std::runtime_error("File exceeds the TOC size limit: " + path.string()); + throw std::runtime_error("File exceeds the TOC size limit: " + path_utf8(path)); std::vector bytes(static_cast(length)); input.seekg(0); if (!bytes.empty() && !input.read(reinterpret_cast(bytes.data()), static_cast(bytes.size()))) - throw std::runtime_error("Cannot read " + path.string()); + throw std::runtime_error("Cannot read " + path_utf8(path)); return bytes; } @@ -40,12 +41,12 @@ void write_file(const fs::path& path, std::span bytes) { std::ofstream output(temporary, std::ios::binary | std::ios::trunc); if (!output || !output.write(reinterpret_cast(bytes.data()), static_cast(bytes.size()))) - throw std::runtime_error("Cannot write " + temporary.string()); + throw std::runtime_error("Cannot write " + path_utf8(temporary)); } if (!MoveFileExW(temporary.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { const auto error = GetLastError(); DeleteFileW(temporary.c_str()); - throw std::runtime_error("Cannot publish " + path.string() + + throw std::runtime_error("Cannot publish " + path_utf8(path) + " (Windows error " + std::to_string(error) + ")"); } } @@ -64,12 +65,12 @@ void unshare(const fs::path& path) { const auto temporary = fs::path(path.wstring() + L".unshared"); std::error_code error; fs::copy_file(path, temporary, fs::copy_options::overwrite_existing, error); - if (error) throw std::runtime_error("Cannot give " + path.string() + " a private copy: " + + if (error) throw std::runtime_error("Cannot give " + path_utf8(path) + " a private copy: " + error.message()); if (!MoveFileExW(temporary.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING)) { const auto failure = GetLastError(); DeleteFileW(temporary.c_str()); - throw std::runtime_error("Cannot replace the shared " + path.string() + + throw std::runtime_error("Cannot replace the shared " + path_utf8(path) + " (Windows error " + std::to_string(failure) + ")"); } } @@ -78,22 +79,22 @@ std::uint64_t append_file(const fs::path& from, const fs::path& to) { fs::create_directories(to.parent_path()); unshare(to); std::ifstream input(from, std::ios::binary); - if (!input) throw std::runtime_error("Cannot read " + from.string()); + if (!input) throw std::runtime_error("Cannot read " + path_utf8(from)); // tellp() on a stream opened for append reads 0 until the first write, so // the block's start comes from the file itself. std::error_code error; const auto existing = fs::file_size(to, error); const auto start = error ? std::uint64_t{} : existing; std::ofstream output(to, std::ios::binary | std::ios::app); - if (!output) throw std::runtime_error("Cannot append to " + to.string()); + if (!output) throw std::runtime_error("Cannot append to " + path_utf8(to)); // A megabyte of stack is a stack overflow; the copy buffer lives on the heap. std::vector buffer(1 << 20); while (input) { input.read(buffer.data(), static_cast(buffer.size())); if (input.gcount() && !output.write(buffer.data(), input.gcount())) - throw std::runtime_error("Cannot append to " + to.string()); + throw std::runtime_error("Cannot append to " + path_utf8(to)); } - if (!output) throw std::runtime_error("Cannot append to " + to.string()); + if (!output) throw std::runtime_error("Cannot append to " + path_utf8(to)); return start; } @@ -104,7 +105,7 @@ void link_or_copy(const fs::path& from, const fs::path& to) { fs::remove(to, error); if (CreateHardLinkW(to.c_str(), from.c_str(), nullptr)) return; fs::copy_file(from, to, fs::copy_options::overwrite_existing, error); - if (error) throw std::runtime_error("Cannot place " + to.string() + ": " + error.message()); + if (error) throw std::runtime_error("Cannot place " + path_utf8(to) + ": " + error.message()); } RelativeFiles scan(const fs::path& directory) { diff --git a/Engine/Vfs/mod_merge_grid.cpp b/Engine/Vfs/mod_merge_grid.cpp index fc8ba2c..11dcf39 100644 --- a/Engine/Vfs/mod_merge_grid.cpp +++ b/Engine/Vfs/mod_merge_grid.cpp @@ -2,6 +2,7 @@ #include "Engine/Resource/cas_codec.h" #include "Engine/Resource/ebx_document.h" #include "Engine/Resource/ebx_writer.h" +#include "Engine/Core/Platform/path_text.h" #include #include @@ -128,7 +129,7 @@ GridPlan plan_material_grid(const std::vector& mods, // Lowest priority first, the order the merge layers mods in. for (auto at = mods.rbegin(); at != mods.rend(); ++at) { const auto* mod = *at; - const auto modName = mod->directory.filename().string(); + const auto modName = path_utf8(mod->directory.filename()); for (const auto& relative : modFiles.at(mod).tocs) { try { const auto toc = fb::read_toc(read_file(mod->directory / fs::path(relative))); @@ -190,7 +191,7 @@ GridPlan plan_material_grid(const std::vector& mods, grid::Combiner combiner(*base); std::size_t combined{}; for (auto& contribution : contributions) { - const auto modName = contribution.mod->directory.filename().string(); + const auto modName = path_utf8(contribution.mod->directory.filename()); const auto before = combiner.slots(); grid::SlotMap slots; try { diff --git a/Engine/Vfs/mod_merge_load_screens.cpp b/Engine/Vfs/mod_merge_load_screens.cpp index 51f11cf..8ed98e9 100644 --- a/Engine/Vfs/mod_merge_load_screens.cpp +++ b/Engine/Vfs/mod_merge_load_screens.cpp @@ -1,5 +1,6 @@ #include "mod_merge_internal.h" #include "Engine/Resource/ebx_document.h" +#include "Engine/Core/Platform/path_text.h" #include #include @@ -33,7 +34,7 @@ std::vector read_load_screens(const std::vector result; for (const auto* mod : mods) { - const auto modName = mod->directory.filename().string(); + const auto modName = path_utf8(mod->directory.filename()); for (const auto& relative : modFiles.at(mod).tocs) { if (lower(relative) != configurationToc) continue; try { diff --git a/Engine/Vfs/mod_merge_stamp.cpp b/Engine/Vfs/mod_merge_stamp.cpp index 2f067f6..3161150 100644 --- a/Engine/Vfs/mod_merge_stamp.cpp +++ b/Engine/Vfs/mod_merge_stamp.cpp @@ -1,4 +1,5 @@ #include "mod_merge_internal.h" +#include "Engine/Core/Platform/path_text.h" #include #include @@ -69,7 +70,7 @@ std::string merge_fingerprint(const Catalog& catalog, const std::vectoris_regular_file(error) && !error) files.push_back(it->path()); error.clear(); } - if (error) throw std::runtime_error("Cannot list " + mod->directory.string()); + if (error) throw std::runtime_error("Cannot list " + path_utf8(mod->directory)); std::ranges::sort(files); for (const auto& file : files) describe(utf8(fs::relative(file, mod->directory, error)), file); @@ -151,10 +152,10 @@ void write_stamp(const fs::path& output, const std::string& fingerprint, const M for (fs::recursive_directory_iterator it(output, error), end; it != end && !error; it.increment(error)) { if (!it->is_regular_file(error) || error) { error.clear(); continue; } const auto size = fs::file_size(it->path(), error); - if (error) throw std::runtime_error("Cannot size " + it->path().string()); + if (error) throw std::runtime_error("Cannot size " + path_utf8(it->path())); text += "file " + std::to_string(size) + ' ' + utf8(fs::relative(it->path(), output, error)) + '\n'; } - if (error) throw std::runtime_error("Cannot list " + output.string()); + if (error) throw std::runtime_error("Cannot list " + path_utf8(output)); for (auto note : report.notes) { std::ranges::replace(note, '\n', ' '); std::ranges::replace(note, '\r', ' '); diff --git a/Engine/Vfs/world_layer_scan.cpp b/Engine/Vfs/world_layer_scan.cpp index 34c84f2..a019c1d 100644 --- a/Engine/Vfs/world_layer_scan.cpp +++ b/Engine/Vfs/world_layer_scan.cpp @@ -3,6 +3,7 @@ #include "game_bundles.h" #include "Engine/Core/Json/json.h" #include "Engine/Resource/ebx_document.h" +#include "Engine/Core/Platform/path_text.h" #include #include #include @@ -252,7 +253,7 @@ WorldLayerCatalog load_or_scan(const fs::path& gameRoot, const fs::path& file) { WorldLayerCatalog read(const fs::path& file) { std::ifstream input(file, std::ios::binary); - if (!input) throw std::runtime_error("Cannot open " + file.string()); + if (!input) throw std::runtime_error("Cannot open " + path_utf8(file)); const std::string text{std::istreambuf_iterator(input), std::istreambuf_iterator()}; return from_json(Json::parse(text.begin(), text.end())); } diff --git a/Launcher/updater.cpp b/Launcher/updater.cpp index 4f8557c..5532a66 100644 --- a/Launcher/updater.cpp +++ b/Launcher/updater.cpp @@ -5,6 +5,7 @@ #include "Engine/Core/Platform/launcher_support.h" #include "launcher_update_config.h" #include "text_encoding.h" +#include "Engine/Core/Platform/path_text.h" #include #include @@ -197,7 +198,7 @@ struct Zip { // Writes one ZIP entry to `output`. void extract_index(Zip& zip, mz_uint index, const mz_zip_archive_file_stat& entry, const fs::path& output) { Handle file{CreateFileW(output.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)}; - if (file.value == INVALID_HANDLE_VALUE) fail("Cannot create " + output.string()); + if (file.value == INVALID_HANDLE_VALUE) fail("Cannot create " + path_utf8(output)); struct Sink { HANDLE file; std::uint64_t count{}; } sink{file.value}; const auto write = [](void* opaque, mz_uint64 offset, const void* buffer, size_t count) -> size_t { auto& target = *static_cast(opaque); From abdda77946d82127ba5ceb94bf66dbb2b0e1de44 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sat, 3 Oct 2026 18:30:28 -0500 Subject: [PATCH 2/3] Give each mod archive its own index; unblock launches and saves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mod archives (Engine/Vfs/mod_merge*) - A free archive index no longer has to sit at or below the highest index the layout already declares. The engine keeps archives in a table keyed by layer, install chunk and a 16-bit index, with no range to stay inside; what it cannot survive is a reference to an archive the layout never declared. Indices are taken lowest first, per package directory. - So a mod that collides takes an index of its own and its file is hard linked rather than concatenated, and no two mods share one archive's 4 GB of addressable bytes. Sharing is left for a mod added while the game runs, which has no declared index to take, and for a directory with no index left. - A placement addresses its payload with a 32-bit offset, so both append paths now refuse to cross 4 GB instead of wrapping round and pointing the game at some other payload: CasStore::write fails with the archive that is full, and the live-apply path checks archive 1's size plus the mod's before appending and asks for a restart, which gives the mod an archive of its own anyway. Injection through a hooked LoadLibraryW (Engine/Core/Platform) - A player could not launch at all: "Child LoadLibraryW entry bytes match neither Windows' kernel32 file nor this process". Anti-virus and overlays hook the entry with a jump to a per-process trampoline, so the bytes differ in the launcher and in the child and neither comparison could match. The guard already allowed the same hook when both processes carried it identically, so it refused on an accident of how the hook was written rather than on intent. - inline_jump_length recognises the forms a hooking engine writes (rel32 jmp or call, push/ret, jmp [rip], mov rax/jmp rax, short jmp). When one is present and every byte after it still matches the clean kernel32 file, the launch goes ahead: the body is Windows' own, and the provider image already matched on timestamp, checksum and size. A wholesale replacement is still refused, and now says so. - The failure names the modules loaded into Skate that came from neither Windows nor the game folder, instead of guessing at "security software". An accepted hook is logged as a warning with the same list. Launching (Launcher/launch.cpp) - Exporting loose scripts is a convenience, and a folder that would not take it was failing the whole launch: fs::create_directories threw and nothing caught it. The export is skipped, RESKATE_LOOSE_FILES goes to 0 so the runtime does not look for files that are not there, and Skate starts. Someone whose install is under Program Files can play; only editing loose scripts is lost. Failures worth reading (Launcher/problem.h) - A hooked LoadLibraryW, a refused write and ERROR_ELEVATION_REQUIRED each explain themselves now. Windows reports the last two in the user's own language -- "Zugriff verweigert", "La operación solicitada requiere elevación" -- so the rules key on the operation names and the error number, which are never translated. Saved skaters (Extension/Customization, Extension/Profile) - Outfits stopped saving and every slot showed the standard skater. The load hook marks a preset blocked on entry and clears it only on a full success, so a load refused for a catalog that was not ready yet left the preset blocked for the session and every later save was refused as previous_load_rejected. The game asks once per level load and does not ask again, so one lost race cost a skater until a restart. Logs show the catalog arriving three seconds after the refused load, at startup and again on a map switch 17 minutes later. - The record is remembered in CosmeticRuntime::pending_loads and applied by retry_pending_cosmetic_loads from the customization pump, which already waits for refresh_cosmetic_catalog. The preset stays blocked until a load really succeeds, so nothing is written over the outfit on disk. - Those records belong to the loadout manager's array, which can be replaced in between, so each pointer is checked against the live array exactly as a save checks one -- manager current, begin/end sane, 0x48 stride, at most 10 records, in range and aligned. A record that no longer belongs is dropped as retry_record_moved rather than written through. --- Engine/Core/Platform/launcher_injection.cpp | 86 ++++++++++++++++++- Engine/Core/Platform/launcher_support.h | 5 +- Engine/Vfs/mod_merge.cpp | 36 +++++--- Engine/Vfs/mod_merge_cas_store.cpp | 5 ++ Engine/Vfs/mod_merge_internal.h | 16 ++-- .../local_customization_runtime.cpp | 60 +++++++++++-- .../local_customization_runtime.h | 10 +++ Extension/Profile/local_profile_runtime.cpp | 4 + Launcher/launch.cpp | 38 +++++--- Launcher/problem.h | 27 ++++++ 10 files changed, 240 insertions(+), 47 deletions(-) diff --git a/Engine/Core/Platform/launcher_injection.cpp b/Engine/Core/Platform/launcher_injection.cpp index 225a716..824f66d 100644 --- a/Engine/Core/Platform/launcher_injection.cpp +++ b/Engine/Core/Platform/launcher_injection.cpp @@ -1,5 +1,6 @@ #include "launcher_support.h" #include "launcher_support_internal.h" +#include "path_text.h" #include #include @@ -21,6 +22,60 @@ using detail::fail; using detail::fail_windows; using detail::Handle; +// What is loaded into the child that Windows did not put there. One of these +// is usually what hooked LoadLibraryW, and naming it beats telling someone to +// go looking for "security software". +std::string foreign_modules(HANDLE process) { + std::array windows{}; + const auto length = GetWindowsDirectoryW(windows.data(), static_cast(windows.size())); + const std::wstring system_root(windows.data(), length && length < windows.size() ? length : 0); + Handle snapshot(CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, GetProcessId(process))); + if (snapshot.get() == INVALID_HANDLE_VALUE || !snapshot.get()) return {}; + MODULEENTRY32W entry{}; + entry.dwSize = sizeof(entry); + std::wstring game_root; + std::string names; + std::size_t shown = 0; + for (auto more = Module32FirstW(snapshot.get(), &entry); more; + more = Module32NextW(snapshot.get(), &entry)) { + const std::wstring path(entry.szExePath); + // The first module is Skate.exe itself; everything beside it is the + // install, which is not what we are looking for. + if (game_root.empty()) { + game_root = fs::path(path).parent_path().wstring(); + continue; + } + const auto under = [&path](const std::wstring& root) { + return !root.empty() && path.size() > root.size() && + _wcsnicmp(path.c_str(), root.c_str(), root.size()) == 0; + }; + if (under(system_root) || under(game_root)) continue; + if (shown == 8) { names += ", and more"; break; } + names += (names.empty() ? "" : ", ") + path_utf8(fs::path(entry.szModule)); + ++shown; + } + return names; +} + +// The length of a jump written over a function's first bytes, or zero when the +// bytes are not one of the forms a hooking engine writes. Anti-virus and +// overlays hook by patching a jump to their own code at the entry and running +// the original afterwards; what they do not do is replace the whole function. +std::size_t inline_jump_length(const unsigned char* bytes, std::size_t size) { + const auto at = [&](std::size_t index) { return index < size ? bytes[index] : 0u; }; + if (size >= 5 && (at(0) == 0xE9 || at(0) == 0xE8)) return 5; // jmp/call rel32 + if (size >= 6 && at(0) == 0x68 && at(5) == 0xC3) return 6; // push imm32; ret + if (size >= 6 && at(0) == 0xFF && at(1) == 0x25) { + // jmp [rip+rel32]. The common form targets the eight bytes that follow. + const bool next = at(2) == 0 && at(3) == 0 && at(4) == 0 && at(5) == 0; + return next && size >= 14 ? 14 : 6; + } + if (size >= 12 && at(0) == 0x48 && at(1) == 0xB8 && at(10) == 0xFF && at(11) == 0xE0) + return 12; // mov rax, imm64; jmp rax + if (size >= 2 && at(0) == 0xEB) return 2; // short jmp, as hotpatching writes + return 0; +} + bool executable(DWORD protection) { if (protection & (PAGE_GUARD | PAGE_NOACCESS)) return false; switch (protection & 0xff) { @@ -390,7 +445,7 @@ LoaderGate prepare_loader_for_injection(HANDLE process, HANDLE primary_thread, } } -std::uintptr_t validated_remote_load_library(HANDLE process) { +std::uintptr_t validated_remote_load_library(HANDLE process, std::string* note) { const auto kernel32 = GetModuleHandleW(L"kernel32.dll"); if (!kernel32) fail_windows("Cannot locate local kernel32.dll"); const auto symbol = GetProcAddress(kernel32, "LoadLibraryW"); @@ -468,9 +523,32 @@ std::uintptr_t validated_remote_load_library(HANDLE process) { const auto clean = image_file_bytes(fs::path(std::wstring(provider_path, provider_length)), static_cast(address - local_base), fingerprint_size, remote_base); const bool matches_file = clean.size() == actual.size() && std::equal(clean.begin(), clean.end(), actual.begin()); - if (!matches_file && actual != local_bytes) - fail("Child LoadLibraryW entry bytes match neither Windows' kernel32 file nor this process. " - "Security software may be hooking new processes; try allowing ReSkateLauncher.exe and Skate.exe."); + // A hook that writes a jump to a per-process trampoline leaves different + // bytes in the launcher and in the child, so neither comparison above can + // match even though nothing is wrong. Accept that shape when the jump is + // the only difference: everything past it is still Windows' own code, and + // the provider image already matched by timestamp, checksum and size. + bool hooked_entry = false; + if (!matches_file && actual != local_bytes && clean.size() == actual.size()) { + const auto jump = inline_jump_length(actual.data(), actual.size()); + hooked_entry = jump > 0 && jump < actual.size() && + std::equal(clean.begin() + static_cast(jump), clean.end(), + actual.begin() + static_cast(jump)); + } + if (!matches_file && actual != local_bytes && !hooked_entry) { + const auto foreign = foreign_modules(process); + std::string message = "Skate's LoadLibraryW has been replaced, not just hooked: its entry bytes match " + "neither Windows' kernel32 file nor this process, and what follows them is not " + "Windows' code either."; + if (!foreign.empty()) message += " Loaded into Skate and not part of Windows: " + foreign + "."; + fail(message.c_str()); + } + if (hooked_entry && note) { + *note = "Skate's LoadLibraryW is hooked at its entry; the rest of it is Windows' own, so ReSkate is " + "loading through it."; + if (const auto foreign = foreign_modules(process); !foreign.empty()) + *note += " Loaded into Skate and not part of Windows: " + foreign + "."; + } return address; } diff --git a/Engine/Core/Platform/launcher_support.h b/Engine/Core/Platform/launcher_support.h index c36f3f3..aeb1a0c 100644 --- a/Engine/Core/Platform/launcher_support.h +++ b/Engine/Core/Platform/launcher_support.h @@ -128,6 +128,9 @@ LoaderGate prepare_loader_for_injection(HANDLE process, HANDLE primary_thread, // Returns the ordinary LoadLibraryW address for a same-architecture suspended // child only after verifying the matching KnownDLL mapping and entry bytes. -std::uintptr_t validated_remote_load_library(HANDLE process); +// An entry hooked by anti-virus or an overlay is allowed through when the rest +// of the function is still Windows' own; `note`, when given, is then filled +// with what was found so the caller can log it. +std::uintptr_t validated_remote_load_library(HANDLE process, std::string* note = nullptr); } // namespace dingosdk::launcher diff --git a/Engine/Vfs/mod_merge.cpp b/Engine/Vfs/mod_merge.cpp index 5030c41..4180511 100644 --- a/Engine/Vfs/mod_merge.cpp +++ b/Engine/Vfs/mod_merge.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -98,10 +99,11 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con // What the layout declares in each package directory. A free index is // judged against its own directory rather than every directory at once - // -- judged against the union, three mods exhausted the range -- and it - // stays at or below the highest index that directory already declares: - // the engine sizes each install chunk's archive table from that range, - // and an index past it brings the game down during startup. + // (judged against the union, three mods used up the gaps), lowest + // first, and carries on past the highest index the game declares: the + // engine keeps archives in a table keyed by layer, install chunk and a + // 16-bit index, with no range to stay inside. What it cannot survive is + // a reference to an archive the layout never declared. std::map> declaredIn; for (const auto* field : {"layeredInstallChunkFiles", "unlayeredInstallChunkFiles"}) { const auto* node = layout.field(field); @@ -110,16 +112,14 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con if (const auto* directory = store.find_directory(id)) declaredIn[*directory].insert(archive); }); } - const auto ceiling = [&](const std::string& directory) -> std::uint16_t { - const auto found = declaredIn.find(directory); - return found == declaredIn.end() || found->second.empty() ? 0 : *found->second.rbegin(); - }; std::map> claimedArchives; const auto claim = [&](const std::string& directory) -> std::optional { const auto& declared = declaredIn[directory]; auto& claimed = claimedArchives[directory]; - for (std::uint16_t candidate = 1; candidate <= ceiling(directory); ++candidate) - if (!declared.contains(candidate) && claimed.insert(candidate).second) return candidate; + for (std::uint32_t candidate = 1; candidate <= std::numeric_limits::max(); ++candidate) { + const auto index = static_cast(candidate); + if (!declared.contains(index) && claimed.insert(index).second) return index; + } return std::nullopt; }; @@ -172,6 +172,14 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con const auto target = output / path.parent_path() / archive_file(manifestArchive); if (!fs::exists(target, error)) throw std::runtime_error(mod->name + " needs a restart: the patch has no archive 1 in " + directory); + // Checked before anything is appended: past 4 GB the mod's + // payloads could not be addressed, and the next launch + // gives it an archive of its own anyway. + const auto held = fs::file_size(target, error); + const auto added = error ? std::uintmax_t{} : fs::file_size(mod->directory / path, error); + if (error || held + added > std::numeric_limits::max()) + throw std::runtime_error(mod->name + " needs a restart: the patch's archive 1 in " + directory + + " has no room left for it while the game runs"); ArchivePlacement::Spot spot{manifestArchive, append_file(mod->directory / path, target)}; placements[mod].at.emplace(std::pair{directory, *number}, spot); report.notes.push_back(mod->name + ": " + directory + "/cas_" + std::to_string(*number) + @@ -194,12 +202,12 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con auto directory = lower(path.parent_path().generic_string()); if (directory.starts_with("win32/")) directory.erase(0, 6); // The first mod to ship an index keeps it. A later one takes a - // free index of its own where the base leaves one, so the file - // is hard linked rather than copied; failing that it is + // free index of its own, so the file is hard linked rather + // than copied and no two mods share an archive's 4 GB of + // addressable bytes; only with every index taken is it // concatenated onto the archive it collides with. auto& claimed = claimedArchives[directory]; ArchivePlacement::Spot spot{number, 0}; - const auto keepable = number <= ceiling(directory) || declaredIn[directory].contains(number); // Archive 1 stays the patch's own file, never a link to a mod's: // a live apply appends manifests and new mods to it while the // game has it open, and a linked file cannot be swapped for a @@ -212,7 +220,7 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con } else { spot.offset = append_file(mod->directory / path, output / path); } - } else if (keepable && claimed.insert(number).second) { + } else if (claimed.insert(number).second) { link_or_copy(mod->directory / path, output / path); } else if (const auto free = claim(directory); free) { spot.archive = *free; diff --git a/Engine/Vfs/mod_merge_cas_store.cpp b/Engine/Vfs/mod_merge_cas_store.cpp index 9223420..0bf6898 100644 --- a/Engine/Vfs/mod_merge_cas_store.cpp +++ b/Engine/Vfs/mod_merge_cas_store.cpp @@ -22,6 +22,11 @@ fb::BundleFileInfo CasStore::write(std::uint32_t installChunk, std::uint16_t arc const auto existing = fs::file_size(path, error); if (!error) offset = existing; } + // A placement holds a 32-bit offset; one past it would wrap round and + // point the game at some other payload. + if (offset + encoded.size() > std::numeric_limits::max()) + throw std::runtime_error("The merged patch's own archive " + path_utf8(path) + + " is full (4 GB); restart Skate so it is built again"); std::ofstream out(path, std::ios::binary | std::ios::app); if (!out || !out.write(reinterpret_cast(encoded.data()), static_cast(encoded.size()))) diff --git a/Engine/Vfs/mod_merge_internal.h b/Engine/Vfs/mod_merge_internal.h index e3355ee..9d1f579 100644 --- a/Engine/Vfs/mod_merge_internal.h +++ b/Engine/Vfs/mod_merge_internal.h @@ -39,14 +39,16 @@ struct RelativeFiles { RelativeFiles scan(const fs::path& directory); -// Every mod's archives are concatenated into one file per index, so a patch -// reference keeps the archive number the mod was built for and moves only by -// the byte offset that mod's block landed at. Re-indexing was the alternative, -// and the engine reads a mod's payloads from no index but its own. +// Every mod is built against archive 1, so the merge moves each mod's archives +// to indices of their own and rewrites the mod's patch references to match. A +// placement addresses its payload with a 32-bit offset, so an archive holds at +// most 4 GB: one index per mod archive is what keeps the merged patch from +// having a size limit of its own. struct ArchivePlacement { - // Where a mod's archive ended up: a free index of its own when one is - // available, which costs nothing to place, or a byte offset inside an - // archive it now shares with a higher-priority mod. + // Where a mod's archive ended up: an index of its own, which costs nothing + // to place, or a byte offset inside an archive it shares. Sharing is for a + // mod added while the game runs, which has no declared index to take, and + // for a package directory with no index left. struct Spot { std::uint16_t archive{}; std::uint64_t offset{}; diff --git a/Extension/Customization/local_customization_runtime.cpp b/Extension/Customization/local_customization_runtime.cpp index 1b4e954..66f9eba 100644 --- a/Extension/Customization/local_customization_runtime.cpp +++ b/Extension/Customization/local_customization_runtime.cpp @@ -20,7 +20,8 @@ bool cosmetic_diagnostic(const char* operation, const char* reason, std::string_ // ..._rejected are), so a player's ordinary log says why; the rest are routine. const std::string_view why(reason); const bool routine = why == "callback_entered" || why == "unchanged" || why == "no_saved_loadout" || - why == "card_only_preset_recovered" || why == "card_is_not_skater_preset"; + why == "card_only_preset_recovered" || why == "card_is_not_skater_preset" || + why == "retried_after_catalog_ready"; const dingosdk::Json event{{"event", routine ? "local_cosmetic_loadout_diagnostic" : "local_cosmetic_loadout_rejected"}, {"operation", operation}, {"reason", reason}, {"preset", id}}; dingosdk::logging::event(dingosdk::logging::Channel::customization, event.dump().c_str()); @@ -297,14 +298,11 @@ bool recover_card_only_preset(std::string_view id, const profile::CosmeticLoadou return true; } -bool load_cosmetic_hook(const void* wrapper, void* destination) { +// A load, taking the preset id instead of the game's wrapper so that +// retry_pending_cosmetic_loads can run it again. The caller holds native_mutex. +bool apply_saved_loadout(const std::string& id, void* destination) { auto& s = local_runtime(); - if (!s.active.load(std::memory_order_acquire)) return s.load_cosmetic(wrapper, destination); - PreserveError preserve; - std::lock_guard lock(s.native_mutex); - std::string id; try { - if (!identifier(wrapper, id)) return cosmetic_diagnostic("load", "invalid_preset_id"); const auto saved = s.store->cosmetic_loadout(id); if (!saved) { profile::CosmeticLoadout starter; std::vector native; @@ -324,7 +322,13 @@ bool load_cosmetic_hook(const void* wrapper, void* destination) { profile::CosmeticLoadout defaults; std::vector native; if (!read_cosmetic_recipes(destination, defaults, native)) return cosmetic_diagnostic("load", "invalid_native_recipe", id); if (recover_card_only_preset(id, *saved, defaults)) return false; - if (!refresh_cosmetic_catalog()) return cosmetic_diagnostic("load", "catalog_not_ready", id); + if (!refresh_cosmetic_catalog()) { + // The cosmetics manager is not ready this early in a level load. + // The preset stays blocked, so nothing is written over the outfit + // on disk, and the pump applies this record once it is ready. + c.pending_loads[id] = destination; + return cosmetic_diagnostic("load", "catalog_not_ready", id); + } if (saved->recipes.size() != defaults.recipes.size()) return cosmetic_diagnostic("load", "recipe_count_mismatch", id); const auto snapshot_shared = s.store->shared_snapshot(); const auto& snapshot = *snapshot_shared; @@ -378,6 +382,7 @@ bool load_cosmetic_hook(const void* wrapper, void* destination) { } s.copy_cosmetic(destination, native.data(), native.data() + native.size()); c.blocked_loadouts.erase(id); + c.pending_loads.erase(id); if (held.empty()) c.held_slots.erase(id); else c.held_slots[id] = std::move(held); dingosdk::logging::event(dingosdk::logging::Channel::customization, "{\"event\":\"local_cosmetic_loadout_loaded\"}"); @@ -389,6 +394,45 @@ bool load_cosmetic_hook(const void* wrapper, void* destination) { } } +bool load_cosmetic_hook(const void* wrapper, void* destination) { + auto& s = local_runtime(); + if (!s.active.load(std::memory_order_acquire)) return s.load_cosmetic(wrapper, destination); + PreserveError preserve; + std::lock_guard lock(s.native_mutex); + std::string id; + try { + if (!identifier(wrapper, id)) return cosmetic_diagnostic("load", "invalid_preset_id"); + } catch (...) { + return cosmetic_diagnostic("load", "invalid_preset_id"); + } + return apply_saved_loadout(id, destination); +} + +void retry_pending_cosmetic_loads() { + auto& c = cosmetic_runtime(); + if (c.pending_loads.empty()) return; + auto& s = local_runtime(); + // The records belong to the loadout manager's array, which can be replaced + // between the refused load and now. Check each pointer against the live + // array exactly as a save does, and drop one that no longer belongs rather + // than writing through it. + std::uintptr_t manager{}, begin{}, end{}; + if (!read(s.base + addr::engine::loadout_manager, manager) || !manager || + !read(manager + 0x18, begin) || !read(manager + 0x20, end) || begin > end || + (end - begin) % 0x48 || (end - begin) / 0x48 > 10) return; + auto pending = std::move(c.pending_loads); + c.pending_loads.clear(); + for (const auto& [id, destination] : pending) { + const auto p = reinterpret_cast(destination); + if (!destination || p < begin || p >= end || (p - begin) % 0x48) { + cosmetic_diagnostic("load", "retry_record_moved", id); + continue; + } + if (apply_saved_loadout(id, destination)) + cosmetic_diagnostic("load", "retried_after_catalog_ready", id); + } +} + void save_cosmetic_hook(std::uintptr_t manager, void* record, const char* raw_id, const char* reason, bool changed_only) { auto& s = local_runtime(); if (!s.active.load(std::memory_order_acquire)) { s.save_cosmetic(manager, record, raw_id, reason, changed_only); return; } diff --git a/Extension/Customization/local_customization_runtime.h b/Extension/Customization/local_customization_runtime.h index a5e863b..41cba2d 100644 --- a/Extension/Customization/local_customization_runtime.h +++ b/Extension/Customization/local_customization_runtime.h @@ -16,6 +16,12 @@ struct CosmeticRuntime { std::uint32_t item_count{}; std::map> items; std::set blocked_loadouts; + // Presets whose load was refused only because the cosmetics catalog was not + // ready yet, with the record the game asked us to fill. The game asks once + // per level load and does not ask again, so the pump retries these once the + // catalog is ready. Until one succeeds the preset stays in + // `blocked_loadouts`, so a save cannot write over the outfit on disk. + std::map> pending_loads; // Saved outfit items that were not installed when their preset loaded (a // disabled or removed costume mod). The slot shows its default this session; // saves keep the original item unless the player changes that slot. @@ -106,6 +112,10 @@ bool recover_card_only_preset(std::string_view id, const profile::CosmeticLoadou const profile::CosmeticLoadout& defaults); bool load_cosmetic_hook(const void* wrapper, void* destination); +// Applies the outfits whose load was refused for a catalog that was not ready. +// Call from the customization pump, holding native_mutex, once +// refresh_cosmetic_catalog() is true. +void retry_pending_cosmetic_loads(); void save_cosmetic_hook(std::uintptr_t manager, void* record, const char* raw_id, const char* reason, bool changed_only); } diff --git a/Extension/Profile/local_profile_runtime.cpp b/Extension/Profile/local_profile_runtime.cpp index 6d175e1..bdc3a21 100644 --- a/Extension/Profile/local_profile_runtime.cpp +++ b/Extension/Profile/local_profile_runtime.cpp @@ -303,6 +303,10 @@ void update_local_customization() noexcept { if (!c.catalog_failed && refresh_cosmetic_catalog()) { publish_cosmetic_catalog(); publish_cosmetic_inventory(); + // Outfits the game asked for before the catalog was ready: it does + // not ask again, so they are applied here instead of the player + // finding a standard skater that will not save over itself. + retry_pending_cosmetic_loads(); } } catch (...) { cosmetic_runtime().catalog_failed = true; diff --git a/Launcher/launch.cpp b/Launcher/launch.cpp index 24200d7..7aa81c4 100644 --- a/Launcher/launch.cpp +++ b/Launcher/launch.cpp @@ -423,7 +423,10 @@ RemoteModule inject_dll(HANDLE process, DWORD process_id, const fs::path& dll) { SIZE_T written{}; if (!WriteProcessMemory(process, remote_path.get(), path.c_str(), byte_count, &written) || written != byte_count) win32_failure(L"WriteProcessMemory(ReSkate.dll path)"); - const auto loader = dingosdk::launcher::validated_remote_load_library(process); + std::string hook_note; + const auto loader = dingosdk::launcher::validated_remote_load_library(process, &hook_note); + if (!hook_note.empty()) + dingosdk::logging::write(dingosdk::logging::Level::warning, dingosdk::logging::Channel::launcher, hook_note); std::wostringstream address; address << L"Starting ordinary LoadLibraryW injection at 0x" << std::hex << loader; dingosdk::logging::write(dingosdk::logging::Level::info, dingosdk::logging::Channel::launcher, address.str()); @@ -648,21 +651,30 @@ DWORD start_game(const Session& session, const launcher::LaunchOptions& options, logging::log(logging::Level::warning, logging::Channel::world, "World layers could not be read: {}", error.what()); } configure_environment(paths.logs); - const bool loose_files = options.loose_files && initfs::loose_files_preference(paths.directory); - set_environment(L"RESKATE_LOOSE_FILES", loose_files ? L"1" : L"0"); + bool loose_files = options.loose_files && initfs::loose_files_preference(paths.directory); if (loose_files) { - fs::create_directories(paths.directory / L"scripts" / L"Custom"); - const auto exported = initfs::export_files(paths.game, - initfs::data_directory(paths.directory, options.game_arguments), paths.directory, true); - if (exported.already_exported) { - logging::write(logging::Level::info, logging::Channel::assets, - "Using existing scripts/ and config/ export; edits and removed files are preserved."); - } else { - logging::log(logging::Level::info, logging::Channel::assets, - "InitFS exported: {} Lua, {} configs; {} created, {} existing files preserved, {} skipped.", - exported.scripts, exported.configs, exported.created, exported.preserved, exported.skipped); + try { + fs::create_directories(paths.directory / L"scripts" / L"Custom"); + const auto exported = initfs::export_files(paths.game, + initfs::data_directory(paths.directory, options.game_arguments), paths.directory, true); + if (exported.already_exported) { + logging::write(logging::Level::info, logging::Channel::assets, + "Using existing scripts/ and config/ export; edits and removed files are preserved."); + } else { + logging::log(logging::Level::info, logging::Channel::assets, + "InitFS exported: {} Lua, {} configs; {} created, {} existing files preserved, {} skipped.", + exported.scripts, exported.configs, exported.created, exported.preserved, exported.skipped); + } + } catch (const std::exception& failure) { + // An install under Program Files is the usual reason. Skate plays + // without loose scripts; only editing them is lost. + loose_files = false; + logging::log(logging::Level::warning, logging::Channel::assets, + "Loose files are off for this launch: scripts/ and config/ could not be exported ({}). Skate still " + "starts; editing them needs a folder ReSkate is allowed to write to.", failure.what()); } } + set_environment(L"RESKATE_LOOSE_FILES", loose_files ? L"1" : L"0"); set_environment(L"RESKATE_LOG_CONSOLE", options.window_console ? L"1" : L"0"); set_environment(L"RESKATE_LOG_LEVEL", widen(options.log_level).c_str()); set_environment(L"RESKATE_FORCE_WINDOWED", options.force_windowed ? L"1" : L"0"); diff --git a/Launcher/problem.h b/Launcher/problem.h index c572ac0..ab093bc 100644 --- a/Launcher/problem.h +++ b/Launcher/problem.h @@ -69,6 +69,23 @@ inline Problem explain(std::string_view raw) { if (mentions(raw, "need different keys")) return {"The menu and console share a key", "Open Settings > KEYS and pick a different key for one of them."}; + // ERROR_ELEVATION_REQUIRED. Windows localises the text, so match the + // number win32_failure puts in front of it. + if (mentions(raw, "failed (740)")) + return {"Skate.exe is set to always run as administrator", + "ReSkate has to start Skate itself, and Windows does not let it start a program marked to " + "need administrator. Right-click Skate.exe, pick Properties, then Compatibility, and untick " + "\"Run this program as an administrator\". Starting ReSkateLauncher.exe as " + "administrator works too."}; + if (mentions(raw, "LoadLibraryW is hooked")) { + std::string advice = "Security software or an overlay is modifying Skate while it starts, and ReSkate will " + "not load itself through that. Allow ReSkateLauncher.exe and Skate.exe in it, or close " + "it, then press RETRY."; + // The launcher named what it found in Skate; that is the useful half. + if (const auto found = raw.find("Loaded into Skate"); found != std::string_view::npos) + advice += " " + std::string(raw.substr(found)); + return {"Something is hooking Skate as it starts", std::move(advice)}; + } if (mentions(raw, "Skate closed while it was starting")) return {"Skate closed while it was starting", std::string(attach_advice)}; if (mentions(raw, "timed out after")) @@ -79,6 +96,16 @@ inline Problem explain(std::string_view raw) { mentions(raw, "module list") || mentions(raw, "VirtualAllocEx") || mentions(raw, "NtQueryInformationProcess") || mentions(raw, "DingoSDKDebugInitialize") || mentions(raw, "Remote ")) return {"ReSkate could not attach to Skate", std::string(attach_advice)}; + // Windows reports a refused write in the user's own language, so these + // key on the operation names and the path, which are never translated. + const bool write_failure = mentions(raw, "create_directories") || mentions(raw, "create_directory") || + mentions(raw, "Cannot write") || mentions(raw, "Cannot create") || + mentions(raw, "Cannot publish") || mentions(raw, "Cannot append") || + mentions(raw, "Cannot replace"); + if (write_failure) + return {"ReSkate cannot write to its own folder", + "Move the whole ReSkate folder somewhere else, such as C:\\Games\\ReSkate, and start it from " + "there. Program Files and OneDrive do not let it write."}; return {std::string(raw), "Press RETRY. If it keeps happening, copy the details below and share them with logs\\ReSkate.log " "on the ReSkate Discord."}; From f815658bf1833ef0580fc87ea1758bfd91ddb361 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sat, 3 Oct 2026 19:21:51 -0500 Subject: [PATCH 3/3] Draw the launcher on the best GPU; hide the CoreGameAssets layer A blank launcher window (Launcher/gui_renderer.cpp, Launcher/gui.cpp) Reported as a launcher that showed nothing, from someone with a GT 1030. The log named the card it had used: Intel HD Graphics 4600, the onboard chip beside it. - D3D12CreateDevice(nullptr, ...) takes whichever adapter DXGI lists first, which on a PC with onboard graphics beside a real card is the onboard one. It now walks EnumAdapterByGpuPreference with HIGH_PERFORMANCE, skips software adapters and takes the first that gives a device, as a game would. The old default adapter and WARP remain behind it, so no working setup loses its device. - That chip's Direct3D 12 driver cannot build a pipeline, and the ImGui backend creates one on the first frame while ignoring whether it worked (imgui_impl_dx12.cpp:792). Every draw then does nothing and the window shows only ClearRenderTargetView's colour, which is near black. Untextured shapes sample the font atlas's white pixel, so a failed atlas looks identical -- the drawn background disappears with the text. The pipeline and font texture are built in init now and checked, and a failure says to update the driver and that the card may be below what ReSkate and skate. need. - None of this was diagnosable, because the renderer logged nothing at all. It now records the adapter, its memory and whether it is the software fallback; the window size, scale, system DPI and work area; the font atlas size and whether it built; and the first frame drawn. Those four lines are what turned "blank window, healthy log" into the two bugs above, and they separate a missing device from a missing pipeline from a window that draws and is still black. World layers (Engine/Vfs/world_layer_scan.cpp) - CoreGameAssets had a row like any other, and switching it off took the world away with no clue which row had done it. essential_layer keeps its node, so its children are still found and still switchable, and drops its row -- which removes it from the menu, from "world off" and from layer sync at once, rather than hiding it in one place and leaving it reachable from another. - Choices are stored by row key, not by index, so no saved setting shifts, and an "off" already stored for it has no row to apply to: a profile that turned it off fixes itself on the next launch. - The cache stamp goes to 2, so a cache written before this is rebuilt instead of keeping the row alive on existing installs. - The scan test asserted one row per node, which this breaks on purpose. It now checks the counts differ, that no row's key holds "coregameassets", and that the node is still there. Against a real install: 262 rows to 261, six maps, passing. --- Engine/Vfs/Test/world_layer_scan_tests.cpp | 13 ++++- Engine/Vfs/world_layer_scan.cpp | 12 +++- Launcher/gui.cpp | 20 ++++++- Launcher/gui_renderer.cpp | 68 ++++++++++++++++++++-- 4 files changed, 106 insertions(+), 7 deletions(-) diff --git a/Engine/Vfs/Test/world_layer_scan_tests.cpp b/Engine/Vfs/Test/world_layer_scan_tests.cpp index 0d43456..3d69f05 100644 --- a/Engine/Vfs/Test/world_layer_scan_tests.cpp +++ b/Engine/Vfs/Test/world_layer_scan_tests.cpp @@ -21,7 +21,8 @@ int main(int argc, char** argv) { const std::filesystem::path root = argv[1]; const auto catalog = world_layer_scan::scan(root); check(catalog.anchors.size() == 6, "one anchor per map"); - check(catalog.nodes.size() >= 200 && catalog.layers.size() == catalog.nodes.size(), "one row per layer"); + check(catalog.nodes.size() >= 200 && catalog.layers.size() < catalog.nodes.size() && + catalog.layers.size() + 8 > catalog.nodes.size(), "a row per layer but the essential ones"); for (std::size_t i = 0; i < catalog.nodes.size(); ++i) { const auto parent = catalog.nodes[i].parent; check(parent >= -1 && parent < static_cast(i), "parents come first"); @@ -31,6 +32,16 @@ int main(int argc, char** argv) { }; for (const auto* key : {"bam_tod_1_morning", "bam_tod_7_weathernight", "grom_tod_5_night", "bam_debug_tests"}) check(has(key), key); + // No row for what every map is built out of: a player cannot switch off + // the world and then wonder which row did it. + check(std::none_of(catalog.layers.begin(), catalog.layers.end(), [](const WorldLayer& layer) { + return layer.key.find("coregameassets") != std::string::npos; + }), "CoreGameAssets has no row to switch"); + check(std::any_of(catalog.nodes.begin(), catalog.nodes.end(), [](const WorldLayerNode& node) { + std::string bundle = node.bundle; + std::transform(bundle.begin(), bundle.end(), bundle.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return bundle.find("coregameassets") != std::string::npos; + }), "but it keeps its node, so its children are still reachable"); check(std::none_of(catalog.nodes.begin(), catalog.nodes.end(), [](const WorldLayerNode& node) { std::string bundle = node.bundle; std::transform(bundle.begin(), bundle.end(), bundle.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); diff --git a/Engine/Vfs/world_layer_scan.cpp b/Engine/Vfs/world_layer_scan.cpp index a019c1d..11ed8a4 100644 --- a/Engine/Vfs/world_layer_scan.cpp +++ b/Engine/Vfs/world_layer_scan.cpp @@ -78,6 +78,15 @@ std::vector read_references(const fs::path& gameRoot, const char* toc return result; } +// Layers there is no sense in switching off. They stay in the node tree, so +// their children are still found and still switchable, but they get no row of +// their own: CoreGameAssets holds what every map is built out of, and a player +// who turned it off lost the world with no clue which row had done it. A +// choice already stored for one is keyed by row, so it simply stops applying. +bool essential_layer(std::string_view bundle) { + return contains(leaf_of(lower(bundle)), "coregameassets"); +} + std::string category(std::string_view bundle) { const auto path = lower(bundle); const auto leaf = leaf_of(path); @@ -143,6 +152,7 @@ void add_map(WorldLayerCatalog& catalog, WorldMap map, const std::vector 1 ? slug(node.bundle) : slug(leaf)); auto label = leaf; @@ -154,7 +164,7 @@ void add_map(WorldLayerCatalog& catalog, WorldMap map, const std::vector #include @@ -221,6 +223,12 @@ Fonts load_fonts() { fonts.action = font(L"FONT_HEADING", L"seguibl.ttf", 50); fonts.title = font(L"FONT_BRUSH", L"seguibl.ttf", 86); io.FontDefault = fonts.body; + // Built here rather than on the first frame, so the log says whether it + // worked. A window with no atlas draws nothing but its clear colour. + const bool built = io.Fonts->Build(); + logging::log(built ? logging::Level::info : logging::Level::error, logging::Channel::launcher, + "Launcher fonts: atlas {}x{} {}.", io.Fonts->TexWidth, io.Fonts->TexHeight, + built ? "built" : "COULD NOT BE BUILT, so the window will be empty"); return fonts; } @@ -313,6 +321,9 @@ int run(const launcher_app::Session& session, const std::vector& a const int width = static_cast(S(design_width)); const int height = static_cast(S(design_height)); + logging::log(logging::Level::info, logging::Channel::launcher, + "Launcher window: {}x{} at scale {:.2f} (system DPI {}), work area {}x{}.", width, height, g_scale, + GetDpiForSystem(), work.right - work.left, work.bottom - work.top); const HWND window = CreateWindowExW(WS_EX_APPWINDOW, window_class.lpszClassName, L"ReSkate", WS_POPUP | WS_MINIMIZEBOX | WS_SYSMENU, work.left + (work.right - work.left - width) / 2, work.top + (work.bottom - work.top - height) / 2, width, height, nullptr, nullptr, instance, nullptr); @@ -334,7 +345,9 @@ int run(const launcher_app::Session& session, const std::vector& a ImGui_ImplWin32_Shutdown(); ImGui::DestroyContext(); DestroyWindow(window); - throw std::runtime_error("Cannot start Direct3D 12 for the launcher window"); + throw std::runtime_error("This PC's graphics driver cannot draw the launcher's window. Update your " + "graphics driver and try again. If that does not help, the graphics card is below what ReSkate and " + "skate. need. The log names the card it tried to use."); } g_renderer = &renderer; { @@ -376,6 +389,7 @@ int run(const launcher_app::Session& session, const std::vector& a HANDLE game{}; DWORD game_id{}; bool hidden{}; + bool drew{}; while (running) { MSG message; while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) { @@ -423,6 +437,10 @@ int run(const launcher_app::Session& session, const std::vector& a frame(launcher, fonts, window, ui, mods_panel); ImGui::Render(); renderer.render(); + if (!drew) { + drew = true; + logging::write(logging::Level::info, logging::Channel::launcher, "Launcher drew its first frame."); + } } ShowWindow(window, SW_HIDE); if (game) CloseHandle(game); diff --git a/Launcher/gui_renderer.cpp b/Launcher/gui_renderer.cpp index a43c31f..e3214cc 100644 --- a/Launcher/gui_renderer.cpp +++ b/Launcher/gui_renderer.cpp @@ -1,5 +1,9 @@ #include "gui_renderer.h" +#include "Engine/Core/Log/logging.h" + +#include + #include #include @@ -13,11 +17,52 @@ namespace dingosdk::launcher_gui::detail { bool Renderer::init(HWND window) { UINT factory_flags = 0; ComPtr factory; - if (FAILED(CreateDXGIFactory2(factory_flags, IID_PPV_ARGS(&factory)))) return false; - if (FAILED(D3D12CreateDevice(nullptr, D3D_FEATURE_LEVEL_11_0, IID_PPV_ARGS(&device_)))) { + if (FAILED(CreateDXGIFactory2(factory_flags, IID_PPV_ARGS(&factory)))) { + logging::write(logging::Level::error, logging::Channel::launcher, "Launcher renderer: no DXGI factory."); + return false; + } + bool software = false; + // Ask for the best card rather than whichever DXGI lists first. Where + // onboard graphics sit beside a real card, the default adapter is usually + // the onboard one, and its Direct3D 12 driver can be too old to draw the + // window at all while the card that would have worked goes unused. + if (ComPtr ranked; SUCCEEDED(factory.As(&ranked))) { + for (UINT index = 0; !device_; ++index) { + ComPtr candidate; + if (FAILED(ranked->EnumAdapterByGpuPreference(index, DXGI_GPU_PREFERENCE_HIGH_PERFORMANCE, + IID_PPV_ARGS(&candidate)))) break; + DXGI_ADAPTER_DESC1 description{}; + // WARP is the last resort below, not a candidate here. + if (FAILED(candidate->GetDesc1(&description)) || (description.Flags & DXGI_ADAPTER_FLAG_SOFTWARE)) + continue; + D3D12CreateDevice(candidate.Get(), D3D_FEATURE_LEVEL_11_0, IID_PPV_ARGS(&device_)); + } + } + if (!device_ && FAILED(D3D12CreateDevice(nullptr, D3D_FEATURE_LEVEL_11_0, IID_PPV_ARGS(&device_)))) { ComPtr warp; if (FAILED(factory->EnumWarpAdapter(IID_PPV_ARGS(&warp))) || - FAILED(D3D12CreateDevice(warp.Get(), D3D_FEATURE_LEVEL_11_0, IID_PPV_ARGS(&device_)))) return false; + FAILED(D3D12CreateDevice(warp.Get(), D3D_FEATURE_LEVEL_11_0, IID_PPV_ARGS(&device_)))) { + logging::write(logging::Level::error, logging::Channel::launcher, + "Launcher renderer: no Direct3D 12 device, not even the software one."); + return false; + } + software = true; + } + { + // Which card drew the window, so a blank one can be told apart from a + // driver problem, a software fallback or a remote session. + DXGI_ADAPTER_DESC1 description{}; + ComPtr adapter; + if (SUCCEEDED(factory->EnumAdapterByLuid(device_->GetAdapterLuid(), IID_PPV_ARGS(&adapter))) && + SUCCEEDED(adapter->GetDesc1(&description))) + logging::write(logging::Level::info, logging::Channel::launcher, + std::wstring(L"Launcher renderer: ") + description.Description + + (software ? L" (software fallback)" : L"") + L", " + + std::to_wstring(description.DedicatedVideoMemory / (1024 * 1024)) + L" MB dedicated"); + else + logging::write(logging::Level::warning, logging::Channel::launcher, + software ? "Launcher renderer: software fallback, adapter unidentified." + : "Launcher renderer: adapter unidentified."); } D3D12_DESCRIPTOR_HEAP_DESC rtv_desc{D3D12_DESCRIPTOR_HEAP_TYPE_RTV, frame_count, D3D12_DESCRIPTOR_HEAP_FLAG_NONE, 1}; D3D12_DESCRIPTOR_HEAP_DESC srv_desc{D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV, 1 + max_textures, @@ -67,7 +112,22 @@ bool Renderer::init(HWND window) { info.SrvDescriptorHeap = srv_heap_.Get(); info.LegacySingleSrvCpuDescriptor = srv_heap_->GetCPUDescriptorHandleForHeapStart(); info.LegacySingleSrvGpuDescriptor = srv_heap_->GetGPUDescriptorHandleForHeapStart(); - return ImGui_ImplDX12_Init(&info); + if (!ImGui_ImplDX12_Init(&info)) { + logging::write(logging::Level::error, logging::Channel::launcher, + "Launcher renderer: the Direct3D 12 backend did not start."); + return false; + } + // The backend builds its pipeline and font texture on the first frame and + // ignores whether that worked, so a driver that cannot make one draws + // nothing at all and the window shows only its clear colour -- a black + // window, with a log that says everything succeeded. Build it here, where + // the answer can be acted on. + if (!ImGui_ImplDX12_CreateDeviceObjects()) { + logging::write(logging::Level::error, logging::Channel::launcher, + "Launcher renderer: this driver cannot create the Direct3D 12 pipeline the window draws with."); + return false; + } + return true; } void Renderer::render() {