fix: anchor Oodle lookup and write portable release ZIPs

This commit is contained in:
Frumpy9 committed 2026-10-12 17:14:21 -04:00
1 parent 22758a789e
commit d0985ffcfd
8 files changed
+338 -1

No files matched your search

+26
View File
@@ -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;
}
}
+10
View File
@@ -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;
}
+4 -1
View File
@@ -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);
+33
View File
@@ -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.
+9
View File
@@ -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)
+90
View File
@@ -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()
+73
View File
@@ -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()
+93
View File
@@ -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()