mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 15:07:06 +02:00
fix: anchor Oodle lookup and write portable release ZIPs
This commit is contained in:
1 parent
22758a789e
commit
d0985ffcfd
8 files changed
+338
-1
No files matched your search
@@ -0,0 +1,26 @@
|
||||
// A test codec exercises DLL discovery without the game's proprietary Oodle DLL.
|
||||
#include "Engine/Resource/cas_codec.h"
|
||||
#include <Windows.h>
|
||||
#include <array>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
int wmain(int argc, wchar_t** argv) {
|
||||
try {
|
||||
if (argc != 2) throw std::runtime_error("Pass the directory containing the test Oodle DLL");
|
||||
if (GetModuleHandleW(L"oo2core_9_win64.dll"))
|
||||
throw std::runtime_error("The fixture must exercise discovery before Oodle is loaded");
|
||||
// One byte in an Oodle CAS block. The fixture codec returns that byte unchanged.
|
||||
constexpr std::array encoded{
|
||||
std::byte{0}, std::byte{0}, std::byte{0}, std::byte{1},
|
||||
std::byte{0x11}, std::byte{0}, std::byte{0}, std::byte{1}, std::byte{0x2a}};
|
||||
const auto decoded = dingosdk::frostbite::decode_cas(encoded, {std::filesystem::path(argv[1])});
|
||||
if (decoded.size() != 1 || decoded.front() != std::byte{0x2a})
|
||||
throw std::runtime_error("The test codec did not decode the CAS byte");
|
||||
std::cout << "Root Oodle fixture loaded and CAS byte decoded\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Test-only DLL: never package this as the game's actual codec.
|
||||
#include <cstdint>
|
||||
|
||||
extern "C" __declspec(dllexport) std::int64_t __cdecl OodleLZ_Decompress(
|
||||
const void* input, std::int64_t inputSize, void* output, std::int64_t outputSize,
|
||||
int, int, int, void*, std::int64_t, void*, void*, void*, std::int64_t, int) {
|
||||
if (!input || !output || inputSize != 1 || outputSize != 1) return -1;
|
||||
*static_cast<unsigned char*>(output) = *static_cast<const unsigned char*>(input);
|
||||
return 1;
|
||||
}
|
||||
@@ -63,7 +63,10 @@ HMODULE oodle(const std::filesystem::path& gameRoot) {
|
||||
if (error || !entry.is_regular_file(error)) { error.clear(); continue; }
|
||||
auto name = entry.path().filename().wstring();
|
||||
std::ranges::transform(name, name.begin(), ::towlower);
|
||||
if (name.starts_with(L"oo2core_") && name.ends_with(L"_win64.dll")) matches.push_back(entry.path());
|
||||
// Under Proton, a Unix filename containing '\\' can leave extra directories
|
||||
// in MSVC's iterator path. Keep the candidate anchored to the original root.
|
||||
if (name.starts_with(L"oo2core_") && name.ends_with(L"_win64.dll"))
|
||||
matches.push_back(gameRoot / entry.path().filename());
|
||||
}
|
||||
if (matches.empty()) throw std::runtime_error("The game's Oodle codec was not found");
|
||||
std::ranges::sort(matches);
|
||||
|
||||
@@ -243,6 +243,39 @@ ctest --test-dir build/vs2022-x64 -C Release
|
||||
|
||||
The project builds with `/W4 /WX`: warnings are errors.
|
||||
|
||||
### Packaging release ZIPs
|
||||
|
||||
Stage the complete release contents, including a real `licenses/` directory, then create the ZIP
|
||||
with Python 3.9 or newer (no extra packages):
|
||||
|
||||
```sh
|
||||
python3 contrib/package_release_zip.py path/to/staged-release path/to/ReSkate-release.zip
|
||||
python3 contrib/package_release_zip.py --check path/to/ReSkate-release.zip
|
||||
```
|
||||
|
||||
The output must be outside the staging directory and must not already exist. The packager stores
|
||||
forward slashes on Windows as well as Linux, checks the completed ZIP, and rejects unsafe entries
|
||||
and symlinks. Use `--check` on the actual upload artifact before publishing: backslashes in ZIP entry
|
||||
names can become literal Unix filenames and break Oodle lookup under Proton
|
||||
([issue 124](https://github.com/Dingo-Shenanigans/ReSkate/issues/124)). These commands do not change
|
||||
the staged binaries or an installed game. Run the packaging tests with
|
||||
`python3 -m unittest discover -s contrib/Test -p '*_tests.py'`.
|
||||
|
||||
The `oodle_path` CTest (with `DINGOSDK_BUILD_LAUNCHER_TESTS=ON` on Windows) loads a test-only
|
||||
codec DLL and decodes a CAS byte without game files. To reproduce the literal-backslash bug on
|
||||
Proton, build that test once with the original `cas_codec.cpp` and once with the fix, then run:
|
||||
|
||||
```sh
|
||||
python3 contrib/Test/run_oodle_proton_test.py --wine path/to/Proton/files/bin/wine \
|
||||
--baseline path/to/original/dingosdk_oodle_path_tests.exe \
|
||||
--patched path/to/patched/dingosdk_oodle_path_tests.exe \
|
||||
--codec path/to/test-fixture/oo2core_9_win64.dll
|
||||
```
|
||||
|
||||
This creates a temporary fixture and an isolated Wine prefix, requires the original repeated-path
|
||||
failure, and checks that the patched loader works both before and after normalizing the filenames.
|
||||
It never uses the installed game or its DLL. **Never ship the test codec DLL as a game/release file.**
|
||||
|
||||
## Project layout
|
||||
|
||||
The source tree loosely follows Frostbite's own layout.
|
||||
|
||||
@@ -13,6 +13,15 @@ endif()
|
||||
option(DINGOSDK_BUILD_LAUNCHER_TESTS "Build launcher mod manager regression tests" OFF)
|
||||
if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32)
|
||||
enable_testing()
|
||||
add_library(dingosdk_test_oodle_codec SHARED Engine/Resource/Test/oodle_test_codec.cpp)
|
||||
set_target_properties(dingosdk_test_oodle_codec PROPERTIES
|
||||
OUTPUT_NAME "oo2core_9_win64" PREFIX ""
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/oodle-fixture/$<CONFIG>")
|
||||
add_executable(dingosdk_oodle_path_tests Engine/Resource/Test/oodle_path_tests.cpp)
|
||||
target_link_libraries(dingosdk_oodle_path_tests PRIVATE dingosdk_frostbite)
|
||||
target_include_directories(dingosdk_oodle_path_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
add_dependencies(dingosdk_oodle_path_tests dingosdk_test_oodle_codec)
|
||||
add_test(NAME oodle_path COMMAND dingosdk_oodle_path_tests "$<TARGET_FILE_DIR:dingosdk_test_oodle_codec>")
|
||||
add_executable(dingosdk_mod_manager_tests Launcher/Test/mod_manager_tests.cpp Launcher/mod_manager.cpp)
|
||||
target_link_libraries(dingosdk_mod_manager_tests PRIVATE dingosdk_mod_list dingosdk_launcher_support dingosdk_miniz shell32)
|
||||
add_test(NAME launcher_mod_manager COMMAND dingosdk_mod_manager_tests)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
script = Path(__file__).resolve().parents[1] / "package_release_zip.py"
|
||||
spec = importlib.util.spec_from_file_location("package_release_zip", script)
|
||||
release = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(release)
|
||||
|
||||
|
||||
class ReleaseZipTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="release paths ")
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
self.source = self.root / "staged release"
|
||||
(self.source / "licenses").mkdir(parents=True)
|
||||
(self.source / "licenses/imgui-LICENSE.txt").write_bytes(b"license contents\n")
|
||||
(self.source / "ReSkate.dll").write_bytes(b"fixture binary")
|
||||
self.output = self.root / "release.zip"
|
||||
|
||||
def test_nested_files_extract_to_directories_with_contents_preserved(self):
|
||||
self.assertEqual(release.package(self.source, self.output), 2)
|
||||
release.check_archive(self.output)
|
||||
with zipfile.ZipFile(self.output) as archive:
|
||||
self.assertIn("licenses/imgui-LICENSE.txt", archive.namelist())
|
||||
self.assertFalse(any("\\" in name for name in archive.namelist()))
|
||||
archive.extractall(self.root / "extracted")
|
||||
self.assertEqual((self.root / "extracted/licenses/imgui-LICENSE.txt").read_bytes(), b"license contents\n")
|
||||
self.assertEqual((self.root / "extracted/ReSkate.dll").read_bytes(), b"fixture binary")
|
||||
|
||||
def test_backslash_archive_is_rejected_without_changes(self):
|
||||
with zipfile.ZipFile(self.output, "w") as archive:
|
||||
archive.writestr("licenses\\imgui-LICENSE.txt", b"license contents\n")
|
||||
before = self.output.read_bytes()
|
||||
with self.assertRaises(ValueError):
|
||||
release.check_archive(self.output)
|
||||
self.assertEqual(self.output.read_bytes(), before)
|
||||
|
||||
def test_unsafe_and_duplicate_archive_names_are_rejected(self):
|
||||
for names in [("../escape.txt",), ("/absolute.txt",), ("C:/drive.txt",),
|
||||
("licenses//file.txt",), ("file.txt", "FILE.TXT")]:
|
||||
with self.subTest(names=names):
|
||||
with zipfile.ZipFile(self.output, "w") as archive:
|
||||
for name in names:
|
||||
archive.writestr(name, b"fixture")
|
||||
with self.assertRaises(ValueError):
|
||||
release.check_archive(self.output)
|
||||
|
||||
def test_symlink_archive_and_staging_are_refused(self):
|
||||
entry = zipfile.ZipInfo("linked.txt")
|
||||
entry.create_system = 3
|
||||
entry.external_attr = 0o120777 << 16
|
||||
with zipfile.ZipFile(self.output, "w") as archive:
|
||||
archive.writestr(entry, "outside.txt")
|
||||
with self.assertRaises(ValueError):
|
||||
release.check_archive(self.output)
|
||||
self.output.unlink()
|
||||
try:
|
||||
(self.source / "link").symlink_to(self.root / "missing")
|
||||
except OSError:
|
||||
self.skipTest("Creating symlinks is unavailable on this host")
|
||||
with self.assertRaises(ValueError):
|
||||
release.package(self.source, self.output)
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_existing_output_is_preserved(self):
|
||||
self.output.write_bytes(b"existing release")
|
||||
with self.assertRaises(FileExistsError):
|
||||
release.package(self.source, self.output)
|
||||
self.assertEqual(self.output.read_bytes(), b"existing release")
|
||||
|
||||
def test_output_inside_staging_is_refused(self):
|
||||
with self.assertRaises(ValueError):
|
||||
release.package(self.source, self.source / "release.zip")
|
||||
self.assertFalse((self.source / "release.zip").exists())
|
||||
|
||||
def test_malformed_unix_staging_name_is_refused(self):
|
||||
if "\\" == __import__("os").sep:
|
||||
self.skipTest("Literal backslash filenames require a Unix filesystem")
|
||||
(self.source / "licenses\\lz4-LICENSE.txt").write_bytes(b"malformed name")
|
||||
with self.assertRaises(ValueError):
|
||||
release.package(self.source, self.output)
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare baseline/patched Windows codec tests in an isolated Proton Wine prefix."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--wine", required=True, type=Path, help="wine executable from an installed Proton")
|
||||
parser.add_argument("--baseline", required=True, type=Path, help="oodle_path test built with the original cas_codec.cpp")
|
||||
parser.add_argument("--patched", required=True, type=Path, help="oodle_path test built with the patched cas_codec.cpp")
|
||||
parser.add_argument("--codec", required=True, type=Path, help="test-only oo2core_9_win64.dll fixture, never the game's DLL")
|
||||
args = parser.parse_args()
|
||||
if os.name != "posix":
|
||||
parser.error("The malformed-filename reproduction requires a Unix host")
|
||||
for path in (args.wine, args.baseline, args.patched, args.codec):
|
||||
if not path.is_file():
|
||||
parser.error(f"Missing fixture executable/library: {path}")
|
||||
scratch = Path(tempfile.mkdtemp(prefix="reskate-oodle-proton-"))
|
||||
root = scratch / "Game root"
|
||||
root.mkdir()
|
||||
libraries = ["bcdec", "detours", "imgui", "lz4", "miniz", "Montserrat",
|
||||
"PermanentMarker", "rapidjson", "spdlog", "sqlite", "steam_networking", "zstd"]
|
||||
malformed = []
|
||||
for library in libraries:
|
||||
path = root / ("licenses\\" + library + "-LICENSE.txt")
|
||||
path.write_text("Test filename only\n", encoding="utf-8")
|
||||
malformed.append(path)
|
||||
shutil.copy2(args.codec, root / "oo2core_9_win64.dll")
|
||||
environment = os.environ.copy()
|
||||
environment.update(WINEPREFIX=str(scratch / "prefix"), WINEARCH="win64", WINEDEBUG="-all",
|
||||
WINEDLLOVERRIDES="mscoree,mshtml=")
|
||||
game_path = "Z:" + str(root).replace("/", "\\")
|
||||
results = []
|
||||
|
||||
def run(label, executable):
|
||||
process = subprocess.run([str(args.wine.resolve()), str(executable.resolve()), game_path],
|
||||
env=environment, text=True, capture_output=True, timeout=120)
|
||||
row = {"test": label, "returncode": process.returncode,
|
||||
"stdout": process.stdout, "stderr": process.stderr}
|
||||
results.append(row)
|
||||
(scratch / "results.json").write_text(json.dumps(results, indent=2) + "\n", encoding="utf-8")
|
||||
return row
|
||||
|
||||
print(f"Isolated fixture and results: {scratch}")
|
||||
original_bad = run("baseline with malformed names", args.baseline)
|
||||
if (original_bad["returncode"] == 0 or "Cannot load " not in original_bad["stderr"] or
|
||||
"licenses\\licenses" not in original_bad["stderr"]):
|
||||
parser.exit(1, "The original repeated-path failure was not reproduced; inspect results.json.\n")
|
||||
print("PASS: original loader reproduces the repeated-license-path error")
|
||||
fixed_bad = run("patched with malformed names", args.patched)
|
||||
if fixed_bad["returncode"] != 0 or "CAS byte decoded" not in fixed_bad["stdout"]:
|
||||
parser.exit(1, "Patched loader failed on malformed names; inspect results.json.\n")
|
||||
print("PASS: patched loader loads the root fixture and decodes with malformed names present")
|
||||
(root / "licenses").mkdir()
|
||||
for path in malformed:
|
||||
path.rename(root / "licenses" / path.name.split("\\", 1)[1])
|
||||
for label, executable in [("baseline with normalized names", args.baseline),
|
||||
("patched with normalized names", args.patched)]:
|
||||
result = run(label, executable)
|
||||
if result["returncode"] != 0 or "CAS byte decoded" not in result["stdout"]:
|
||||
parser.exit(1, f"{label} failed; inspect results.json.\n")
|
||||
print(f"PASS: {label}")
|
||||
print("No installed game, real Oodle DLL, mod, save, setting, or existing Wine prefix was used or changed.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create or check a release ZIP with portable, forward-slash entry names."""
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
|
||||
def check_name(name):
|
||||
parts = name.rstrip("/").split("/")
|
||||
if "\\" in name or ":" in name or any(part in ("", ".", "..") for part in parts):
|
||||
raise ValueError(f"Non-portable ZIP entry: {name!r}")
|
||||
|
||||
|
||||
def check_archive(path):
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
seen = set()
|
||||
for entry in archive.infolist():
|
||||
check_name(entry.filename)
|
||||
key = entry.filename.rstrip("/").casefold()
|
||||
if key in seen or stat.S_ISLNK(entry.external_attr >> 16):
|
||||
raise ValueError(f"Duplicate or symlink ZIP entry: {entry.filename!r}")
|
||||
seen.add(key)
|
||||
damaged = archive.testzip()
|
||||
if damaged is not None:
|
||||
raise ValueError(f"Damaged ZIP entry: {damaged!r}")
|
||||
|
||||
|
||||
def package(source, output):
|
||||
source = Path(source).resolve(strict=True)
|
||||
output = Path(output).absolute()
|
||||
if not source.is_dir():
|
||||
raise ValueError("The staged release must be a directory")
|
||||
if output.exists() or output.is_symlink():
|
||||
raise FileExistsError(f"Archive already exists: {output}")
|
||||
if output.resolve().is_relative_to(source):
|
||||
raise ValueError("Write the archive outside the staged release directory")
|
||||
files = []
|
||||
for path in sorted(source.rglob("*")):
|
||||
if path.is_symlink():
|
||||
raise ValueError(f"Symlinks are not release files: {path}")
|
||||
if path.is_dir():
|
||||
continue
|
||||
if not path.is_file():
|
||||
raise ValueError(f"Not a regular release file: {path}")
|
||||
name = path.relative_to(source).as_posix()
|
||||
check_name(name)
|
||||
files.append((path, name))
|
||||
if not files:
|
||||
raise ValueError("The staged release is empty")
|
||||
# Validate the completed archive before exposing the requested output name.
|
||||
with tempfile.TemporaryDirectory(prefix="reskate-release-", dir=output.parent) as temporary:
|
||||
staged = Path(temporary) / "release.zip"
|
||||
with zipfile.ZipFile(staged, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for path, name in files:
|
||||
archive.write(path, arcname=name)
|
||||
check_archive(staged)
|
||||
created = False
|
||||
try:
|
||||
with output.open("xb") as target, staged.open("rb") as ready:
|
||||
created = True
|
||||
shutil.copyfileobj(ready, target)
|
||||
except BaseException:
|
||||
if created:
|
||||
output.unlink()
|
||||
raise
|
||||
return len(files)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("paths", nargs="*", metavar="PATH", help="STAGED_FOLDER OUTPUT.zip")
|
||||
parser.add_argument("--check", type=Path, metavar="ARCHIVE.zip", help="Validate an existing ZIP without changing it")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.check is not None:
|
||||
if args.paths:
|
||||
parser.error("--check accepts no staging/output paths")
|
||||
check_archive(args.check)
|
||||
print(f"Portable release ZIP: {args.check}")
|
||||
else:
|
||||
if len(args.paths) != 2:
|
||||
parser.error("Provide STAGED_FOLDER OUTPUT.zip, or --check ARCHIVE.zip")
|
||||
count = package(*args.paths)
|
||||
print(f"Packaged {count} file(s): {args.paths[1]}")
|
||||
except (OSError, ValueError, zipfile.BadZipFile) as error:
|
||||
parser.exit(1, f"Release ZIP refused: {error}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user