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 1955a0b..079aa37 100644 --- a/Engine/Core/Platform/launcher_support.h +++ b/Engine/Core/Platform/launcher_support.h @@ -136,7 +136,10 @@ 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); #else // Linux: virtual-key names and loader injection are Windows-only. inline constexpr unsigned default_menu_key = 0x2D; 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/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/game_archives.cpp b/Engine/Vfs/game_archives.cpp index 68eccc3..0eca63f 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 / "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 f77a1f7..073ff76 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_ / "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.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 e4f56e9..0bf6898 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 @@ -21,10 +22,15 @@ 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()))) - 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_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/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 47eed84..dd369c2 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 @@ -77,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); @@ -142,6 +152,7 @@ void add_map(WorldLayerCatalog& catalog, WorldMap map, const std::vector 1 ? slug(node.bundle) : slug(leaf)); auto label = leaf; @@ -153,7 +164,7 @@ void add_map(WorldLayerCatalog& catalog, WorldMap map, const std::vector(input), std::istreambuf_iterator()}; return from_json(Json::parse(text.begin(), text.end())); } 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/gui.cpp b/Launcher/gui.cpp index a1ddf12..f59db34 100644 --- a/Launcher/gui.cpp +++ b/Launcher/gui.cpp @@ -3,6 +3,8 @@ #include "gui_internal.h" #include "gui_renderer.h" +#include "Engine/Core/Log/logging.h" + #include #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() { 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."}; 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);