Include a self-contained Windows build and portable profiles

This commit is contained in:
raptorreplica committed 2026-10-11 17:51:38 +11:00
1 parent 64e59fda1f
commit 1b52466f4c
20 files changed
+729 -34

No files matched your search

+3 -1
View File
@@ -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
+76
View File
@@ -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
+8 -4
View File
@@ -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
+18
View File
@@ -0,0 +1,18 @@
Copyright (C) 1997-2025 Sam Lantinga <slouken@libsdl.org>
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.
Executable
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -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
+4 -3
View File
@@ -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
+10 -1
View File
@@ -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.
+6 -4
View File
@@ -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
+70
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
@echo off
setlocal
cd /d "%~dp0"
"%~dp0bin\raptor-rust.exe" %*
exit /b %errorlevel%
+7 -7
View File
@@ -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)"
]
}
+47
View File
@@ -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"
}
+28 -8
View File
@@ -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<Vec<u8>> {
);
Ok(data)
}
fn package_hash_name(relative: &Path) -> Result<String> {
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<InstalledMod> {
ensure!(
fs::symlink_metadata(path)?.is_dir(),
@@ -117,7 +136,10 @@ fn load_package(path: &Path) -> Result<InstalledMod> {
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<InstalledMod> {
}
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<PathBuf>,
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");
}
}
+199 -4
View File
@@ -20,9 +20,16 @@ pub struct Paths {
}
impl Paths {
pub fn discover() -> Result<Self> {
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<std::ffi::OsString>) -> Result<Self> {
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<std::ffi::OsString>) -> Result<Self> {
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<u8> {
#[cfg(test)]
mod tests {
use super::*;
fn environment(values: &[(&str, &str)]) -> impl FnMut(&str) -> Option<std::ffi::OsString> {
let values = values
.iter()
.map(|(key, value)| (key.to_string(), std::ffi::OsString::from(value)))
.collect::<BTreeMap<_, _>>();
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");
}
}
+55
View File
@@ -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();
+3 -1
View File
@@ -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))
+189
View File
@@ -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("<I", payload, offset)[0]
if len(payload) < 64 or payload[:2] != b"MZ":
raise ValueError(f"not a Windows executable: {path}")
header = u32(60)
if payload[header:header+4] != b"PE\0\0":
raise ValueError(f"invalid PE signature: {path}")
machine, sections = struct.unpack_from("<HH", payload, header+4)
optional_size = struct.unpack_from("<H", payload, header+20)[0]
optional = header+24
if machine != 0x8664 or struct.unpack_from("<H", payload, optional)[0] != 0x20B or optional_size < 128:
raise ValueError(f"expected a Windows x86_64 PE32+ image: {path}")
headers_size = u32(optional+60)
# Dependency closure must include every runtime-loaded import. Delay
# imports are not used by our builds; reject them rather than overlooking
# a DLL needed only after the game has started.
if u32(optional+108) > 13:
if optional_size < 224 or any(struct.unpack_from("<II", payload, optional+216)):
raise ValueError(f"unsupported PE delay imports: {path}")
table = optional+optional_size
def offset(rva):
if rva < headers_size and rva < len(payload):
return rva
for index in range(sections):
start = table+index*40
virtual_size, address, raw_size, raw_offset = struct.unpack_from("<IIII", payload, start+8)
if address <= rva < address+max(virtual_size, raw_size):
delta = rva-address
if delta < raw_size and raw_offset+delta < len(payload):
return raw_offset+delta
raise ValueError(f"unmapped PE address in {path}")
imports, size = struct.unpack_from("<II", payload, optional+120)
if not imports:
return []
result = []
for index in range(min(size//20, 4096)):
descriptor = struct.unpack_from("<IIIII", payload, offset(imports+index*20))
if not any(descriptor):
return sorted(set(result))
start = offset(descriptor[3])
end = payload.find(b"\0", start, min(start+256, len(payload)))
if end < 0:
raise ValueError(f"unterminated PE import name: {path}")
result.append(payload[start:end].decode("ascii").lower())
raise ValueError(f"unterminated PE import table: {path}")
def sdk_files(root, target):
if target.endswith("-msvc"):
library = root / "lib/x64/SDL2.lib"
dll = root / "lib/x64/SDL2.dll"
else:
if (root / "x86_64-w64-mingw32").is_dir():
root = root / "x86_64-w64-mingw32"
library = root / "lib/libSDL2.dll.a"
dll = root / "bin/SDL2.dll"
for path in (library, dll):
if not path.is_file():
raise ValueError(f"missing SDL2 Windows SDK file: {path}")
return library, dll
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--sdl2-dir", type=Path, required=True, help="extracted SDL2 MinGW or Visual C SDK")
parser.add_argument("--target", default="x86_64-pc-windows-msvc" if os.name == "nt" else "x86_64-pc-windows-gnu",
choices=["x86_64-pc-windows-gnu", "x86_64-pc-windows-msvc"])
parser.add_argument("--cargo", default="cargo")
parser.add_argument("--rustc", type=Path, help="optional matching compiler from an isolated toolchain")
parser.add_argument("--linker", help="cross-linker command or full path")
parser.add_argument("--sysroot", type=Path, help="optional isolated matching Rust target standard library")
parser.add_argument("--skip-build", action="store_true")
parser.add_argument("--extra-dll", type=Path, action="append", default=[])
parser.add_argument("--zip", type=Path, help="export a Windows-only playable folder with source, docs and mods")
args = parser.parse_args()
package = Path(__file__).resolve().parents[1]
library, sdl_dll = sdk_files(args.sdl2_dir.resolve(), args.target)
license = args.sdl2_dir.resolve() / "COPYING.txt"
if not license.is_file():
license = args.sdl2_dir.resolve() / "LICENSE.txt"
if not license.is_file():
raise ValueError("SDL2 SDK license is missing")
environment = os.environ.copy()
if args.rustc:
environment["RUSTC"] = str(args.rustc.resolve())
key = args.target.upper().replace("-", "_")
flags = ["-L", "native="+str(library.parent)]
if args.sysroot:
flags += ["--sysroot", str(args.sysroot.resolve())]
if args.target.endswith("-msvc"):
flags += ["-C", "target-feature=+crt-static"]
# Encoded flags preserve SDK/sysroot paths containing spaces. Cargo's
# explicit --target keeps these flags away from host build scripts.
environment["CARGO_ENCODED_RUSTFLAGS"] = "\x1f".join(flags)
if args.linker:
environment["CARGO_TARGET_"+key+"_LINKER"] = args.linker
linker = Path(args.linker)
if linker.is_file():
# Rust also invokes the cross toolchain's dlltool for raw Windows
# imports; an isolated linker needs its sibling tools on PATH.
environment["PATH"] = str(linker.resolve().parent)+os.pathsep+environment.get("PATH", "")
build = package / "target/windows-runtime"
if not args.skip_build:
subprocess.run([args.cargo, "build", "--locked", "--release", "--target", args.target,
"--target-dir", str(build), "--bin", "raptor-rust", "--features", "desktop"],
cwd=package, env=environment, check=True)
executable = build / args.target / "release/raptor-rust.exe"
dependencies = pe_imports(executable)
if "sdl2.dll" not in dependencies:
raise ValueError("Windows binary has no SDL2 import; rebuild the desktop feature")
bundle = {"raptor-rust.exe": executable, "SDL2.dll": sdl_dll}
for extra in args.extra_dll:
if extra.name.lower() in {name.lower() for name in bundle}:
raise ValueError(f"duplicate bundled DLL: {extra.name}")
bundle[extra.name] = extra.resolve()
bundled_names = {name.lower() for name in bundle}
dependency_report = {}
for name, path in bundle.items():
imports = pe_imports(path)
unresolved = [dll for dll in imports if dll not in SYSTEM_DLLS and dll not in bundled_names
and not dll.startswith(("api-ms-win-", "ext-ms-win-"))]
if unresolved:
raise ValueError(f"unbundled Windows dependency for {name}: {unresolved}; supply --extra-dll")
dependency_report[name] = imports
data_manifest = json.loads((package / "runtime-manifest.json").read_text())
for name, expected in data_manifest["files"].items():
if digest(checked_file(package, name)) != expected:
raise ValueError(f"packaged data differs from runtime manifest: {name}; refresh the shared bundle first")
(package / "bin").mkdir(exist_ok=True)
for name, path in bundle.items():
shutil.copy2(path, package / "bin" / name)
shutil.copy2(license, package / "bin/SDL2-WINDOWS-LICENSE.txt")
report = {"format_version": 1, "platform": "Windows x86_64", "target": args.target,
"catalog_entries": data_manifest["catalog_entries"],
"approved_hd_frames": data_manifest["approved_hd_frames"],
"shared_data_manifest": "runtime-manifest.json", "dependencies": dependency_report,
"files": {"bin/"+name: digest(package / "bin" / name) for name in bundle},
"launcher": "run.cmd", "validation": "PE imports and complete shared data hashes; see docs/windows-build.md"}
report["files"]["bin/SDL2-WINDOWS-LICENSE.txt"] = digest(package / "bin/SDL2-WINDOWS-LICENSE.txt")
report["files"]["run.cmd"] = digest(package / "run.cmd")
(package / "runtime-windows-manifest.json").write_text(json.dumps(report, indent=2)+"\n")
if os.name == "nt":
subprocess.run([str(package / "bin/raptor-rust.exe"), "--verify-assets"], cwd=package.parent, check=True)
if args.zip:
archive = args.zip.resolve()
archive.parent.mkdir(parents=True, exist_ok=True)
skipped = {"target", "dist", ".reference", ".local", "derived", "__pycache__", ".git", "lib"}
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as output:
for path in sorted(package.rglob("*")):
relative = path.relative_to(package)
if not path.is_file() or skipped.intersection(relative.parts) or path.resolve() == archive:
continue
if relative == Path("bin/raptor-rust") or 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(report, indent=2))
if __name__ == "__main__":
main()