diff --git a/.gitattributes b/.gitattributes index 6f9a4f80..0ca84c5a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,9 +1,11 @@ -# Preserve the catalog and packaged Linux runtime byte for byte on checkout. +# Preserve the catalog and packaged runtimes byte for byte on checkout. /data/** -text -diff /bin/** -text -diff /lib/** -text -diff +/mods/** -text /data/INDEX diff /data/hd/hd.json diff # The launcher must retain Unix line endings. *.sh text eol=lf +*.cmd text eol=crlf diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 00000000..76f4fd7f --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,76 @@ +name: Windows build and validation + +on: + workflow_dispatch: + push: + paths: + - "src/**" + - "tests/**" + - "mods/**" + - "data/**" + - "art/**" + - "Cargo.toml" + - "Cargo.lock" + - "run.cmd" + - "runtime-manifest.json" + - ".gitattributes" + - "tools/assemble_windows.py" + - "tools/assemble_runtime.py" + - ".github/workflows/windows.yml" + +permissions: + contents: read + +jobs: + windows: + runs-on: windows-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install the pinned Rust toolchain + run: | + rustup toolchain install 1.99.0 --profile minimal + rustup default 1.99.0 + - name: Download and verify the official SDL2 SDK + shell: pwsh + run: | + $url = "https://github.com/libsdl-org/SDL/releases/download/release-2.32.10/SDL2-devel-2.32.10-VC.zip" + Invoke-WebRequest $url -OutFile "$env:RUNNER_TEMP\SDL2.zip" + $expected = "af347939395a58b365846aaea27391e69f9ec9d4dd650d6ac40802159b418a6e" + if ((Get-FileHash "$env:RUNNER_TEMP\SDL2.zip" -Algorithm SHA256).Hash.ToLower() -ne $expected) { + throw "SDL2 SDK checksum mismatch" + } + Expand-Archive "$env:RUNNER_TEMP\SDL2.zip" "$env:RUNNER_TEMP\sdl2" + - name: Run portable simulation, mod and Windows save tests + run: cargo test --locked --no-default-features + - name: Build and package Windows desktop + run: python tools/assemble_windows.py --target x86_64-pc-windows-msvc --sdl2-dir "$env:RUNNER_TEMP\sdl2\SDL2-2.32.10" --zip dist/RustRaptor-windows-x86_64.zip + - name: Validate packaged data, HD artwork, mods and SDL gameplay + shell: pwsh + run: | + $portable = "$env:RUNNER_TEMP\Portable Windows Ω folder" + Expand-Archive "$env:GITHUB_WORKSPACE\dist\RustRaptor-windows-x86_64.zip" $portable + $package = "$portable\RustRaptor" + $exe = "$package\bin\raptor-rust.exe" + Set-Location $env:RUNNER_TEMP + & "$package\run.cmd" --verify-assets + if ($LASTEXITCODE -ne 0) { throw "Catalog validation failed" } + & $exe --validate-hd + if ($LASTEXITCODE -ne 0) { throw "HD validation failed" } + $mods = (Get-ChildItem "$package\mods" -Directory | Where-Object { Test-Path "$($_.FullName)\mod.toml" } | ForEach-Object { $_.Name }) -join "," + & $exe --mods "$package\mods" --profile-root "$env:RUNNER_TEMP\raptor-profile" --validate-mods $mods + if ($LASTEXITCODE -ne 0) { throw "Mod validation failed" } + $env:SDL_VIDEODRIVER = "dummy" + $env:SDL_AUDIODRIVER = "dummy" + & $exe --profile-root "$env:RUNNER_TEMP\raptor-profile" --smoke-frames 32 --preview-mission base:sector_1_wave_1 --screenshot "$env:GITHUB_WORKSPACE\dist\windows-gameplay.png" + if ($LASTEXITCODE -ne 0) { throw "Windows SDL gameplay failed" } + - uses: actions/upload-artifact@v4 + with: + name: RustRaptor-windows-x86_64 + path: | + dist/RustRaptor-windows-x86_64.zip + dist/windows-gameplay.png + if-no-files-found: error diff --git a/README.md b/README.md index de4c1234..cfbf3cbd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # RustRaptor -The independent Linux Rust game and its complete local mod packages live here. +The independent Linux and Windows Rust game and its complete local mod packages live here. SDL2 handles platform services; gameplay, asset decoding, rendering, audio, saving and mod scripting run in Rust. @@ -16,6 +16,9 @@ and the bundled `mods/`. `./run.sh` launches that executable without Cargo or the old engine; paths remain valid after moving the folder. For development, the launcher falls back to Cargo when no packaged executable is present. `--data DIR` selects another catalog. Some optional comparison tests use retained source. +On Windows, double-click `run.cmd`; it starts the included +`bin/raptor-rust.exe` with its adjacent SDL2 DLL and the same data and mods. +See [Windows builds and packaging](docs/windows-build.md). Refresh the executable and copied original/approved-HD data with `python3 tools/assemble_runtime.py`. Add `--zip dist/RustRaptor-linux-x86_64.zip` @@ -33,8 +36,9 @@ weapon mods. Enable them in Mods and buy them in the supply room. The cannon is a selected special that chains through nearby targets; the shield fires with your primary guns while Fire is held. Both ship SD/HD icons and sounds. For a user mods folder, pass `--mods DIR` or create -`$XDG_DATA_HOME/RaptorReplica/mods/`. Saves and configuration use separate XDG -`RaptorReplica` directories, with read-only legacy import. +`$XDG_DATA_HOME/RaptorReplica/mods/` on Linux or +`%APPDATA%\RaptorReplica\mods\` on Windows. Saves and configuration use separate +user profile directories, with read-only legacy import. Enter the **Hangar** to fly, choose missions, visit the supply room, manage equipment or **Save pilot**. New pilots have editable names, callsigns and ID @@ -61,7 +65,7 @@ Existing untouched Rust default bindings migrate to these controls. | `tests/` | Portable fixtures and opt-in original-data checks | | `tools/` | Replay comparator, content-table generator and asset reference tools | | `docs/` | API, artwork, build and validation documentation | -| `bin/`, `data/`, `lib/` | Local standalone runtime and any bundled SDL2 library | +| `bin/`, `data/`, `lib/` | Included Linux/Windows executables, shared data and SDL2 runtimes | | `target/` | Local Cargo builds, excluded from Git | The bundled executable, libraries, original game catalog and approved HD assets diff --git a/bin/SDL2-WINDOWS-LICENSE.txt b/bin/SDL2-WINDOWS-LICENSE.txt new file mode 100644 index 00000000..23abb73f --- /dev/null +++ b/bin/SDL2-WINDOWS-LICENSE.txt @@ -0,0 +1,18 @@ +Copyright (C) 1997-2025 Sam Lantinga + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. + diff --git a/bin/SDL2.dll b/bin/SDL2.dll new file mode 100755 index 00000000..1bba76f6 Binary files /dev/null and b/bin/SDL2.dll differ diff --git a/bin/raptor-rust b/bin/raptor-rust index 09c01819..58b5e40b 100755 Binary files a/bin/raptor-rust and b/bin/raptor-rust differ diff --git a/bin/raptor-rust.exe b/bin/raptor-rust.exe new file mode 100755 index 00000000..a72ed3d0 Binary files /dev/null and b/bin/raptor-rust.exe differ diff --git a/docs/modding-api.md b/docs/modding-api.md index 3323abbc..d9853b0d 100644 --- a/docs/modding-api.md +++ b/docs/modding-api.md @@ -52,7 +52,7 @@ common_tools = "^1.2" IDs use lowercase ASCII letters, digits, `_` and `-`, with a maximum of 64 bytes. `base` is reserved. Package versions and dependency requirements use semantic version syntax. The in-game manager selects compatible installed dependencies when enabling a package. It never downloads or installs missing packages. The CLI still requires a complete explicit `--enabled` set. Cycles, missing packages, unsupported API versions, incompatible dependency versions and declared conflicts reject activation. Two installed folders claiming the same ID make that ID unusable; rename or remove one folder, then rescan. -All package paths are relative. Absolute paths, parent traversal, symlinks and special files are rejected. Packages are limited to 10,000 files, 16 directory levels, 64 MiB per file and 256 MiB in total. Manifests are limited to 64 KiB; display names to 128 bytes; descriptions to 4 KiB; script declarations to 64, with a 1 MiB limit per source file. Every file, including artwork and documentation, contributes to the package SHA-256 hash. A package changed after discovery requires rescanning before it can be applied. +All package paths are relative. Absolute paths, parent traversal, symlinks and special files are rejected. Filenames must be UTF-8 and cannot contain literal backslashes or colons. Hashes use forward-slash paths and UTF-8 component ordering on both Linux and Windows, preserving existing Linux save pins. Keep the original file bytes when copying packages between systems; the repository disables line-ending conversion for mods. Packages are limited to 10,000 files, 16 directory levels, 64 MiB per file and 256 MiB in total. Manifests are limited to 64 KiB; display names to 128 bytes; descriptions to 4 KiB; script declarations to 64, with a 1 MiB limit per source file. Every file, including artwork and documentation, contributes to the package SHA-256 hash. A package changed after discovery requires rescanning before it can be applied. ## Content definitions and overrides diff --git a/docs/repository.md b/docs/repository.md index 5a561266..1343f41f 100644 --- a/docs/repository.md +++ b/docs/repository.md @@ -5,11 +5,12 @@ Cargo commands from this folder. The public remote is [Quintine/RustRaptor](https://github.com/Quintine/RustRaptor), on branch `codex/rust-rewrite`. -The repository includes the Linux executable in `bin/`, its bundled libraries -in `lib/`, the complete original game catalog and approved HD snapshot in +The repository includes Linux and Windows executables in `bin/`, the Windows +SDL2 DLL beside its executable, Linux libraries in `lib/`, and the complete +original game catalog and approved HD snapshot in `data/`, and `runtime-manifest.json`. All authored mod definitions, scripts, SD/HD artwork, sounds and music are included. A fresh checkout can launch the -bundled game with `./run.sh` without compiling it. +bundled game with `./run.sh` on Linux or `run.cmd` on Windows without compiling it. Build output in `target/`, local review evidence in `.reference/`, generation source references and duplicate zip exports in `dist/` are ignored. Working diff --git a/docs/resume-checkpoint.md b/docs/resume-checkpoint.md index 8f017d10..113a502a 100644 --- a/docs/resume-checkpoint.md +++ b/docs/resume-checkpoint.md @@ -24,9 +24,18 @@ the relocation. See [repository layout](repository.md). ## Engine and package +- Windows support is now included: `bin/raptor-rust.exe`, adjacent `SDL2.dll`, + `run.cmd`, `runtime-windows-manifest.json` and the Windows export tool/CI. + The GNU executable SHA-256 is + `e414d2df745369115082439414099f528aca8457f9c58d1c1745eb1b511fc484`. + All 279 portable tests pass under Wine, including Windows save replacement; + all 22 mod hashes match existing Linux save pins. Windows paths respect + APPDATA/LOCALAPPDATA and mods preserve exact bytes through Git checkouts. + See [Windows build and validation](windows-build.md). The tracked catalog + remains the 340-frame reviewed snapshot while broader HD review continues. - Packaged executable now includes the `c0a6fd3` reviewed source checkpoint, including diagnostics navigation and interpolated Lightning effects. Its - SHA-256 is `4b164987d9fabf7388d2fc66168a9f2aedc71392d42a6ce4066e3607684b1479`. + SHA-256 is `4b4304f625381cbbf1331e126c62ca0913d49a52e7a35191f12e734067660e66`. The original `fdf2ff0` checkpoint supplied standalone RustRaptor, widescreen menus and always-4:3 original cinematics, including when gameplay stretching is selected. Fresh 1080p intro capture confirms black 240-pixel side borders. diff --git a/docs/standalone-package.md b/docs/standalone-package.md index 04be7d96..4165c3b6 100644 --- a/docs/standalone-package.md +++ b/docs/standalone-package.md @@ -1,9 +1,9 @@ -# Standalone Linux package +# Standalone game packages `RustRaptor/` contains the Rust executable, its original data catalog, approved HD replacements, authored mods, SD/HD mod artwork, sound effects and music. Normal play needs no C++ engine, Cargo, Python or source checkout. Run -`./run.sh`; installed pilots and configuration remain in the user's separate +`./run.sh` on Linux or `run.cmd` on Windows; installed pilots and configuration remain in the user's separate `RaptorReplica` profile directories. The executable finds its package from `bin/raptor-rust`, including when launched @@ -27,8 +27,10 @@ dependencies. Unapproved HD drafts, reference screenshots and the Cargo build cache are excluded from the zip. The exported `RustRaptor` folder can be moved or unpacked anywhere, including paths containing spaces. -The binary targets Linux x86_64. It still uses the operating system's C runtime -and desktop/audio drivers; this package is not a Windows or macOS executable. +The Linux binary targets Linux x86_64 and uses the operating system's C runtime +and desktop/audio drivers. The included Windows x86_64 executable and SDL2 DLL +use the same data and mods. See [Windows packaging](windows-build.md) +for its separate launcher, build tool, manifest, zip and validation. Source, Cargo.lock, code licenses and mod API documentation are included. The packaged executable, runtime libraries, original catalog and approved HD artwork are tracked in Git, along with the assembler and authored mod images diff --git a/docs/windows-build.md b/docs/windows-build.md new file mode 100644 index 00000000..4d2878ad --- /dev/null +++ b/docs/windows-build.md @@ -0,0 +1,70 @@ +# Windows build + +The repository includes `bin/raptor-rust.exe`, `bin/SDL2.dll` and `run.cmd`. +Double-click `run.cmd` after extracting the entire `RustRaptor` folder. +Windows and Linux share the same `data/`, `art/` and `mods/` directories; +the Windows launcher needs no Rust, Python, SDL installation or old engine. +Use a 64-bit Windows 10 or Windows 11 machine. + +Windows pilots, settings and user mods live in `%APPDATA%\RaptorReplica`. +Derived artwork and audio caches use `%LOCALAPPDATA%\RaptorReplica\cache`. +Redirected folders are respected; `%USERPROFILE%\AppData` is a fallback. +Legacy import reads `%APPDATA%\Raptor`. Moving the game leaves profiles intact. + +`runtime-windows-manifest.json` records executable, DLL, license and launcher +hashes and checked PE dependencies. The shared `runtime-manifest.json` +records every original/approved-HD data-file hash. The Windows exporter +excludes Linux libraries, build caches, local toolchains, review drafts and Git. + +## Rebuild + +The included build uses Rust 1.99.0, `x86_64-pc-windows-gnu` and the official +SDL2 2.32.10 MinGW SDK. Native Windows CI builds the same engine with MSVC and +statically links its C runtime. + +On Linux with Rust's Windows target and MinGW-w64 installed: + +```sh +rustup target add x86_64-pc-windows-gnu +python3 tools/assemble_windows.py \ + --sdl2-dir /path/to/SDL2-2.32.10 \ + --linker x86_64-w64-mingw32-gcc \ + --zip dist/RustRaptor-windows-x86_64.zip +``` + +Download the SDK from the [official SDL2 release](https://github.com/libsdl-org/SDL/releases/tag/release-2.32.10). +The MinGW tarball SHA256 is +`83a5d74012311edc3c0d40ea6faecbe57ad692aa033fa5dc273cc937e3938ff2`. +Rust's [Windows GNU target documentation](https://doc.rust-lang.org/rustc/platform-support/windows-gnu.html) +describes cross-toolchain requirements. + +On Windows with Rust and Visual Studio C++ build tools: + +```powershell +python tools/assemble_windows.py --target x86_64-pc-windows-msvc ` + --sdl2-dir C:\SDKs\SDL2-2.32.10 ` + --zip dist\RustRaptor-windows-x86_64.zip +``` + +Use the Visual C SDK for that command. Its official zip SHA256 is +`af347939395a58b365846aaea27391e69f9ec9d4dd650d6ac40802159b418a6e`. +The assembler checks architecture, SDL import, complete data hashes and DLL +dependencies. Supply `--extra-dll FILE` if another compiler introduces another +runtime dependency. Optional `--rustc` and `--sysroot` support an isolated +matching cross toolchain without changing the system Rust installation. + +The [Windows workflow](../.github/workflows/windows.yml) runs simulation, +modding and save tests on Windows, validates packaged assets and all local +mods, captures SDL gameplay and publishes the zip as a workflow artifact. + +## Validation checkpoint: 11 October 2026 + +The included GNU executable passed 279 portable tests under Wine 11.19, +including Windows save replacement and preserving a locked existing save. +All 22 installed mod hashes match their previous Linux hashes exactly. +The shared catalog validates 6,449 assets and the current packaged HD snapshot +validates all 340 supplied frames. The exporter verifies all 6,593 shared +data-file hashes and includes only checked Windows DLL dependencies. + +Wine checks exercise the Windows executable from Linux; native Windows +builds and runtime checks are also configured in the workflow above. diff --git a/run.cmd b/run.cmd new file mode 100644 index 00000000..7dbffcf8 --- /dev/null +++ b/run.cmd @@ -0,0 +1,5 @@ +@echo off +setlocal +cd /d "%~dp0" +"%~dp0bin\raptor-rust.exe" %* +exit /b %errorlevel% diff --git a/runtime-manifest.json b/runtime-manifest.json index 96c8933e..391be780 100644 --- a/runtime-manifest.json +++ b/runtime-manifest.json @@ -3,7 +3,7 @@ "platform": "Linux x86_64", "catalog_entries": 6449, "approved_hd_frames": 340, - "binary_sha256": "4b164987d9fabf7388d2fc66168a9f2aedc71392d42a6ce4066e3607684b1479", + "binary_sha256": "4b4304f625381cbbf1331e126c62ca0913d49a52e7a35191f12e734067660e66", "data_files": 6593, "data_bytes": 195469368, "files": { @@ -6602,11 +6602,11 @@ "data/ui/WINGAME_SWD": "304630a1223419d69723902ffd22c2c4eea0388491e37dfca663fcbe78e99aff" }, "system_dependencies": [ - "\tlinux-vdso.so.1 (0x00007f23a012d000)", - "\tlibSDL2-2.0.so.0 => /home/quintine/.local/lib/libSDL2-2.0.so.0 (0x00007f239f400000)", - "\tlibgcc_s.so.1 => /usr/lib/libgcc_s.so.1 (0x00007f23a00d9000)", - "\tlibm.so.6 => /usr/lib/libm.so.6 (0x00007f239f6c9000)", - "\tlibc.so.6 => /usr/lib/libc.so.6 (0x00007f239f000000)", - "\t/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007f23a012f000)" + "\tlinux-vdso.so.1 (0x00007fcb5dcd3000)", + "\tlibSDL2-2.0.so.0 => /home/quintine/.local/lib/libSDL2-2.0.so.0 (0x00007fcb5ce00000)", + "\tlibgcc_s.so.1 => /usr/lib/libgcc_s.so.1 (0x00007fcb5dc7f000)", + "\tlibm.so.6 => /usr/lib/libm.so.6 (0x00007fcb5db48000)", + "\tlibc.so.6 => /usr/lib/libc.so.6 (0x00007fcb5ca00000)", + "\t/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007fcb5dcd5000)" ] } diff --git a/runtime-windows-manifest.json b/runtime-windows-manifest.json new file mode 100644 index 00000000..693349e8 --- /dev/null +++ b/runtime-windows-manifest.json @@ -0,0 +1,47 @@ +{ + "format_version": 1, + "platform": "Windows x86_64", + "target": "x86_64-pc-windows-gnu", + "catalog_entries": 6449, + "approved_hd_frames": 340, + "shared_data_manifest": "runtime-manifest.json", + "dependencies": { + "raptor-rust.exe": [ + "api-ms-win-core-synch-l1-2-0.dll", + "api-ms-win-crt-environment-l1-1-0.dll", + "api-ms-win-crt-heap-l1-1-0.dll", + "api-ms-win-crt-locale-l1-1-0.dll", + "api-ms-win-crt-math-l1-1-0.dll", + "api-ms-win-crt-private-l1-1-0.dll", + "api-ms-win-crt-runtime-l1-1-0.dll", + "api-ms-win-crt-stdio-l1-1-0.dll", + "api-ms-win-crt-string-l1-1-0.dll", + "bcryptprimitives.dll", + "kernel32.dll", + "ntdll.dll", + "sdl2.dll" + ], + "SDL2.dll": [ + "advapi32.dll", + "gdi32.dll", + "imm32.dll", + "kernel32.dll", + "msvcrt.dll", + "ole32.dll", + "oleaut32.dll", + "setupapi.dll", + "shell32.dll", + "user32.dll", + "version.dll", + "winmm.dll" + ] + }, + "files": { + "bin/raptor-rust.exe": "e414d2df745369115082439414099f528aca8457f9c58d1c1745eb1b511fc484", + "bin/SDL2.dll": "53e8fa7e9ed43c30bd650c39396f0d9d2067d2fff0b926c9433b75202592e4aa", + "bin/SDL2-WINDOWS-LICENSE.txt": "97f35b302b361680ec1e891e95d2d52097bb95abff361434916d99dc1305f127", + "run.cmd": "b007382d9ed3560191856514a9ebd4e429801fc25428840e304bb297f3553269" + }, + "launcher": "run.cmd", + "validation": "PE imports and complete shared data hashes; see docs/windows-build.md" +} diff --git a/src/modding.rs b/src/modding.rs index 5822a87a..5e05cf14 100644 --- a/src/modding.rs +++ b/src/modding.rs @@ -20,7 +20,7 @@ use sha2::{Digest, Sha256}; use std::{ collections::{BTreeMap, BTreeSet}, fs, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, }; #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] @@ -108,6 +108,25 @@ fn read_package_file(root: &Path, relative: &Path) -> Result> { ); Ok(data) } +fn package_hash_name(relative: &Path) -> Result { + let mut parts = Vec::new(); + for component in relative.components() { + let Component::Normal(name) = component else { + anyhow::bail!("package hash paths must contain only relative filename components"); + }; + let name = name.to_str().context("package filenames must be UTF-8")?; + // A literal backslash is a Unix filename but a Windows separator; + // a colon can address a Windows drive or alternate data stream. Do not + // turn either into an alias when generating portable hash keys. + ensure!( + !name.contains(['\\', ':']), + "package filenames contain an ambiguous Windows separator or colon: {name}" + ); + parts.push(name); + } + ensure!(!parts.is_empty(), "empty package hash path"); + Ok(parts.join("/")) +} fn load_package(path: &Path) -> Result { ensure!( fs::symlink_metadata(path)?.is_dir(), @@ -117,7 +136,10 @@ fn load_package(path: &Path) -> Result { let mut files = Vec::new(); let mut bytes = 0; walk(&root, &root, &mut files, &mut bytes, 0)?; - files.sort(); + // Compare UTF-8 component strings on every platform. Whole-path string + // ordering would place art-qa.json before art/... and invalidate existing + // Linux saves, whose v1 hashes used PathBuf's component ordering. + files.sort_by(|(a, _), (b, _)| a.split('/').cmp(b.split('/'))); let manifest_data = read_package_file(&root, Path::new("mod.toml"))?; ensure!( manifest_data.len() <= 64 * 1024, @@ -174,10 +196,7 @@ fn load_package(path: &Path) -> Result { } let mut hash = Sha256::new(); hash.update(b"raptor-mod-package-v1\0"); - for relative in files { - let name = relative - .to_str() - .context("package filenames must be UTF-8")?; + for (name, relative) in files { let data = read_package_file(&root, &relative)?; hash.update((name.len() as u64).to_le_bytes()); hash.update(name.as_bytes()); @@ -250,7 +269,7 @@ pub fn discover(root: &Path) -> Discovery { fn walk( root: &Path, current: &Path, - files: &mut Vec, + files: &mut Vec<(String, PathBuf)>, bytes: &mut u64, depth: usize, ) -> Result<()> { @@ -261,6 +280,7 @@ fn walk( for e in fs::read_dir(current)? { let e = e?; let relative = e.path().strip_prefix(root)?.to_path_buf(); + let name = package_hash_name(&relative)?; ensure!( !e.file_type()?.is_symlink(), "mod packages may not contain symlinks" @@ -280,7 +300,7 @@ fn walk( .checked_add(e.metadata()?.len()) .context("package size overflow")?; ensure!(*bytes <= MAX_PACKAGE_BYTES, "mod package exceeds 256 MiB"); - files.push(relative); + files.push((name, relative)); ensure!(files.len() <= MAX_FILES, "mod package has too many files"); } } diff --git a/src/persistence.rs b/src/persistence.rs index 8f8efdfe..6214a547 100644 --- a/src/persistence.rs +++ b/src/persistence.rs @@ -20,9 +20,16 @@ pub struct Paths { } impl Paths { pub fn discover() -> Result { - let home = PathBuf::from(std::env::var_os("HOME").context("HOME is unset")?); - let dir = |name: &str, fallback: &str| { - std::env::var_os(name) + #[cfg(windows)] + return Self::windows_from_env(|name| std::env::var_os(name)); + #[cfg(not(windows))] + Self::unix_from_env(|name| std::env::var_os(name)) + } + #[cfg(any(not(windows), test))] + fn unix_from_env(mut env: impl FnMut(&str) -> Option) -> Result { + let home = PathBuf::from(env("HOME").context("HOME is unset")?); + let mut dir = |name: &str, fallback: &str| { + env(name) .map(PathBuf::from) .unwrap_or_else(|| home.join(fallback)) }; @@ -33,6 +40,31 @@ impl Paths { legacy: home.join(".local/share/Raptor"), }) } + #[cfg(any(windows, test))] + fn windows_from_env(mut env: impl FnMut(&str) -> Option) -> Result { + let mut dir = |name: &str| { + env(name) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + }; + let profile = dir("USERPROFILE"); + // Respect redirected application-data folders. A normal Windows login + // provides these variables; USERPROFILE also supports stripped-down + // launch environments without relying on Unix HOME or XDG variables. + let roaming = dir("APPDATA") + .or_else(|| profile.as_ref().map(|p| p.join("AppData/Roaming"))) + .context("APPDATA and USERPROFILE are unset; cannot locate Windows saves")?; + let local = dir("LOCALAPPDATA") + .or_else(|| profile.as_ref().map(|p| p.join("AppData/Local"))) + .context("LOCALAPPDATA and USERPROFILE are unset; cannot locate Windows cache")?; + Ok(Self { + data: roaming.join("RaptorReplica"), + config: roaming.join("RaptorReplica"), + cache: local.join("RaptorReplica/cache"), + // The legacy SDL_GetPrefPath("", "Raptor") location on Windows. + legacy: roaming.join("Raptor"), + }) + } pub fn mods(&self) -> PathBuf { self.data.join("mods") } @@ -452,7 +484,15 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<()> { let result = (|| -> Result<()> { file.write_all(data)?; file.sync_all()?; - fs::rename(&temp, path)?; + // Release the temporary handle before replacement. rename replaces an + // existing destination on Windows as well as Unix; never remove the + // previous save first or retry by copying over it. + drop(file); + fs::rename(&temp, path) + .with_context(|| format!("cannot atomically replace {}", path.display()))?; + // Directory handles opened by ordinary File::open cannot be synced on + // Windows. The file itself was synced before its name was committed. + #[cfg(unix)] fs::File::open(parent)?.sync_all()?; Ok(()) })(); @@ -1167,6 +1207,112 @@ fn decrypt(bytes: &[u8], key: &[u8]) -> Vec { #[cfg(test)] mod tests { use super::*; + fn environment(values: &[(&str, &str)]) -> impl FnMut(&str) -> Option { + let values = values + .iter() + .map(|(key, value)| (key.to_string(), std::ffi::OsString::from(value))) + .collect::>(); + move |key| values.get(key).cloned() + } + #[test] + fn windows_paths_respect_redirected_data_folders_without_home() { + let paths = Paths::windows_from_env(environment(&[ + ("APPDATA", "redirected-roaming"), + ("LOCALAPPDATA", "redirected-local"), + ("HOME", "unused-unix-home"), + ("XDG_DATA_HOME", "unused-xdg-data"), + ("XDG_CONFIG_HOME", "unused-xdg-config"), + ("XDG_CACHE_HOME", "unused-xdg-cache"), + ])) + .unwrap(); + let roaming = Path::new("redirected-roaming"); + assert_eq!(paths.data, roaming.join("RaptorReplica")); + assert_eq!(paths.config, roaming.join("RaptorReplica")); + assert_eq!(paths.mods(), roaming.join("RaptorReplica/mods")); + assert_eq!( + paths.save_path(3), + roaming.join("RaptorReplica/pilot-0003.json") + ); + assert_eq!(paths.legacy, roaming.join("Raptor")); + assert_eq!( + paths.cache, + Path::new("redirected-local").join("RaptorReplica/cache") + ); + } + #[test] + fn windows_paths_fall_back_to_unicode_user_profile() { + let profile = "profile-Quintine-é-雪"; + let paths = Paths::windows_from_env(environment(&[("USERPROFILE", profile)])).unwrap(); + let profile = Path::new(profile); + assert_eq!(paths.data, profile.join("AppData/Roaming/RaptorReplica")); + assert_eq!(paths.config, paths.data); + assert_eq!( + paths.cache, + profile.join("AppData/Local/RaptorReplica/cache") + ); + assert_eq!(paths.legacy, profile.join("AppData/Roaming/Raptor")); + } + #[test] + fn windows_missing_or_empty_application_paths_fall_back_independently() { + let paths = Paths::windows_from_env(environment(&[ + ("USERPROFILE", "profile"), + ("APPDATA", "roaming-redirect"), + ("LOCALAPPDATA", ""), + ])) + .unwrap(); + assert_eq!( + paths.data, + Path::new("roaming-redirect").join("RaptorReplica") + ); + assert_eq!( + paths.cache, + Path::new("profile").join("AppData/Local/RaptorReplica/cache") + ); + let paths = Paths::windows_from_env(environment(&[ + ("USERPROFILE", "profile"), + ("APPDATA", ""), + ("LOCALAPPDATA", "local-redirect"), + ])) + .unwrap(); + assert_eq!( + paths.data, + Path::new("profile").join("AppData/Roaming/RaptorReplica") + ); + assert_eq!( + paths.cache, + Path::new("local-redirect").join("RaptorReplica/cache") + ); + } + #[test] + fn windows_missing_profile_has_an_actionable_error() { + let error = Paths::windows_from_env(environment(&[("HOME", "unix-home")])).unwrap_err(); + assert!(error.to_string().contains("APPDATA and USERPROFILE")); + let error = Paths::windows_from_env(environment(&[("APPDATA", "roaming")])).unwrap_err(); + assert!(error.to_string().contains("LOCALAPPDATA and USERPROFILE")); + } + #[test] + fn unix_paths_keep_home_and_xdg_behavior() { + let paths = Paths::unix_from_env(environment(&[("HOME", "unix-home")])).unwrap(); + let home = Path::new("unix-home"); + assert_eq!(paths.data, home.join(".local/share/RaptorReplica")); + assert_eq!(paths.config, home.join(".config/RaptorReplica")); + assert_eq!(paths.cache, home.join(".cache/RaptorReplica")); + assert_eq!(paths.legacy, home.join(".local/share/Raptor")); + let paths = Paths::unix_from_env(environment(&[ + ("HOME", "unix-home"), + ("XDG_DATA_HOME", "custom-data"), + ("XDG_CONFIG_HOME", "custom-config"), + ("XDG_CACHE_HOME", "custom-cache"), + ])) + .unwrap(); + assert_eq!(paths.data, Path::new("custom-data").join("RaptorReplica")); + assert_eq!( + paths.config, + Path::new("custom-config").join("RaptorReplica") + ); + assert_eq!(paths.cache, Path::new("custom-cache").join("RaptorReplica")); + assert!(Paths::unix_from_env(environment(&[("APPDATA", "roaming")])).is_err()); + } #[test] fn writes_replace_atomically() { let tmp = tempfile::tempdir().unwrap(); @@ -1176,4 +1322,53 @@ mod tests { assert_eq!(fs::read(p).unwrap(), b"second"); assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 1); } + #[test] + fn replacement_failure_preserves_the_destination_and_cleans_the_temporary() { + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path().join("save.json"); + fs::create_dir(&p).unwrap(); + let sentinel = p.join("keep-original"); + fs::write(&sentinel, b"original contents").unwrap(); + let error = atomic_write(&p, b"new save").unwrap_err(); + assert!(error.to_string().contains("cannot atomically replace")); + assert_eq!(fs::read(sentinel).unwrap(), b"original contents"); + assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 1); + } + #[test] + fn temporary_name_collision_and_unicode_backup_are_preserved() { + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path().join("pilot-é-雪.json"); + let stale = tmp + .path() + .join(format!(".pilot-é-雪.json.{}.0.tmp", std::process::id())); + fs::write(&stale, b"interrupted write").unwrap(); + atomic_write(&p, b"original pilot").unwrap(); + let backup = tmp.path().join("pilot-é-雪.original.json"); + atomic_write(&backup, &fs::read(&p).unwrap()).unwrap(); + atomic_write(&p, b"updated pilot").unwrap(); + assert_eq!(fs::read(&p).unwrap(), b"updated pilot"); + assert_eq!(fs::read(&backup).unwrap(), b"original pilot"); + assert_eq!(fs::read(&stale).unwrap(), b"interrupted write"); + assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 3); + } + #[cfg(windows)] + #[test] + fn locked_windows_save_is_never_deleted_before_replacement() { + use std::os::windows::fs::OpenOptionsExt; + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path().join("save.json"); + atomic_write(&p, b"original pilot").unwrap(); + let locked = OpenOptions::new() + .read(true) + .share_mode(0) + .open(&p) + .unwrap(); + assert!(atomic_write(&p, b"updated pilot").is_err()); + assert!(p.exists()); + assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 1); + drop(locked); + assert_eq!(fs::read(&p).unwrap(), b"original pilot"); + atomic_write(&p, b"updated pilot").unwrap(); + assert_eq!(fs::read(&p).unwrap(), b"updated pilot"); + } } diff --git a/tests/modding.rs b/tests/modding.rs index 6c93bfc8..9d38aa9a 100644 --- a/tests/modding.rs +++ b/tests/modding.rs @@ -203,6 +203,61 @@ fn package_hash_covers_added_assets_and_manifest_changes() { assert!(error(ActiveMods::build(&base(), &d, &ids(&["alpha"]), None)).contains("rescan")); } +#[test] +fn nested_package_hash_is_portable_and_preserves_existing_linux_order() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("fixture"); + // Intentionally independent of the TOML serializer and native path spelling. + // The fixed digest uses UTF-8 component ordering (art/... before art-qa.json), + // forward slash names, little-endian u64 byte lengths and these exact bytes. + // The BMP/supplementary names also distinguish UTF-8 from UTF-16 ordering. + let files: &[(&str, &[u8])] = &[ + ("art/deep/hull.bin", b"\x00\x7f\xff"), + ("art/hull-é.png", b"synthetic PNG bytes\r\n"), + ("art/\u{e000}.bin", b"bmp filename"), + ("art/\u{10000}.bin", b"supplementary filename"), + ("art-qa.json", b"{\"ok\":true}\n"), + ("content.json", b"{}"), + ("mod.toml", b"id = \"fixture\"\nname = \"Portable fixture\"\nversion = \"1.0.0\"\napi_version = 1\ncontent = \"content.json\"\nscripts = [\"scripts/weapon.rhai\"]\n"), + ("scripts/weapon.rhai", b"fn on_tick(event, state, random) { #{ commands: [], state: state } }\n"), + ]; + // Reverse creation order to ensure directory enumeration cannot set the hash. + for (name, data) in files.iter().rev() { + let path = root.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, data).unwrap(); + } + let discovered = modding::discover(temp.path()); + assert!(discovered.errors.is_empty(), "{:?}", discovered.errors); + assert_eq!( + discovered.mods["fixture"].hash, + "1c851b694b601dc9658d0bbbc6a6edd2d5359571c5b558cf0c3f076afa08cf3d" + ); +} + +#[cfg(unix)] +#[test] +fn ambiguous_windows_filename_components_and_non_utf8_names_are_rejected() { + use std::os::unix::ffi::OsStringExt; + for filename in ["art\\hull.bin", "hull.bin:extra"] { + let temp = tempfile::tempdir().unwrap(); + install(temp.path(), "alpha", "alpha", json!({})); + fs::write(temp.path().join("alpha").join(filename), b"ambiguous asset").unwrap(); + let discovered = modding::discover(temp.path()); + assert!(discovered.mods.is_empty()); + assert_eq!(discovered.errors.len(), 1); + assert!(discovered.errors[0].contains("ambiguous Windows")); + } + let temp = tempfile::tempdir().unwrap(); + install(temp.path(), "alpha", "alpha", json!({})); + let filename = std::ffi::OsString::from_vec(b"asset-\xff.bin".to_vec()); + fs::write(temp.path().join("alpha").join(filename), b"invalid name").unwrap(); + let discovered = modding::discover(temp.path()); + assert!(discovered.mods.is_empty()); + assert_eq!(discovered.errors.len(), 1); + assert!(discovered.errors[0].contains("filenames must be UTF-8")); +} + #[test] fn package_paths_depth_and_size_are_bounded() { let temp = tempfile::tempdir().unwrap(); diff --git a/tools/assemble_runtime.py b/tools/assemble_runtime.py index f3fbf8c5..e1f903ef 100644 --- a/tools/assemble_runtime.py +++ b/tools/assemble_runtime.py @@ -127,13 +127,15 @@ def main(): if args.zip: archive = args.zip.resolve() archive.parent.mkdir(parents=True, exist_ok=True) - skipped = {"target", "dist", ".reference", "__pycache__", ".git"} + skipped = {"target", "dist", ".reference", ".local", "derived", "__pycache__", ".git"} with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as output: for path in sorted(package.rglob("*")): relative = path.relative_to(package) if path.is_file() and not skipped.intersection(relative.parts) and path.resolve() != archive: if any(part.startswith(".runtime-data-") for part in relative.parts): continue + if len(relative.parts) >= 4 and relative.parts[0] == "mods" and relative.parts[2:4] == ("art", "source"): + continue output.write(path, str(Path("RustRaptor") / relative)) print(json.dumps({key: value for key, value in report.items() if key not in {"files", "system_dependencies"}}, indent=2)) diff --git a/tools/assemble_windows.py b/tools/assemble_windows.py new file mode 100644 index 00000000..93d6f052 --- /dev/null +++ b/tools/assemble_windows.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Build and include a Windows x86_64 runtime using the shared packaged catalog.""" +import argparse +import json +import os +from pathlib import Path +import shutil +import struct +import subprocess +import zipfile + +from assemble_runtime import checked_file, digest + + +SYSTEM_DLLS = { + "advapi32.dll", "bcrypt.dll", "bcryptprimitives.dll", "comdlg32.dll", "crypt32.dll", "dinput8.dll", + "gdi32.dll", "hid.dll", "imm32.dll", "iphlpapi.dll", "kernel32.dll", + "msvcrt.dll", "ntdll.dll", "ole32.dll", "oleaut32.dll", "powrprof.dll", + "psapi.dll", "rpcrt4.dll", "secur32.dll", "setupapi.dll", "shell32.dll", + "shlwapi.dll", "ucrtbase.dll", "user32.dll", "userenv.dll", "version.dll", + "winmm.dll", "winspool.drv", "ws2_32.dll", +} + + +def pe_imports(path): + """Read checked PE32+ headers and DLL imports without host-specific tools.""" + payload = path.read_bytes() + def u32(offset): + return struct.unpack_from(" 13: + if optional_size < 224 or any(struct.unpack_from("= 4 and relative.parts[0] == "mods" and relative.parts[2:4] == ("art", "source"): + continue + output.write(path, str(Path("RustRaptor") / relative)) + print(json.dumps(report, indent=2)) + + +if __name__ == "__main__": + main()