Load ZIP mod packages and publish agent authoring guides and packaging tools

This commit is contained in:
chazmm committed 2026-09-08 22:42:01 +01:00
1 parent 3611047d32
commit b262dfdcfe
33 files changed
+668 -171

No files matched your search

+3 -1
View File
@@ -32,4 +32,6 @@ __pycache__/
*.pem
*.key
/mods/mario-kart/rider.json
/sdk/examples/mario-kart/rider.json
/mods/.cache/
+1 -1
View File
@@ -33,7 +33,7 @@ try {
$modDestination = Join-Path $privateDirectory 'mods/native-trainer'
New-Item -ItemType Directory -Force -Path $modDestination | Out-Null
foreach ($name in @('mod.json','main.lua','help.txt','README.md')) {
$source = Join-Path $PSScriptRoot "mods/native-trainer/$name"
$source = Join-Path $PSScriptRoot "sdk/examples/native-trainer/$name"
$target = Join-Path $modDestination $name
if (-not (Test-Path -LiteralPath $target)) { Copy-Item -LiteralPath $source -Destination $target }
elseif ((Get-FileHash -LiteralPath $source).Hash -ne (Get-FileHash -LiteralPath $target).Hash) {
+1 -1
View File
@@ -33,7 +33,7 @@ try {
$modDestination = Join-Path $privateDirectory 'mods/native-trainer'
New-Item -ItemType Directory -Force -Path $modDestination | Out-Null
foreach ($name in @('mod.json','main.lua','help.txt','README.md')) {
$source = Join-Path $PSScriptRoot "mods/native-trainer/$name"
$source = Join-Path $PSScriptRoot "sdk/examples/native-trainer/$name"
$target = Join-Path $modDestination $name
if (-not (Test-Path -LiteralPath $target)) { Copy-Item -LiteralPath $source -Destination $target }
elseif ((Get-FileHash -LiteralPath $source).Hash -ne (Get-FileHash -LiteralPath $target).Hash) {
+1 -1
View File
@@ -33,7 +33,7 @@ try {
$modDestination = Join-Path $privateDirectory 'mods/native-trainer'
New-Item -ItemType Directory -Force -Path $modDestination | Out-Null
foreach ($name in @('mod.json','main.lua','help.txt','README.md')) {
$source = Join-Path $PSScriptRoot "mods/native-trainer/$name"
$source = Join-Path $PSScriptRoot "sdk/examples/native-trainer/$name"
$target = Join-Path $modDestination $name
if (-not (Test-Path -LiteralPath $target)) { Copy-Item -LiteralPath $source -Destination $target }
elseif ((Get-FileHash -LiteralPath $source).Hash -ne (Get-FileHash -LiteralPath $target).Hash) {
+1 -1
View File
@@ -33,7 +33,7 @@ try {
$modDestination = Join-Path $privateDirectory 'mods/native-trainer'
New-Item -ItemType Directory -Force -Path $modDestination | Out-Null
foreach ($name in @('mod.json','main.lua','help.txt','README.md')) {
$source = Join-Path $PSScriptRoot "mods/native-trainer/$name"
$source = Join-Path $PSScriptRoot "sdk/examples/native-trainer/$name"
$target = Join-Path $modDestination $name
if (-not (Test-Path -LiteralPath $target)) { Copy-Item -LiteralPath $source -Destination $target }
elseif ((Get-FileHash -LiteralPath $source).Hash -ne (Get-FileHash -LiteralPath $target).Hash) {
+1 -1
View File
@@ -33,7 +33,7 @@ try {
$modDestination = Join-Path $privateDirectory 'mods/native-trainer'
New-Item -ItemType Directory -Force -Path $modDestination | Out-Null
foreach ($name in @('mod.json','main.lua','help.txt','README.md')) {
$source = Join-Path $PSScriptRoot "mods/native-trainer/$name"
$source = Join-Path $PSScriptRoot "sdk/examples/native-trainer/$name"
$target = Join-Path $modDestination $name
if (-not (Test-Path -LiteralPath $target)) { Copy-Item -LiteralPath $source -Destination $target }
elseif ((Get-FileHash -LiteralPath $source).Hash -ne (Get-FileHash -LiteralPath $target).Hash) {
+19 -20
View File
@@ -3,7 +3,7 @@ $ErrorActionPreference = 'Stop'
$privateDirectory = Join-Path $PSScriptRoot '.local/vehicle-sdk'
$buildDirectory = Join-Path $privateDirectory 'build'
$artifact = Join-Path $buildDirectory 'skate3rust-vehicle-sdk.exe'
$destination = Join-Path $privateDirectory 'skate3-vehicle-ramp-audio.exe'
$destination = Join-Path $privateDirectory 'skate3-zip-mods.exe'
Push-Location $PSScriptRoot
$previousFlags = $env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS
try {
@@ -25,33 +25,32 @@ try {
}
$bytes = [System.IO.File]::ReadAllBytes($artifact)
$text = [System.Text.Encoding]::ASCII.GetString($bytes)
foreach ($required in @('--start-paused', 'Wheel or drag scrollbar', 'Drag // to resize', 'hold_fakie', 'Vehicle key already spawned', 'Native trainer controls are already owned by another mod')) {
foreach ($required in @('--start-paused', 'Wheel or drag scrollbar', 'Drag // to resize', 'hold_fakie', 'Vehicle key already spawned', 'ZIP must contain mod.json at its root', 'Native trainer controls are already owned by another mod')) {
if (-not $text.Contains($required)) { throw "Wrong vehicle-sdk artifact: missing $required" }
}
Copy-Item -LiteralPath $artifact -Destination $destination -Force
# Only original SDK example sources are staged. Never copy extracted assets.
$modDestination = Join-Path $privateDirectory 'mods/native-trainer'
New-Item -ItemType Directory -Force -Path $modDestination | Out-Null
foreach ($name in @('mod.json','main.lua','help.txt','README.md')) {
$source = Join-Path $PSScriptRoot "mods/native-trainer/$name"
$target = Join-Path $modDestination $name
if (-not (Test-Path -LiteralPath $target)) { Copy-Item -LiteralPath $source -Destination $target }
elseif ((Get-FileHash -LiteralPath $source).Hash -ne (Get-FileHash -LiteralPath $target).Hash) {
Write-Warning "Preserving existing mod file: $target. Copy the updated source there when ready."
# Build ZIPs from the editable SDK examples. The project launcher reads root/mods.
$modRoot = Join-Path $privateDirectory 'mods'
New-Item -ItemType Directory -Force -Path $modRoot | Out-Null
foreach ($name in @('native-trainer','mario-kart')) {
& python (Join-Path $PSScriptRoot 'tools/package_mod.py') (Join-Path $PSScriptRoot "sdk/examples/$name") (Join-Path $PSScriptRoot "mods/$name.zip") --target-dir (Join-Path $PSScriptRoot '.local/vehicle-tests')
if ($LASTEXITCODE -ne 0) { throw "Failed to package $name" }
$old = [System.IO.Path]::GetFullPath((Join-Path $modRoot $name))
if (Test-Path -LiteralPath $old -PathType Container) {
$archive = [System.IO.Path]::GetFullPath((Join-Path $modRoot ('.legacy-' + $name + '-' + [guid]::NewGuid().ToString('N'))))
$bound = [System.IO.Path]::GetFullPath($modRoot) + [System.IO.Path]::DirectorySeparatorChar
if (-not $old.StartsWith($bound) -or -not $archive.StartsWith($bound)) { throw 'Invalid mod migration path' }
Move-Item -LiteralPath $old -Destination $archive
}
Copy-Item -LiteralPath (Join-Path $PSScriptRoot "mods/$name.zip") -Destination (Join-Path $modRoot "$name.zip") -Force
}
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'mods/README.md') -Destination (Join-Path $privateDirectory 'mods/README.md') -Force
$kartDestination = Join-Path $privateDirectory 'mods/mario-kart'
New-Item -ItemType Directory -Force -Path $kartDestination | Out-Null
foreach ($name in @('mod.json','main.lua','vehicle.json','README.md','kart.glb','rider.json')) {
$source = Join-Path $PSScriptRoot "mods/mario-kart/$name"
$target = Join-Path $kartDestination $name
if (-not (Test-Path -LiteralPath $target)) { Copy-Item -LiteralPath $source -Destination $target }
elseif ((Get-FileHash -LiteralPath $source).Hash -ne (Get-FileHash -LiteralPath $target).Hash) { Write-Warning "Preserving $target" }
}
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'mods/README.md') -Destination (Join-Path $modRoot 'README.md') -Force
New-Item -ItemType Directory -Force -Path (Join-Path $privateDirectory 'docs'), (Join-Path $privateDirectory 'sdk') | Out-Null
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'docs/vehicle-sdk.md') -Destination (Join-Path $privateDirectory 'docs/vehicle-sdk.md') -Force
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'docs/mixamo-vehicle-workflow.md') -Destination (Join-Path $privateDirectory 'docs/mixamo-vehicle-workflow.md') -Force
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'sdk/AGENTS.md') -Destination (Join-Path $privateDirectory 'sdk/AGENTS.md') -Force
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'docs/mod-packages.md') -Destination (Join-Path $privateDirectory 'docs/mod-packages.md') -Force
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'docs/lua-modding.md') -Destination (Join-Path $privateDirectory 'docs/lua-modding.md') -Force
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'sdk/skate.lua') -Destination (Join-Path $privateDirectory 'sdk/skate.lua') -Force
$hash = (Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash
if ($hash -ne (Get-FileHash -LiteralPath $artifact -Algorithm SHA256).Hash) {
Generated
+48
View File
@@ -181,6 +181,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -2181,6 +2190,17 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -4724,10 +4744,12 @@ dependencies = [
name = "skate-mods"
version = "0.1.0"
dependencies = [
"blake3",
"mlua",
"serde",
"serde_json",
"skate-vehicles",
"zip",
]
[[package]]
@@ -6316,6 +6338,20 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "zip"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b"
dependencies = [
"arbitrary",
"crc32fast",
"flate2",
"indexmap",
"memchr",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.7"
@@ -6328,6 +6364,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
+2 -2
View File
@@ -9,7 +9,7 @@ if not defined SKATE3_ASSETS (
pause
exit /b 1
)
if not exist "%~dp0.local\vehicle-sdk\skate3-vehicle-ramp-audio.exe" (
if not exist "%~dp0.local\vehicle-sdk\skate3-zip-mods.exe" (
echo Build missing. Run Build-VehicleSDK.ps1 first.
pause
exit /b 1
@@ -21,5 +21,5 @@ echo Open Mods and ENABLE Mario Kart. F10 spawns the kart; E enters/exits. Escap
echo WASD drive, Space brake, Shift handbrake, R reset. Slow down before exiting.
echo Mods folder: %SKATE3_MODS%
echo Trainer log: %~dp0.local\vehicle-sdk\session.log
"%~dp0.local\vehicle-sdk\skate3-vehicle-ramp-audio.exe" --assets "%SKATE3_ASSETS%" --start-paused %* > "%~dp0.local\vehicle-sdk\session.log" 2>&1
"%~dp0.local\vehicle-sdk\skate3-zip-mods.exe" --assets "%SKATE3_ASSETS%" --start-paused %* > "%~dp0.local\vehicle-sdk\session.log" 2>&1
if errorlevel 1 pause
+8
View File
@@ -8,6 +8,14 @@ A Rust and Bevy skating project built from Skate 3 reverse-engineering research.
Includes skating, tricks, grinds, offboard movement, difficulty settings and
`.skate` map support. Gameplay parity is still a work in progress.
## Mods and agent authoring
Drop mod ZIPs into top-level `mods/` and use `PLAY-MARIO-KART.bat` for the integrated
mod build in this checkout. **Point your coding agent at [sdk/AGENTS.md](sdk/AGENTS.md)**
to make a mod. See [package structure](docs/mod-packages.md), [Lua API](docs/lua-modding.md),
[vehicle API](docs/vehicle-sdk.md) and [Mixamo workflow](docs/mixamo-vehicle-workflow.md).
Editable examples live in `sdk/examples/`; `tools/package_mod.py` validates and packages them.
## Play
Extract the Windows release ZIP and run `skate3rust.exe`. Select your Skate 3
+2
View File
@@ -5,8 +5,10 @@ edition.workspace = true
publish.workspace = true
[dependencies]
blake3 = "1.8.2"
mlua = { version = "=0.12.1", features = ["lua54", "vendored", "serde", "send"] }
serde.workspace = true
serde_json.workspace = true
skate-vehicles = { path = "../skate-vehicles" }
zip = { version = "6.0.0", default-features = false, features = ["deflate"] }
+6
View File
@@ -0,0 +1,6 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
let path = std::env::args_os().nth(1).ok_or("Usage: check_mod path/to/mod.zip-or-folder")?;
let manifest = skate_mods::validate_package(std::path::Path::new(&path))?;
println!("Valid package: {} {} (API {})", manifest.id, manifest.version, manifest.api);
Ok(())
}
+99
View File
@@ -0,0 +1,99 @@
//! Bounded ZIP extraction. A session cache never overwrites another package.
use std::{collections::{BTreeMap, BTreeSet}, io::{Read, Write}, path::{Path, PathBuf}};
const LIMIT: u64 = 64 * 1024 * 1024;
#[derive(Default)]
pub(crate) struct Cache {
session: Option<PathBuf>,
entries: BTreeMap<PathBuf, (blake3::Hash, PathBuf)>,
next: u64,
}
fn safe_name(name: &str) -> Result<PathBuf, String> {
let name = name.strip_suffix('/').unwrap_or(name);
if name.is_empty() || name.len() > 240 || name.contains(['\\', ':', '<', '>', '"', '|', '?', '*'])
|| name.chars().any(char::is_control) {
return Err("Invalid ZIP path".into());
}
for part in name.split('/') {
let stem = part.split('.').next().unwrap_or("").to_ascii_lowercase();
if part.is_empty() || part == "." || part == ".." || part.ends_with(['.', ' '])
|| matches!(stem.as_str(), "con" | "prn" | "aux" | "nul" | "com1" | "com2" | "com3" | "com4" | "com5" | "com6" | "com7" | "com8" | "com9" | "lpt1" | "lpt2" | "lpt3" | "lpt4" | "lpt5" | "lpt6" | "lpt7" | "lpt8" | "lpt9") {
return Err("ZIP paths must be ordinary relative paths without traversal or device names".into());
}
}
Ok(PathBuf::from(name))
}
impl Cache {
pub fn materialize(&mut self, root: &Path, archive: &Path) -> Result<PathBuf, String> {
let mut bytes = Vec::new();
std::fs::File::open(archive).map_err(|e| e.to_string())?.take(LIMIT + 1)
.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
if bytes.len() as u64 > LIMIT { return Err("ZIP exceeds 64 MiB compressed".into()); }
let hash = blake3::hash(&bytes);
if let Some((old, path)) = self.entries.get(archive) {
if old == &hash { return Ok(path.clone()); }
}
// Validate and decompress into bounded memory before publishing anything.
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).map_err(|e| e.to_string())?;
if zip.len() > 512 { return Err("ZIP exceeds 512 entries".into()); }
let mut names = BTreeSet::new();
let mut files = Vec::new();
let mut total = 0_u64;
for i in 0..zip.len() {
let mut file = zip.by_index(i).map_err(|e| e.to_string())?;
let path = safe_name(file.name())?;
if !names.insert(path.to_string_lossy().to_lowercase()) { return Err("Duplicate/case-colliding ZIP path".into()); }
let kind = file.unix_mode().unwrap_or(0) & 0o170000;
if kind != 0 && kind != 0o100000 && kind != 0o040000 { return Err("ZIP links and special files are unsupported".into()); }
if file.is_dir() { continue; }
if file.size() > LIMIT - total { return Err("ZIP exceeds 64 MiB expanded".into()); }
let mut contents = Vec::new();
file.by_ref().take(LIMIT - total + 1).read_to_end(&mut contents).map_err(|e| e.to_string())?;
total += contents.len() as u64;
if total > LIMIT { return Err("ZIP exceeds 64 MiB expanded".into()); }
files.push((path, contents));
}
if !files.iter().any(|(p, _)| p == Path::new("mod.json")) {
return Err("ZIP must contain mod.json at its root, not inside a wrapper folder".into());
}
if self.session.is_none() {
let root = root.canonicalize().map_err(|e| e.to_string())?;
let cache = root.join(".cache");
if cache.exists() && std::fs::symlink_metadata(&cache).map_err(|e|e.to_string())?.file_type().is_symlink() {
return Err("ZIP cache must not be a link".into());
}
std::fs::create_dir_all(&cache).map_err(|e| e.to_string())?;
if !cache.canonicalize().map_err(|e|e.to_string())?.starts_with(&root) { return Err("ZIP cache escapes mods folder".into()); }
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|e|e.to_string())?.as_nanos();
let session = cache.join(format!("{}-{nonce}", std::process::id()));
std::fs::create_dir(&session).map_err(|e| e.to_string())?;
self.session = Some(session);
}
self.next += 1;
let destination = self.session.as_ref().unwrap().join(self.next.to_string());
std::fs::create_dir(&destination).map_err(|e| e.to_string())?;
for (path, contents) in files {
let target = destination.join(path);
std::fs::create_dir_all(target.parent().unwrap()).map_err(|e| e.to_string())?;
std::fs::OpenOptions::new().write(true).create_new(true).open(target)
.and_then(|mut f| f.write_all(&contents)).map_err(|e| e.to_string())?;
}
self.entries.insert(archive.to_owned(), (hash, destination.clone()));
Ok(destination)
}
}
impl Drop for Cache {
fn drop(&mut self) {
if let Some(path) = &self.session {
// Only this process's uniquely created session, and never a redirected path.
if let (Ok(actual), Ok(parent)) = (path.canonicalize(), path.parent().unwrap().canonicalize()) {
if actual.parent() == Some(parent.as_path()) && actual.file_name() == path.file_name() {
let _ = std::fs::remove_dir_all(path);
}
}
}
}
}
+32 -4
View File
@@ -1,5 +1,6 @@
//! Window-free, versioned Lua package runtime. Host commands commit only after callbacks succeed.
mod schema;
mod archive;
mod vm;
pub use schema::*;
use serde::{Deserialize, Serialize};
@@ -11,6 +12,24 @@ use std::{
};
pub use vm::Command;
/// Inspect a folder or ZIP and compile its Lua entry without executing callbacks.
pub fn validate_package(source: &Path) -> Result<Manifest, String> {
let source = source.canonicalize().map_err(|e| e.to_string())?;
let mut cache = archive::Cache::default();
let root = if source.is_dir() { source.clone() } else {
cache.materialize(source.parent().ok_or("Missing package parent")?, &source)?
};
let manifest: Manifest = serde_json::from_slice(&read_bounded(&root, "mod.json", 64 * 1024)?)
.map_err(|e| e.to_string())?;
manifest.validate()?;
fingerprint(&root)?;
let source = read_bounded(&root, &manifest.entry, 256 * 1024)?;
let source = std::str::from_utf8(&source).map_err(|e| e.to_string())?;
if source.starts_with('\u{1b}') { return Err("Lua bytecode is unsupported".into()); }
mlua::Lua::new().load(source).set_mode(mlua::chunk::ChunkMode::Text).into_function().map_err(|e|e.to_string())?;
Ok(manifest)
}
#[derive(Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Preferences {
@@ -45,6 +64,7 @@ pub struct Manager {
last_scan: Instant,
pub snapshot: Value,
invalid_since: BTreeMap<String, Instant>,
archives: archive::Cache,
}
impl Manager {
pub fn root(&self) -> &Path {
@@ -61,6 +81,7 @@ impl Manager {
last_scan: Instant::now() - Duration::from_secs(2),
snapshot: Value::Null,
invalid_since: BTreeMap::new(),
archives: archive::Cache::default(),
}
}
pub fn scan(&mut self, force: bool) {
@@ -89,16 +110,23 @@ impl Manager {
let mut paths: Vec<_> = dirs
.filter_map(Result::ok)
.map(|d| d.path())
.filter(|p| p.is_dir())
.filter(|p| !p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'))
&& (p.is_dir() || p.extension().is_some_and(|e| e.eq_ignore_ascii_case("zip"))))
.collect();
paths.sort();
if paths.len() > 128 {
self.diagnostics.push(
"Only the first 128 package directories are supported; remove excess packages"
"Only the first 128 packages are supported; remove excess packages"
.into(),
);
}
for path in paths.into_iter().take(128) {
for source in paths.into_iter().take(128) {
let path = if source.is_file() {
match self.archives.materialize(&self.root, &source) {
Ok(path) => path,
Err(e) => { self.diagnostics.push(format!("{}: {e}", source.display())); continue; }
}
} else { source.clone() };
// Invalid edits retire the old package: never silently retain outdated gameplay.
let result = (|| {
let bytes = read_bounded(&path, "mod.json", 64 * 1024)?;
@@ -116,7 +144,7 @@ impl Manager {
}
found.insert(id, (path, manifest, hash));
}
Err(e) => self.diagnostics.push(format!("{}: {e}", path.display())),
Err(e) => self.diagnostics.push(format!("{}: {e}", source.display())),
}
}
for id in duplicates {
+86
View File
@@ -0,0 +1,86 @@
use skate_mods::Manager;
use std::{io::Write, path::PathBuf, sync::atomic::{AtomicUsize, Ordering}};
static NEXT: AtomicUsize = AtomicUsize::new(0);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let root = std::env::temp_dir().join(format!("skate-zip-{}-{}", std::process::id(), NEXT.fetch_add(1,Ordering::Relaxed)));
std::fs::create_dir_all(&root).unwrap(); Self(root)
}
fn zip(&self, name: &str, files: &[(&str, &[u8])]) {
let mut zip = zip::ZipWriter::new(std::fs::File::create(self.0.join(name)).unwrap());
for (name, contents) in files {
zip.start_file(*name, zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated)).unwrap();
zip.write_all(contents).unwrap();
}
zip.finish().unwrap();
}
fn manager(&self) -> Manager { Manager::new(self.0.clone(), self.0.join(".settings")) }
}
impl Drop for Fixture {
fn drop(&mut self) {
let actual=self.0.canonicalize().unwrap();
let temp=std::env::temp_dir().canonicalize().unwrap();
assert!(actual.parent()==Some(temp.as_path()));
std::fs::remove_dir_all(actual).unwrap();
}
}
const MANIFEST: &[u8] = br#"{"id":"zip.example","api":1,"name":"ZIP Example","version":"1.0.0","author":"Test","description":"Test","entry":"main.lua"}"#;
#[test]
fn zip_assets_reload_and_removal_follow_the_archive() {
let f=Fixture::new();
let code=b"return {on_load=function() sdk.log(sdk.read_text('assets/message.txt')) end}";
f.zip("example.zip", &[("mod.json",MANIFEST),("main.lua",code),("assets/message.txt",b"first")]);
let mut m=f.manager(); m.scan(true); assert!(m.diagnostics.is_empty(), "{:?}",m.diagnostics);
m.enable("zip.example",true).unwrap(); assert!(m.packages["zip.example"].running());
let old=m.packages["zip.example"].root.clone();
assert_eq!(std::fs::read(old.join("assets/message.txt")).unwrap(),b"first");
m.scan(true); assert_eq!(m.packages["zip.example"].root,old);
f.zip("example.zip", &[("mod.json",MANIFEST),("main.lua",code),("assets/message.txt",b"second")]);
m.scan(true); assert_ne!(m.packages["zip.example"].root,old);
assert!(m.packages["zip.example"].running());
std::fs::remove_file(f.0.join("example.zip")).unwrap(); m.scan(true);
assert!(m.packages.is_empty()); assert!(m.retired.contains(&"zip.example".to_owned()));
}
#[test]
fn unsafe_paths_wrapper_folders_and_case_collisions_are_rejected() {
for name in ["../escape.txt", "/absolute.txt", "C:/escape.txt", "a\\escape.txt", "nul.txt", "a./x", "a/../x"] {
let f=Fixture::new(); f.zip("bad.zip", &[("mod.json",MANIFEST),(name,b"bad")]);
let mut m=f.manager(); m.scan(true); assert!(m.packages.is_empty(),"{name}"); assert!(!m.diagnostics.is_empty());
}
for files in [vec![("wrapper/mod.json",MANIFEST)], vec![("mod.json",MANIFEST),("MOD.JSON",MANIFEST)]] {
let f=Fixture::new(); f.zip("bad.zip", &files);
let mut m=f.manager(); m.scan(true); assert!(m.packages.is_empty()); assert!(!m.diagnostics.is_empty());
}
}
#[test]
fn expansion_limits_and_duplicate_ids_are_rejected() {
let f=Fixture::new(); let huge=vec![0_u8;64*1024*1024+1];
f.zip("huge.zip", &[("mod.json",MANIFEST),("huge.bin",&huge)]);
let mut m=f.manager(); m.scan(true); assert!(m.packages.is_empty());
assert!(m.diagnostics.iter().any(|s|s.contains("64 MiB")));
std::fs::remove_file(f.0.join("huge.zip")).unwrap();
for name in ["one.zip","two.zip"] { f.zip(name,&[("mod.json",MANIFEST),("main.lua",b"return {}")]); }
m.scan(true); assert!(m.packages.is_empty()); assert!(m.diagnostics.iter().any(|s|s.contains("Duplicate mod ID")));
}
#[test]
fn links_excess_entries_and_invalid_replacements_do_not_run() {
let f=Fixture::new();
let mut zip=zip::ZipWriter::new(std::fs::File::create(f.0.join("bad.zip")).unwrap());
zip.add_symlink("escape", "../outside", zip::write::SimpleFileOptions::default()).unwrap();
zip.finish().unwrap();
let mut m=f.manager(); m.scan(true); assert!(m.packages.is_empty());
assert!(m.diagnostics.iter().any(|s|s.contains("links")));
let mut zip=zip::ZipWriter::new(std::fs::File::create(f.0.join("bad.zip")).unwrap());
for i in 0..513 { zip.start_file(format!("file-{i}"),zip::write::SimpleFileOptions::default()).unwrap(); }
zip.finish().unwrap(); m.scan(true); assert!(m.diagnostics.iter().any(|s|s.contains("512")));
f.zip("bad.zip", &[("mod.json",MANIFEST),("main.lua",b"return {}")]);
m.scan(true); m.enable("zip.example",true).unwrap(); assert!(m.packages["zip.example"].running());
std::fs::write(f.0.join("bad.zip"),b"not a ZIP").unwrap(); m.scan(true);
assert!(m.packages.is_empty()); assert!(!m.diagnostics.is_empty());
}
+3 -3
View File
@@ -142,7 +142,7 @@ fn traversal_and_unknown_dependencies_rejected() {
}
#[test]
fn examples_load_and_run() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../mods");
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../sdk/examples");
if !root.exists() {
panic!("Examples missing");
}
@@ -299,7 +299,7 @@ fn trainer_commands_validate_and_follow_settings() {
}
fn showcase_manager(f: &Fixture) -> Manager {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../mods");
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../sdk/examples");
let mut m = Manager::new(root, f.0.join("showcase-settings"));
m.snapshot = json!({"player":{"position":[0,0,0],"velocity":[3,0,0],"heading":0.5,"state":100,"on_board":true,"bailing":false,"grind":{"active":false}},"keys":{},"map":{"name":"test","generation":0},"actions":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]});
m.scan(true);
@@ -430,7 +430,7 @@ fn vehicle_api_rejects_traversal_and_invalid_controls() {
#[test]
fn kart_controller_reset_is_edge_triggered_and_rebindable() {
let f=Fixture::new("return {}");let mut m=Manager::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../mods"),f.0.join("kart-settings"));
let f=Fixture::new("return {}");let mut m=Manager::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../sdk/examples"),f.0.join("kart-settings"));
m.snapshot=json!({"player":{"position":[0,0,0],"heading":0},"keys":{},"vehicles":{"community.mario-kart":{"kart":{"position":[2,1,3],"heading":0.5,"occupied":true,"phase":"driving","speed":0,"ready":true}}},"vehicle_input":{"throttle":0,"steering":0,"brake":0,"handbrake":false,"interact":false,"pad_buttons":0}});
m.scan(true);m.enable("community.mario-kart",true).unwrap();m.commands.clear();
for buttons in [128,128,128,0,128] {m.snapshot["vehicle_input"]["pad_buttons"]=json!(buttons);m.dispatch("on_fixed_update",json!({"dt":0.016}));}
+1 -1
View File
@@ -1,6 +1,6 @@
use skate_vehicles::*;
fn definition() -> VehicleDefinition {
serde_json::from_str(include_str!("../../../mods/mario-kart/vehicle.json")).unwrap()
serde_json::from_str(include_str!("../../../sdk/examples/mario-kart/vehicle.json")).unwrap()
}
fn simulation() -> Simulation {
let mut s = Simulation::default();
+53 -90
View File
@@ -1,21 +1,30 @@
# Lua modding SDK 1
This is an implemented, in-process Lua 5.4 SDK for this engine. Open **Escape/Start → Mods**. New packages are disabled. Select a package to enable/disable, reload, edit settings or reset defaults. Up/down and D-pad scroll eight visible rows; left/right changes booleans, numbers and choices; Enter/A chooses. Strings use keyboard text entry, Backspace, Enter to commit and Escape to cancel. B/Escape returns one level without resuming behind the menu. The existing map, marker, bail and character menus remain the owners of their native behavior.
Start with [the agent instructions](../sdk/AGENTS.md) and [ZIP package guide](mod-packages.md).
Use `PLAY-MARIO-KART.bat` / `Build-VehicleSDK.ps1` for the current integrated build;
older named trainer/window launchers are historical snapshots. The project launcher
loads top-level `mods/`, starts paused, and writes `.local/vehicle-sdk/session.log`.
Prepared engine assets are required; Lua itself is embedded.
Run `PLAY-NATIVE-TRAINER.bat` for the current trainer build (`Build-NativeTrainer.ps1` builds it); `PLAY-LUA-SDK.bat` retains the earlier SDK build. It starts paused, uses isolated mod preferences, and writes `.local/lua-sdk/session.log`. It requires prepared engine assets and the executable generated by `Build-LuaSDK.ps1`. Set `SKATE3_ASSETS` or the private `.local/lua-sdk/assets.path`; the checked-in launcher contains no personal installation path. No Lua installation is needed.
Open Escape/Start → Mods. New mods start disabled. Each has a red DISABLED or green
ENABLED badge and explicit enable/disable action. Enabled mods have separate movable,
resizable and collapsible settings windows with continuous scrolling and a scrollbar.
The ordinary pause menu stays centred. Minus/plus controls change values; Tab/controller
X cycles focus, arrows/D-pad select/adjust, and strings open the text editor. Changes
are saved outside the package. Window layout is retained for the current process.
## Quick start
Copy `mods/native-trainer` to another immediate child of `mods`, change its `id` and `name`, then choose Rescan packages. A package consists of:
Copy `sdk/examples/native-trainer` into a new authoring folder, change its `id` and `name`, then package it with `tools/package_mod.py`. A ZIP contains:
```text
mods/my-mod/
my-mod.zip (archive root)/
mod.json
main.lua
assets/ # optional original text data or authored body clip JSON
```
`SKATE3_MODS` overrides the discovery directory; its default is the top-level `mods` folder **beside the running game executable**, independent of the working directory. The engine creates it if absent and logs its full path; the Mods list has an **Open mods folder** action. Only immediate child directories are packages. Archives are not loaded. `SKATE3_MOD_SETTINGS` overrides the writable preferences directory; the default is `settings/mods` beside the engine asset directory. Keep preferences outside packages.
`SKATE3_MODS` overrides the discovery directory; its default is the top-level `mods` folder **beside the running game executable**, independent of the working directory. The engine creates it if absent and logs its full path; the Mods list has an **Open mods folder** action. Immediate `.zip` files and non-hidden development folders are packages. ZIPs require mod.json at their root and are extracted automatically into a session cache. `SKATE3_MOD_SETTINGS` overrides the writable preferences directory; the default is `settings/mods` beside the engine asset directory. Keep preferences outside packages.
A minimal manifest:
@@ -94,7 +103,11 @@ Polling occurs at most every 500 ms and hashes bounded package contents (not jus
Successful world changes remove **all** mod objects and animation overrides before `world_changed` is delivered. Lua locals, settings and timers survive this event. Rebuild world-specific objects in the callback, cancel any world-specific timers, and clear stored positions when retaining world-specific positions. `sdk.snapshot.map.generation` identifies the current world, including reloading the same map. Failed map preparation retains the previous world and emits no world-change event.
## Complete SDK API
## SDK API
The callable names/signatures are also in [sdk/skate.lua](../sdk/skate.lua).
Vehicle commands, collider/mass/audio configuration and rider slots are documented
in [vehicle-sdk.md](vehicle-sdk.md). Trainer tuning is documented below.
All vectors are 1-based Lua arrays `{x,y,z}` in engine world coordinates, metres, Y up. All API functions below use dot calls, not colon calls. Mutable snapshot/settings tables are per-state copies: editing them does not mutate the engine. Retained snapshots are stale; query again inside callbacks.
@@ -183,7 +196,14 @@ Only install mods from authors you trust. Isolation is an engine fault-containme
Each Lua allocator is limited to 8 MiB and each entry evaluation/invocation to approximately 101000 VM instructions (hook every 1000 instructions). A long-running C library operation does not execute VM hooks; parsing, garbage collection, file I/O and host work are not hard wall-clock bounded. Aggregate mod load can still lower frame rate. There is no process isolation, hostile-filesystem race defense, hard timeout for native operations or guarantee against implementation bugs in dependencies. Package paths reject traversal and absolute paths, canonicalize within the root and reject escapes. Symlink entries are rejected during scanning; do not use junctions/links in mods. Do not mutate package trees maliciously while reading them.
Discovery inspects at most 128 sorted immediate directories; packages over 512 files/directories or 8 MiB of file data are rejected. Keep packages small. Lua allocator quotas do not include Rust/Bevy asset storage; visual count and package/file limits bound those separately. Source and plain UTF-8 data can be loaded with `sdk.read_text`; there is no general JSON decoder/import loader in Lua. Prefer ordinary Lua local functions and source literals for small data. Body JSON has its dedicated engine parser. This release does not load mod GLTF/meshes, textures, fonts, audio banks or general Bevy scenes. Cubes are original generated geometry and text uses the engine's existing font. Do not ship private Skate maps, banks, extracted character assets, sounds or captures; mods can rely on the player's installed engine data for allowed body slots without copying it.
Discovery inspects at most 128 sorted immediate packages (ZIPs or development folders).
Packages are limited to 512 files/directories and 64 MiB; ZIP compressed/expanded bounds
and path rules are in mod-packages.md. Lua allocator quotas exclude host asset storage,
which is bounded separately. `sdk.read_text` reads UTF-8 package data; there is no general
JSON decoder in Lua. Vehicle definitions and native animation JSON use dedicated host
parsers. Vehicles support embedded GLB models and synthesized engine audio. Arbitrary
scene/texture/font/audio-bank loading is not exposed. Text uses the existing engine font.
No camera-controller override, native audio command, customiser-profile mutation, physics force injection, collision-geometry insertion, climbing controller installation, map-selection command, score manipulation, networking, cross-mod messages or persistent Lua save store is exposed in API 1. Existing character customisation, native audio integration and private climbing loaders retain their own lifetimes. Broad engine-internal access is deliberately absent. Useful current extension types include HUDs, timers, route/training challenges, native-reset navigation tools, visual world annotations, grind/bail instrumentation and constrained original body animation replacement.
@@ -191,92 +211,35 @@ The runtime pins `mlua = 0.12.1`, with `lua54`, `vendored`, `serde`, `send`; Car
Distribute your package directory with source, original permitted data, a license/readme and engine/API requirements. Ship no preferences, local paths, dependency cache, executable or game assets. New compatible SDK functions may be added under API 1; removing/changing existing signatures requires a new API major. Unknown callback names and manifest fields are rejected now, so adding them to an old host fails visibly. Lua/package errors are visible under the selected mod; directory/schema errors are on the list page. If a hot edit fails, fix it and save or Reload. Reload and settings changes are intentionally usable while gameplay is paused.
## Validation and manual test checklist
## Validation and build
`cargo test --locked -p skate-mods --target x86_64-pc-windows-msvc` runs the standalone window-free Lua/schema suite. It covers the bundled Native Trainer, reload state reset, timers/replacement/order, settings persistence and validation, duplicate IDs, removal/invalid manifests, path restrictions, failure isolation, unload failure, instruction and memory limits. This is not rendered-gameplay validation.
```powershell
cargo test --locked -p skate-mods -p skate-vehicles
cargo run --locked -p skate-mods --example check_mod -- path/to/mod.zip
python tools/package_mod.py sdk/examples/native-trainer mods/native-trainer.zip
```
Use `Build-LuaSDK.ps1 -DependencyTargetDirectory <shared cargo cache>` to produce a uniquely named direct linker output, check SDK/CLI diagnostic strings without executing it, and record a staged SHA-256. Do not copy the generic executable from a shared Cargo target. `PLAY-LUA-SDK.bat` is provided exclusively for the user's manual run. No game, recomp, game asset-check mode, automated controller input, screenshot capture or game-based validation was executed for this SDK task.
The checker validates package bounds, manifest/settings and Lua syntax. It does not
execute callbacks, launch gameplay, or prove native animation/handling correctness.
ZIP integration tests cover asset reads, replacement, deletion, duplicate IDs, invalid
paths and expansion limits. Validate in-game behaviour manually after the headless checks.
Manual checks still required: open/scroll/back with keyboard/controller; enable Native Trainer; edit all setting types; hot-edit and reload; deliberately cause a Lua error then recover; add/remove/duplicate a package; disable and verify HUD/cubes disappear; switch maps and check marker/customiser/bail behavior; adjust pop/push settings, then disable and check stock behavior; inspect original authored animation replacement if available. Teleport destinations and animation visuals require deliberate author testing. Compilation and window-free runtime tests do not establish these gameplay results.
## Native trainer and examples
Integration footprint: `skate-mods` owns VM/schema/discovery/preferences; `skate-game/modding` owns menu and command adapter. Shared-file changes are plugin wiring (`main.rs`, `app.rs`), one pause-menu row, a read-only grind observation method and the existing animation evaluator/parser's owner-scoped overrides. Other owners should retain these narrow hooks when merging. Baseline is local merge `26f056c`; no original checkout, private asset tree or other task configuration was modified.
`sdk.trainer.apply(tuning)` installs one owner's reversible native tuning. Only one mod
can own it at a time. Numeric fields default to 1 on each call; `hold_fakie` defaults false.
Supported multipliers are pop, grind_pop, push_speed, push_power, braking, steering,
offboard_jump, grip, turn_power and manual_drag (0.25..4), and wobble (0..2).
`hold_fakie=true` suppresses the automatic stance switch. Unknown/nonfinite values fail.
Disable/reload/fault restores defaults; native contact/state conditions still apply.
These are parameter multipliers, not guarantees of measured speed or height.
### Recorded non-game results
`sdk/examples/native-trainer` demonstrates tuning, HUD text, checkpoints, timers,
breadcrumbs, beacons and configurable shortcuts. Defaults: F5 save checkpoint, F6 return,
F7 stopwatch, F8 clear transient data, F9 beacon. Its text data stays in the package.
`sdk/examples/mario-kart` demonstrates vehicles, controller binds, driving HUD, fitted
rider animations, steering, ramp collision tuning and engine audio. Proprietary assets
are local-only. Both examples are packaged as ZIPs in top-level mods/ for this build.
On 2026-09-08, all 13 original standalone SDK tests passed. The Windows release build and direct-output string/SHA-256 identity checks passed. The first release attempt diagnosed a stale shared `skate-core` artifact missing the merged respawn module; the helper now refreshes only this worktree's local crate source timestamps before compiling, preserving all dependency cache contents. Existing engine warnings remain. No interactive menu, native teleport or authored animation visual result is claimed.
Private manual-test setup uses this task's own real asset-root directory with a read-only junction to the prepared `private` data, and its own settings directory. Maps are referenced through a junction. The launcher therefore does not use another task's writable settings root. None of these references, prepared assets, personal paths or binaries are tracked.
## Native Trainer and pause-panel update
The bundled Speedometer and Route Trainer examples have been removed. `mods/native-trainer` is the single bundled mod, disabled by default. Its seven settings control pop height, grind pop height, push speed, push power, braking, steering and wobble. All start at **1.00x stock**. Pop/grind pop/push speed/push power/braking/steering range from 0.25x to 4x; wobble ranges from 0x to 2x, in 0.25 increments. There is no direct board-velocity write, invincibility or global gravity cheat.
The Mods list now has a right-aligned **ENABLED** badge in green or **DISABLED** in red; the selected mod has an explicit **ENABLE MOD / DISABLE MOD** action. A faulted mod is DISABLED with its error in the detail area. Whenever a mod is running, Escape shows its settings in a separate left-hand panel beside the normal pause controls. Click a value to increase/cycle it, or press **Tab / controller X** to focus that panel, then **Up/Down** to select and **Left/Right** to adjust. Tab/X returns focus to normal pause controls. Select the mod name to open its full enable/disable/settings page. Text settings open the full editor. Lists longer than ten rows page as selection advances.
`sdk.trainer.apply(tuning: table) -> nil` queues an owner-scoped native tuning override. Optional fields `pop`, `grind_pop`, `push_speed`, `push_power`, `braking`, `steering`, `wobble` have the ranges above and default to 1 on **each call**. Unknown fields and nonfinite/out-of-range values raise errors. Only one mod can own trainer tuning at a time; another owner is rejected and stopped. Disabling, reloading or faulting the owner restores default multipliers. Active tuning survives map replacement and is reapplied before further simulation. Existing momentum and an already-launched jump are not rewound; subsequent physics calculations use current tuning.
Source bindings: ground pop scales the current `GroundJumpMode` minimum/maximum heights before the existing ground-jump calculation. Grind pop scales the existing interpolated launch-height argument. Push speed scales the animation-provided push target plus `maximum_pushable_speed`; push power scales difficulty-specific `mode_speed_changes`. Brake strength scales the native foot/tail brake force and steering scales `general_scalar`. Wobble scales the existing mode amplitude multiplier. Ground profiles are derived anew from the stock selected difficulty/surface, so edits never compound and do not overwrite the customiser or installed data. Native floors, contact conditions, speed conservation and state transitions still apply: a 2x setting is a parameter multiplier, not a guaranteed 2x measured jump/speed.
Trainer integration is compile-checked and the standalone Lua/schema suite covers command validation and setting delivery. Native gameplay effects, controller focus and two-panel layout still require the user-run launcher. No game was launched for this update.
The trainer update passes 14 standalone Lua/schema tests and a static-CRT release build. Use **PLAY-NATIVE-TRAINER.bat** for this update; its executable and preferences are isolated from the previous SDK test build.
## Draggable per-mod windows
Use `PLAY-MOD-WINDOWS.bat` for the latest settings-window build (`Build-ModWindows.ps1`). The ordinary Escape menu stays in its original centered position. Each enabled mod receives its own compact window, initially at the left, with a title you can drag. The title-bar minus button collapses that window; plus expands it. Each setting has separate minus/plus controls to decrease/increase numbers (within the manifest bounds), cycle choices, or toggle booleans. String controls open the full editor. Values are still saved and delivered to Lua immediately.
Positions and collapse state are retained for the current process, including closing Escape and disabling/re-enabling a mod; they are not saved across game restarts. Clicking a window brings it forward. Tab/controller X cycles focus through enabled mods and then back to the main pause controls; arrows select/adjust within the focused mod. Each window shows a continuous, clipped settings list. Use the mouse wheel over the window to scroll; keyboard/controller selection scrolls the selected setting into view. The value minus/plus buttons edit that row without changing the other rows. Manage mod opens that mod's enable/disable page. The main pause controls remain mouse-clickable when a mod has keyboard focus. Dragging prevents accidental main-menu mouse actions. No game was launched to validate this UI update; its interactive behavior remains for user testing.
## Showcase 2.0 and top-level packages
`PLAY-SHOWCASE.bat` / `Build-Showcase.ps1` provide the current showcase build.
The build directory contains the executable and a real `mods/native-trainer`
folder beside it. Only original Lua/JSON/text documentation is copied there.
Existing edited mod files are preserved on rebuilding, with a diagnostic if they
differ from bundled sources. The launcher relies on executable-relative discovery;
it does not redirect mods to the checkout. `SKATE3_MODS` remains an explicit override.
`mods/README.md` documents this layout for players; use Open mods folder in-game.
Native Trainer 2.0 now has 23 declarative settings: eleven native multipliers plus
HUD/units/label/help, breadcrumbs/spacing/colour and five keyboard shortcut choices.
The four added `sdk.trainer.apply` fields are `offboard_jump`, `grip`, `turn_power`
and `manual_drag` (all default 1, finite 0.25..4). Off-board jump scales the existing
biped `JumpInput.height_792` before native launch preparation. Grip scales ground
wheel static/dynamic friction and slide-friction strength. Turn power scales the
heading controller's turn strength; manual drag scales its balance drag. No raw
velocity, gravity, mass or state-controller replacement was added.
The player snapshot now additionally includes `heading:number`, the animation-root
yaw in radians (atan2 of forward X/Z), for source-backed checkpoint orientation.
The expanded Lua example demonstrates this native teleport request with a separate
trainer checkpoint; F5 saves and F6 returns by default. F7 starts/stops the active-play
stopwatch, F8 clears transient session data/markers, and F9 places a visual beacon.
All shortcuts are configurable choices. Saving is intended on valid clear ground;
a blocked native return reports a mod error rather than bypassing engine gates.
The HUD displays board speed/peak, approximate travel, position and observed bail/
grind statistics. It is not the career scoring system or precise odometry. Optional
breadcrumbs reuse 24 keys; beacons reuse eight; all visual objects have no collision.
Timers remove temporary notices; settings update visuals immediately; map changes
clear checkpoint, stopwatch, markers and statistics. Script reload resets transient
Lua state while preserving compatible saved settings. `help.txt` demonstrates
package-relative text loading. No private game assets are shipped.
Standalone Lua tests exercise save/return command payloads, stopwatch creation,
world-change cleanup, bounded trail keys and immediate HUD/tuning settings. Actual
native gameplay effects and visuals remain user-run; no game process was launched.
Trainer 2.1 adds `sdk.trainer.apply { hold_fakie = true }` (default false).
It blocks the automatic idle/bump transitions into the native stance-switch animation; normal
steering, slide overrides and trick inputs remain native. Turning the setting
off, disabling the mod or retiring its owner restores stock behavior.
Enabled-mod windows default to 360 by 760 pixels, bounded by the display.
Drag the bottom-right `//` grip to resize (minimum 320 by 260); the settings
viewport grows with the window and keeps mouse-wheel scrolling. Window sizes
are retained in memory while the game runs, alongside position/collapse state.
Each enabled-mod settings viewport also has a right-side scrollbar. Drag its thumb or click the track to jump; wheel and keyboard scrolling update the same thumb. It resizes with the window and dims when all settings fit.
Vehicle SDK v1 adds Rapier vehicles and the Mario Kart example. See [vehicle-sdk.md](vehicle-sdk.md) for commands, animation hooks, limits and controls. Packages now allow 64 MiB for embedded models/animations.
All owner-created visuals are removed on disable, reload or fault. Native skating and
vehicle physics remain host-controlled; Lua does not receive memory pointers or World.
+17 -12
View File
@@ -57,8 +57,8 @@ and skin inverse-bind matrices must remain unchanged. Fix the calibration/source
## 4. Fit the approved poses to the kart
```powershell
& $blender -b --python tools/fit_kart_animation_preview.py -- --raw .local/raw-enter/Raw-Skate-Animation.blend --kart mods/mario-kart/kart.glb --mode enter --output .local/fit-enter
& $blender -b --python tools/fit_kart_animation_preview.py -- --raw .local/raw-exit/Raw-Skate-Animation.blend --kart mods/mario-kart/kart.glb --mode exit --output .local/fit-exit
& $blender -b --python tools/fit_kart_animation_preview.py -- --raw .local/raw-enter/Raw-Skate-Animation.blend --kart sdk/examples/mario-kart/kart.glb --mode enter --output .local/fit-enter
& $blender -b --python tools/fit_kart_animation_preview.py -- --raw .local/raw-exit/Raw-Skate-Animation.blend --kart sdk/examples/mario-kart/kart.glb --mode exit --output .local/fit-exit
```
This example script aligns the seated hips, eases torso lean and solves arms/legs to
@@ -82,7 +82,7 @@ bones, including reparented helpers. Generate the JSON list with the provided ut
```powershell
cargo run --locked -p skate-data --example vehicle_bone_names -- assets/private/stock/data/anim/OnBoard.abin .local/bone-names.json
& $blender -b --python tools/export_kart_rider.py -- --enter .local/fit-enter/Kart-enter.blend --exit .local/fit-exit/Kart-exit.blend --reference assets/private/skater.glb --bone-names .local/bone-names.json --output mods/mario-kart/rider.json
& $blender -b --python tools/export_kart_rider.py -- --enter .local/fit-enter/Kart-enter.blend --exit .local/fit-exit/Kart-exit.blend --reference assets/private/skater.glb --bone-names .local/bone-names.json --output sdk/examples/mario-kart/rider.json
```
The exporter preserves the glTF skin bind basis while converting Blender poses into
@@ -99,21 +99,26 @@ frames with a different rate. The output is a full native pose, not additive rot
## 6. Package, validate and playtest
Put `rider.json`, the embedded `kart.glb`, `vehicle.json`, `mod.json` and `main.lua` in the
project-root mods/mario-kart folder when using PLAY-MARIO-KART.bat. Standalone
builds default to an executable-adjacent mods folder. Set animations.file to rider.json, enter/exit to their
names, idle/drive/reverse/brake to drive for this example, and steer_left/steer_right to
the corresponding endpoints. See vehicle-sdk.md for limits, schema and lifecycle.
Put `rider.json`, the embedded `kart.glb`, `vehicle.json`, `mod.json` and `main.lua` in
`sdk/examples/mario-kart` (or your own authoring folder). Set animations.file to rider.json,
enter/exit to their names, idle/drive/reverse/brake to drive for this example, and
steer_left/steer_right to the corresponding endpoints. Package it with:
```powershell
python tools/package_mod.py sdk/examples/mario-kart mods/mario-kart.zip
```
The archive has mod.json at its root. The game extracts it automatically; do not edit
mods/.cache. See mod-packages.md and vehicle-sdk.md for limits, schema and lifecycle.
The host validates names, frame counts, finite rigid transforms and clip references at
load. The source loader tests cover malformed inputs; local validation also checked the
export against the actual stock bank and matched the final entry pose to drive. The
runtime applies the same native pose to whichever compatible character is selected.
`Build-VehicleSDK.ps1` stages a build and documentation. It preserves differing local
mod files; copy your approved changed files into the playable mod directory explicitly
when it reports them for standalone builds. The project launcher reads the top-level
mods folder directly. Reload/disable-enable the mod to load changed animation files.
`Build-VehicleSDK.ps1` stages a build, example ZIPs and documentation. Repackage approved
source changes to update the live ZIP. The game rescans and reloads enabled changed mods;
use Rescan packages to force discovery. Existing compatible settings survive updates.
Use PLAY-MARIO-KART.bat for a manual session. Check entry/exit from multiple vanilla
stances, steering in both directions, ramps, braking, reset, Escape, and mod disable.
Also try characters with different proportions; native-rig compatibility does not
+134
View File
@@ -0,0 +1,134 @@
# ZIP mod packages (SDK 1)
For agents, start at [sdk/AGENTS.md](../sdk/AGENTS.md). For function signatures, use
[sdk/skate.lua](../sdk/skate.lua); for callbacks/settings use [Lua SDK](lua-modding.md).
## Installation and updates
Drop a `.zip` directly into top-level `mods/` and launch `PLAY-MARIO-KART.bat`.
Open Escape → Mods, select it and enable it. New IDs start disabled. Open mods folder
opens this same directory. SKATE3_MODS overrides discovery; a standalone executable
defaults to its adjacent mods/. No manual extraction is needed.
The game scans approximately every 500 ms. Changed enabled packages reload after a
750 ms debounce; Rescan forces discovery. Replace the ZIP to update; remove it to
uninstall. Stable IDs preserve compatible settings, stored outside the ZIP. Reloading
restarts Lua and removes/recreates owner objects. Removing a driving vehicle releases
its rider. Disabling a mod keeps its package available for later use.
The loader supports editable folders too, but **never install a folder and ZIP with
the same mod ID**. All copies of a duplicate ID are rejected. Hidden directories are
not packages. The two editable examples are under sdk/examples; live mods/ contains
native-trainer.zip and mario-kart.zip. Archives remain local and untracked.
## Exact archive structure
```text
my-mod.zip
mod.json REQUIRED: manifest at archive root
main.lua REQUIRED if manifest.entry is main.lua
README.md recommended controls and credits
vehicle.json optional vehicle definition
assets/
kart.glb optional embedded vehicle model
help.txt optional package text data
animations/
rider.json optional native vehicle clips
```
Do not wrap this in a my-mod/ parent folder. `entry` may name a different relative
Lua file. Optional directory names are conventions, not magic. Paths in vehicle.json
are **package-root relative**, even if the definition itself lives in a subdirectory.
For the above layout, model=`assets/kart.glb`, animations.file=`animations/rider.json`.
Lua uses `sdk.vehicle.spawn('kart','vehicle.json',position,heading)` and
`sdk.read_text('assets/help.txt')`. A ZIP's filename does not determine its mod ID.
Include only files your mod needs. Vehicle GLBs must embed geometry, buffers and textures.
Rider JSON must match the installed native skeleton exactly; FBX and .blend are authoring
inputs, not runtime animation files. Engine audio is currently synthesized through the
vehicle definition; adding arbitrary WAV/MP3 files does not make them playable.
No executable binaries or external Lua modules are loaded.
## Minimal complete mod
mod.json:
```json
{
"id": "yourname.hello", "api": 1, "name": "Hello Skater",
"version": "1.0.0", "author": "Your name", "description": "Example HUD mod.",
"entry": "main.lua",
"settings": {
"message": { "type": "string", "label": "Message",
"description": "Text shown on screen.", "default": "Hello skater" }
}
}
```
main.lua:
```lua
local function draw()
sdk.ui.text('hello', sdk.settings.message)
end
return {
on_load = draw,
on_settings = draw,
on_event = function(event)
if event.name == 'world_changed' then draw() end
end,
}
```
The host removes owner visuals on disable/reload. Number settings require finite min,
max, positive step and an in-range default. Choice settings require choices. Unknown
manifest fields are rejected; there is no permissions or dependency declaration.
Read the full setting and callback reference before extending this example.
## Package and check
From the repository:
```powershell
python tools/package_mod.py sdk/examples/your-mod mods/your-mod.zip
cargo run --locked -p skate-mods --example check_mod -- mods/your-mod.zip
```
Requires Python 3.9+ and Rust/Cargo. The packer uses Python's standard ZIP library and
runs the authoritative Rust checker on both the source and ZIP before replacing the
output. It writes a temporary non-ZIP file first so discovery cannot see a half-written
archive. Optional `--target-dir .local/vehicle-tests` selects the Cargo cache.
The checker does not execute Lua callbacks. Passing it is not a gameplay test.
`Build-VehicleSDK.ps1` builds the host, packages both example sources and stages a
standalone copy with docs. Older mod folders in its private staging directory are
preserved under hidden .legacy-* names to avoid duplicate IDs. It does not delete
personal mod edits. Example-specific model/rider files must exist locally to package
an asset-complete Mario Kart; source checkout alone does not contain those assets.
## Limits and extraction rules
- At most 128 immediate packages; 512 ZIP entries and 512 extracted files/directories.
- At most 64 MiB compressed and 64 MiB expanded per ZIP/package.
- ZIP Store or Deflate compression. No encryption, links or special file entries.
- Relative forward-slash paths only, at most 240 bytes for ZIP paths. No traversal,
absolute/drive paths, Windows device names, forbidden characters or trailing dots/spaces.
- Duplicate paths, including case collisions, are rejected. No overwrite extraction.
- mod.json ≤64 KiB; Lua entry ≤256 KiB of UTF-8 source, no bytecode.
- Vehicle definition ≤128 KiB; model ≤32 MiB; vehicle clips ≤16 MiB.
- Text reads ≤256 KiB; authored vanilla body replacements ≤4 MiB. Other Lua quotas
and native validation rules are described in lua-modding.md and vehicle-sdk.md.
The loader checks/decompresses within bounds, then writes to a new session directory
under mods/.cache. Assets use this private directory transparently. Never edit or package
.cache. Changed ZIPs get new cache paths, so assets cannot silently reuse the old model.
Session caches are removed on normal shutdown; a crash may leave cache directories,
which may be deleted with the game closed. Do not delete live session caches.
A rejected ZIP shows diagnostics in the Mods UI/log and does not continue running an
outdated copy indefinitely. Cache contents do not become independent installed mods.
## Further examples
- [Native Trainer](../sdk/examples/native-trainer/README.md): tuning, HUD, markers, timers.
- [Vehicle SDK](vehicle-sdk.md): physics, controller input, ramp tuning and engine audio.
- [Mixamo workflow](mixamo-vehicle-workflow.md): calibrated native-rig animation authoring.
+10 -8
View File
@@ -19,18 +19,19 @@ Controller: **Y** enter/exit, **RT/LT** accelerate/reverse, **left stick** steer
**A** brake, **B** handbrake, **right-stick click** reset while driving. The reset bind can be changed to left-stick click in the mod settings. F10 remains the keyboard spawn shortcut.
Release controls before switching between driving and skating.
`mods/mario-kart/kart.glb` is generated from the user-supplied ZIP. It is excluded
`sdk/examples/mario-kart/kart.glb` is generated from the user-supplied ZIP. It is excluded
from Git. The importer preserves meshes/materials/embedded textures, normalizes
the model to metres and creates named wheel pivots. To regenerate:
```powershell
python tools/prepare_mario_kart.py C:/Users/Daddy/Downloads/kart-de-mario.zip mods/mario-kart
python tools/prepare_mario_kart.py C:/Users/Daddy/Downloads/kart-de-mario.zip sdk/examples/mario-kart
```
NumPy is required by the importer. Run Build-VehicleSDK.ps1 to build/stage the
executable and original mod sources. The helper preserves existing modified mod
files; copy updates manually when it reports a preserved file. PLAY-MARIO-KART.bat loads the top-level project `mods/` directory via SKATE3_MODS.
A standalone executable defaults to its adjacent mods directory. No model or game assets are committed.
NumPy is required by the model importer. Editable sources live in `sdk/examples/`;
`Build-VehicleSDK.ps1` packages them into top-level `mods/*.zip` and stages a release.
The project launcher loads top-level mods/; a standalone executable defaults to its
adjacent mods directory. See [mod-packages.md](mod-packages.md) for the ZIP layout,
validation, update rules and development-folder support. No game assets are committed.
## Lua API
@@ -76,7 +77,7 @@ mod errors; they retire the mod's vehicles instead of crashing the game.
## Vehicle definition
See `mods/mario-kart/vehicle.json` for a complete working definition. Coordinates
See `sdk/examples/mario-kart/vehicle.json` for a complete working definition. Coordinates
are metres, +Y up, +Z forward, +X driver-left, with heading in radians around +Y.
`half_extents` describes the outer chassis collision bounds. `mass` is kilograms. `model_scale`,
`model_offset` and `model_yaw` affect the model only, not its collider.
@@ -147,7 +148,8 @@ Driving slots loop; brake and reverse select their respective base clips, with d
as fallback. Steering continuously blends that base towards the left/right pose.
Vehicle phase changes blend with native bone-local interpolation; brake/reverse
base-clip changes currently cut, so author compatible seated poses.
Edit vehicle.json or rider.json and reload the mod to install new clips.
Edit vehicle.json or rider.json in the authoring folder, rebuild its ZIP, then reload
the mod (or wait for automatic rescan) to install new clips. Never edit .cache.
## Current boundaries
+13 -23
View File
@@ -1,30 +1,20 @@
# Mods
PLAY-MARIO-KART.bat loads this project-root folder directly through SKATE3_MODS.
Use this folder to add or edit mods; you do not need to find the executable in .local.
The in-game Open mods folder button opens the same location.
Drop community `.zip` packages here. PLAY-MARIO-KART.bat loads this project-root folder;
Escape → Mods lets you enable, disable, reload and configure them. Open mods folder
returns here. ZIPs must have mod.json at their root; the game extracts them automatically.
For a standalone game distribution, the default layout is beside the executable:
Editable example sources live in `sdk/examples/`. Rebuild after editing:
```
skate3rust.exe
mods/
native-trainer/
mod.json
main.lua
```powershell
python tools/package_mod.py sdk/examples/native-trainer mods/native-trainer.zip
python tools/package_mod.py sdk/examples/mario-kart mods/mario-kart.zip
```
Drop each community mod's folder here, then open Escape -> Mods. New mods start
disabled. Use Open mods folder to return here from the game. Added/changed files
are discovered automatically, or choose Rescan packages. Keep personal settings
out of this folder. SKATE3_MODS is an optional explicit directory override.
For a new mod, point your agent at **sdk/AGENTS.md**. Full structure and rules are in
**docs/mod-packages.md**; API signatures are in **sdk/skate.lua**.
Native Trainer Showcase is original Lua source, with stock tuning defaults.
Read its README for controls and examples of supported SDK capabilities.
Do not distribute private Skate assets with mods.
Current loader: unpacked mod folders containing mod.json and main.lua, plus assets.
ZIP files are not loaded directly yet. For sharing, ZIP one package with mod.json
at the archive root, then extract it into mods/<mod-name>/ before use.
A future ZIP installer can validate/extract packages while keeping folder support
for mod development. See docs/vehicle-sdk.md for asset types and package limits.
Development folders are also supported. Do not install duplicate IDs (including a
folder and its ZIP). Ignore .cache: it is managed by the loader. Settings live outside
packages. Standalone executables default to a mods folder beside the executable;
SKATE3_MODS is the explicit override used by this project's launcher.
+67
View File
@@ -0,0 +1,67 @@
# Agent guide: make a mod for this game
Use this file as the starting point when asked to make a mod. The current SDK is
embedded Lua 5.4, API major 1. Deliver a validated `.zip` in the project-root `mods/`.
Do not invent API calls or assume this is an unrestricted Lua/native-code plugin system.
## Read these sources in order
1. `docs/mod-packages.md`: ZIP structure, packaging and installation.
2. `sdk/skate.lua`: callable functions, argument types and bounds; annotations only.
3. `docs/lua-modding.md`: manifest, settings, lifecycle, quotas, callbacks and examples.
4. For vehicles, `docs/vehicle-sdk.md` and `sdk/examples/mario-kart/vehicle.json`.
5. For rider animations, `docs/mixamo-vehicle-workflow.md`. For character conversion,
`docs/mixamo-to-skate.md` and `docs/custom-models.md`.
Paths are relative to the repository root. Authoritative implementations are
`crates/skate-mods/src/{schema,vm}.rs`, `crates/skate-vehicles/src/definition.rs`, and
`crates/skate-game/src/modding/`. If a requested feature has no API, explain the gap
or implement/test a shared host API; do not fake an unsupported call in the mod.
## Workflow
- Create an editable folder outside live mods/, e.g. `sdk/examples/your-mod`.
Copy the smallest suitable example, not the whole repository. Use a unique ID
like `author.feature` and keep that ID stable across updates.
- Write mod.json with api=1, name, version, author, description and entry. Each
setting needs type, label, description and default, plus bounds/options for its type.
- Return a callback table from main.lua. Use owner-local stable keys for visuals,
read settings via sdk.settings, edge-detect held inputs, and clean/reset transient
state on world_changed. Refresh vehicle controls from on_fixed_update.
- Keep assets inside the package and use relative forward-slash paths. The entry
and vehicle filenames need not be the example names, but their references must match.
The game loads one Lua entry file; do not assume require/dofile or arbitrary file I/O.
- Build and validate:
```powershell
cargo run --locked -p skate-mods --example check_mod -- sdk/examples/your-mod
python tools/package_mod.py sdk/examples/your-mod mods/your-mod.zip
```
- For code/API changes, also run `cargo test --locked -p skate-mods -p skate-vehicles`.
Check the game crate and build with `Build-VehicleSDK.ps1` when the host changed.
The helper packages the two bundled examples; package a new mod separately.
- The checker checks structure, limits, schema and Lua syntax without running the mod.
It does not prove callbacks, render results, rig compatibility or physics behaviour.
Run appropriate headless tests. Do not launch the game automatically in this workspace;
provide `PLAY-MARIO-KART.bat` for the user to test and state what remains unverified.
- Report the ZIP path, controls/settings, validation results and relevant limitations.
Keep proprietary assets and resulting ZIPs out of Git; commit original source/docs.
## Available building blocks
- Player/input snapshots, native teleport requests, log/text, coloured cubes, timers,
package text reads and supported authored animation replacement slots.
- Native Trainer tuning: pop, push, braking, steering, grip, fakie hold and related
multipliers. Only one mod can own trainer tuning at a time.
- Vehicles: spawn/read/control/tune/enter/exit/reset/remove, Rapier map and vehicle
collision, raycast wheels/suspension, collider offsets/rounding, independent inertia
and centre of mass, throttle-responsive engine synthesis and live engine volume.
- Native-rig rider enter/exit/drive/idle/reverse/brake/steering slots. The host handles
steering blending, vanilla hand-offs, board hiding and shared motion interpolation.
- Automatic per-mod draggable/resizable/collapsible settings windows and enable badges.
No native DLL/EXE loading, arbitrary network/filesystem access, external Lua imports,
inter-mod dependencies, custom audio recordings or arbitrary rig playback are exposed.
Vehicle replay recording, native skater/vehicle impact-force bridging and multiplayer
are not implemented. Assets can be bundled but only documented asset types have loaders.
+8
View File
@@ -0,0 +1,8 @@
# Mod SDK
**Start here: [AGENTS.md](AGENTS.md).** Give this file to your coding agent when asking
it to make a mod. It links the package format, complete API, examples and validation.
Editable sources: `examples/`. Player-installable output: project-root `mods/*.zip`.
Use `tools/package_mod.py` to validate and package. The current integrated game launcher
is `PLAY-MARIO-KART.bat`; older feature-specific launchers are historical builds.
@@ -7,7 +7,7 @@ Controller: RT/LT accelerate/reverse, left stick steers, A brakes, B handbrakes.
The enabled mod shows keyboard and controller controls on screen, including the current reset bind. Toggle Show driving HUD in its settings to hide/show them, even before spawning.
The GLB is prepared from your supplied ZIP with tools/prepare_mario_kart.py and is
kept out of Git. To rebuild it: python tools/prepare_mario_kart.py PATH_TO_ZIP mods/mario-kart
kept out of Git. To rebuild it: python tools/prepare_mario_kart.py PATH_TO_ZIP sdk/examples/mario-kart
The script requires NumPy. No third-party model is downloaded or bundled in source commits.
Edit vehicle.json for dimensions, wheels, suspension, seat, exit and camera offsets.
@@ -26,3 +26,6 @@ Ramp tuning uses a shorter rounded chassis and a lower centre of mass, with mass
distribution independent of the collider. The shared engine_audio definition enables
throttle/speed-responsive synthesized engine sound; Engine volume in the mod window
controls it live (zero mutes). Full authoring guide: docs/mixamo-vehicle-workflow.md.
Author here, then run `python tools/package_mod.py sdk/examples/mario-kart mods/mario-kart.zip`.
The live ZIP loads automatically; do not edit mods/.cache. See sdk/AGENTS.md.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,4 +1,4 @@
# Native Trainer Showcase 2.0
# Native Trainer Showcase 2.1
One original mod demonstrating the actual SDK, with no game assets. Enable it in
Escape -> Mods. Its independent draggable settings window opens on Escape.
@@ -48,3 +48,6 @@ telemetry overlays and original visual extensions. The engine's full API referen
is docs/lua-modding.md and language-server annotations are sdk/skate.lua.
Hold fakie stance disables automatic fakie stance switching; normal steering and slides still work. Off by default. Escape settings windows can be resized by dragging the bottom-right // grip.
Authoring folder: sdk/examples/native-trainer. Package with tools/package_mod.py into
mods/native-trainer.zip. See sdk/AGENTS.md and docs/mod-packages.md.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+44
View File
@@ -0,0 +1,44 @@
"""Build a validated mod ZIP. Run from any directory; Rust performs authoritative checks."""
import argparse
import os
from pathlib import Path
import subprocess
import tempfile
import zipfile
ROOT = Path(__file__).resolve().parents[1]
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('source', type=Path)
parser.add_argument('output', type=Path)
parser.add_argument('--target-dir', type=Path, help='Optional Cargo build cache')
args = parser.parse_args()
source, output = args.source.resolve(), args.output.resolve()
if not source.is_dir() or not (source / 'mod.json').is_file():
parser.error('Source must be a folder with mod.json at its root')
if output.suffix.lower() != '.zip' or output.is_relative_to(source):
parser.error('Output must be a .zip outside the source folder')
command = ['cargo', 'run', '--quiet', '--locked', '-p', 'skate-mods', '--example', 'check_mod']
if args.target_dir:
command += ['--target-dir', str(args.target_dir.resolve())]
subprocess.run(command + ['--', str(source)], cwd=ROOT, check=True)
output.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(prefix='.package-', suffix='.tmp', dir=output.parent)
os.close(descriptor)
try:
with zipfile.ZipFile(temporary, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
for path in sorted(source.rglob('*')):
if path.is_symlink():
raise ValueError(f'Symlinks are not allowed: {path}')
if path.is_file():
archive.write(path, path.relative_to(source).as_posix())
subprocess.run(command + ['--', temporary], cwd=ROOT, check=True)
os.replace(temporary, output)
print(f'Ready: {output} ({output.stat().st_size:,} bytes)')
finally:
if os.path.exists(temporary):
os.unlink(temporary)
if __name__ == '__main__':
main()