Merge Lua modding SDK, packaged mods and vehicle support

This commit is contained in:
chazmm committed 2026-09-08 23:46:35 +01:00
commit 57742e0e54
97 files changed
+9637 -42

No files matched your search

+267
View File
@@ -0,0 +1,267 @@
# Lua modding SDK 1
Start with [the agent instructions](../sdk/AGENTS.md) and [ZIP package guide](mod-packages.md).
Use `PLAY.bat` / `Build.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.
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 `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
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. 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:
```json
{
"id": "yourname.first-mod",
"api": 1,
"name": "My first mod",
"version": "1.0.0",
"author": "Your name",
"description": "An original example.",
"entry": "main.lua",
"settings": {
"greeting": {
"type": "string", "label": "Greeting",
"description": "Text shown on screen.", "default": "Hello skater"
}
}
}
```
```lua
local function draw()
sdk.ui.text('greeting', sdk.settings.greeting)
end
return {
on_load = draw,
on_settings = draw,
on_event = function(event)
if event.name == 'world_changed' then draw() end
end
}
```
Edit the greeting in the menu; Lua receives it immediately. Edit and save the script; enabled mods reload after files settle. Disable it and its text disappears. Add `sdk.log('loaded')` for a labelled log line. Use `sdk/skate.lua` as a Lua language-server library (Lua 5.4); do not execute this annotation file or package it as your entry point.
## Manifest and settings reference
Unknown manifest/setting fields are errors, including `dependencies`, `conflicts`, `permissions` and `enabled`. SDK 1 does **not** implement inter-mod dependency resolution or cross-mod imports. Packages must be independent; do not depend on another mod's callback side effects. Conflicting IDs reject all copies, regardless of directory ordering. Conflicting animation slots reject the later installing mod. Packages execute in ascending, case-sensitive ID order.
All manifest fields in the minimal example except `settings` are required. `api` is the integer `1`, an exact compatibility major, not a range. `version` is three unsigned integer components `MAJOR.MINOR.PATCH`. IDs and setting/object keys contain 1–64 ASCII lower-case letters, digits, dot, underscore or hyphen; `.` and `..` alone and Windows device-name stems such as `con` or `aux` are invalid. Use a globally distinctive author prefix. IDs identify saved preferences across directory moves and upgrades. Name and author are at most 120 bytes; name is nonempty. Description is at most 2048 bytes. Entry is a relative UTF-8 Lua source file, at most 256 KiB. Bytecode is not accepted.
`settings` is an object keyed by stable setting ID (at most 24). Rows sort by key. Every schema entry requires `type`, `label`, `description`, and `default`. Label is at most 120 bytes and description at most 512 bytes. Types:
| Type | Value | Schema fields |
| --- | --- | --- |
| `boolean` | JSON/Lua boolean | `default` true or false |
| `number` | finite JSON/Lua number | required finite `min`, `max`, positive `step`, and default in range |
| `string` | string without control characters, at most 128 Unicode characters | `default` string |
| `choice` | string equal to a listed choice | required nonempty `choices`, at most 32 strings, each at most 128 bytes |
`min`, `max`, `step` and `choices` are optional on other types and have no effect there. Number step controls the menu increment; values need not lie on that increment grid. Saved values are type/range validated. Missing, invalid or renamed values use defaults; removed keys are discarded on loading the current schema. Same-key compatible values survive version changes. Renaming a key intentionally resets it. There is no arbitrary Lua migration callback or persisted mutable Lua state in API 1. Changing the package ID creates a distinct preference identity. Reset delivers one `on_settings` notification per setting in key order. Preferences store `{ "enabled": boolean, "values": {...} }` as `<id>.json` through a sibling temporary file and rename. Save errors appear in the menu; the live edit may already have applied. Never distribute these preference files with a mod. Enable preference and actual running status differ after a fault: the error remains visible and the mod stays stopped until reload, a settled edit, or a future launch.
There is no generic gamepad rebinding setting yet. A choice containing supported key names, as an ordinary key-choice schema, is an ordinary validated setting; it does not remap native input.
## Lifecycle, ordering and scheduling
Each package owns a fresh Lua state. Entry code must return a table containing only these optional function fields:
| Callback | Argument | Timing |
| --- | --- | --- |
| `on_load` | nil | after source evaluation; top-level and load commands commit together |
| `on_update` | `{dt:number}` | once per active rendered Update, after engine animation; real seconds clamped to 0.25 |
| `on_fixed_update` | `{dt:number}` | after the existing fixed physics set; fixed time delta in seconds |
| `on_event` | event table below | at the indicated host boundary |
| `on_settings` | `{key:string,value:boolean\|number\|string}` | immediately after validation, with new `sdk.settings` already installed |
| `on_unload` | nil | before dropping a running state, including normal host shutdown; commands emitted here are discarded |
Update/fixed callbacks pause during the pause menu and replay. Discovery, settings, load/unload and world notifications remain available while paused. Fixed callbacks observe the just-completed simulation; a teleport request feeds the existing actor reset path on subsequent input publication. Lua never executes on rendering/GPU or map-worker threads. Mods menu input precedes map publication; maintenance follows publication in PreUpdate. Update callbacks run after `FrameSet::Animation`. There is no deterministic lockstep promise across frame rates, filesystems or machines; use fixed callbacks for tick-count logic. Do not expect several fixed callbacks in one render frame to observe distinct keyboard states.
Within each dispatch, callbacks run by ascending mod ID. Commands are collected at the host boundary and grouped by ascending owner ID, preserving that owner's emission order, with its sole teleport deferred until its reversible commands succeed. At most one teleport per owner batch is accepted; if multiple mods request one, the first accepted request makes later ones fail as busy. Host failures stop the offending mod and retire its current owned resources. Log output cannot be retracted. Already completed teleports are gameplay actions and are not reversed when a mod is disabled later.
No callbacks accumulate across reload: there is one returned lifecycle table and one isolated state per running package. Timers and all Lua locals reset. On disable/reload/fault, owned text, meshes, materials, timers and animation overrides are removed. Old queued actions are discarded. `on_unload` errors cannot prevent cleanup. A Lua error discards the entire failing invocation's queue; source/load errors commit no commands. Prior reversible changes made by that mod are also retired when it stops. Lua memory mutations before an error are immaterial because that state is dropped.
Polling occurs at most every 500 ms and hashes bounded package contents (not just timestamps). Changed valid files must retain the same hash for at least 750 ms, so usual effective latency is 1–1.5 seconds. Invalid manifests get a 750 ms grace interval before the old package is retired and diagnostics replace its listing. Deleted directories retire immediately at the next poll. New packages are discovered disabled unless their ID already has a saved enable preference. Explicit Rescan/Reload bypasses debounce: finish saving before using it. A failed reload does not retain the previous version; it remains visibly stopped. Fix/save or explicitly reload to recover. Polling runs on the main thread, so large packages or slow storage can hitch; no zero-hitch guarantee is made. A root-directory I/O error reports diagnostics; a missing root retires its packages.
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.
## 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.
| Function/value | Return and effect |
| --- | --- |
| `sdk.api_version` | integer `1` |
| `sdk.settings` | current validated setting-key → value table |
| `sdk.snapshot` | current observation table described below |
| `sdk.log(text:string)` | nil; queue labelled INFO diagnostic, maximum 2048 UTF-8 bytes |
| `sdk.read_text(path:string)` | UTF-8 string; synchronously read a package-relative file, at most 256 KiB; raises on missing/invalid/traversing/non-UTF8 path or file |
| `sdk.player.read()` | player snapshot table, never a live object |
| `sdk.player.teleport(position:Vec3, heading:number, on_board:boolean)` | nil; queue native manual actor reset; radians about Y, zero facing +Z; preserves the separate bail and session-marker checkpoints |
| `sdk.input.down(key:string)` | boolean; whether Bevy physical key name is held at snapshot time; unrecognized/unpressed names return false |
| `sdk.input.action(id:integer)` | number from published native gameplay action 64–81; errors outside range |
| `sdk.ui.text(key:string,text:string)` | nil; create/update owned plain screen text, max 1024 bytes; stable top-left rows sorted by owner/key, 19px font, 28px row spacing |
| `sdk.scene.cube(key:string,position:Vec3,size:Vec3,color:Vec3)` | nil; create/update owned visual cuboid; no collision, grind attachment or rigid body |
| `sdk.scene.remove(key:string)` | nil; remove owned cube/text; absent key is a no-op; cannot address another mod's objects |
| `sdk.time.elapsed` | active Update seconds since state creation, advanced after `on_update` |
| `sdk.time.after(key:string,seconds:number,callback:function)` | nil; replace keyed one-shot timer; delay 0–86400 finite seconds, key 1–64 bytes, at most 64 timers |
| `sdk.time.cancel(key:string)` | nil; remove timer; absent key is a no-op |
| `sdk.animation.info()` | `{bone_names:string[],slots:table<string,{fps:number,frame_count:integer}>}`; authoring metadata for installed supported body slots |
| `sdk.animation.replace(path:string)` | nil; queue installation of package-relative body-clip JSON, at most 4 MiB; replaces this mod's previous set; conflicting owners or invalid clips stop the mod |
Timers run after `on_update`, within its same instruction budget and transaction. Due timer keys sort lexicographically. A timer is removed before invocation. Newly scheduled timers normally wait for the next Update (replacing an already due, not-yet-delivered key can affect that key's pending delivery). Pausing stops the timer clock. Errors disable the owner. `sdk.time.elapsed` and snapshot fields should be treated as read-only by authors.
Cube positions and teleport positions must be finite, each component within ±100000. Size components must be finite in `(0,100]`. RGB components must be finite in `[0,1]`, interpreted as sRGB. Heading must be finite. Text and cubes share an owner-local key namespace: switching type replaces the old object. At most 64 visual objects per mod; update existing keys to avoid churn. At most 128 commands per Lua invocation. Invalid arguments raise Lua errors; exceeding limits stops the mod. Commands return nil on successful **queuing**, not proof of engine completion. Host rejection appears in the mod's error panel and log. Teleports reject map loading, replay and busy actor-reset conditions. This is a free-position command, not a terrain-safe spawn finder: authors must select valid destinations. It does not set velocities directly or bypass the native deferred reset/camera-cut sequence.
### Snapshot and event payloads
`sdk.snapshot` contains:
```lua
{
player = {
position = {x,y,z}, -- current animation-to-world root translation
velocity = {x,y,z}, -- physical board velocity; not biped walking speed
on_board = boolean, -- physical category != 500
state = integer, category = integer, -- native identifiers, not a trick name
bailing = boolean,
grind = {active=boolean,name=string,kind=integer,distance=number}
},
map = {name=string,generation=integer},
tick = integer, -- engine physics tick, may reset on map replacement
keys = { [physical_key_name]=true },
actions = {number,...}, -- 18 values, array slot 1 is native action 64
paused = boolean, replay = boolean,
animation = {bone_names={...},slots={...}}
}
```
Keyboard names follow Bevy 0.18 `KeyCode` debug names, e.g. `KeyA`, `KeyJ`, `F6`, `Space`, `ArrowUp`, `ShiftLeft`. Held-state reads do not consume input or inject native actions. Track the previous held state in a Lua local for edge detection. Native actions expose the current published mapping: 64/65 left stick axes, 66 left trigger, 67/68 right stick axes, 69 right trigger, 70/71 stick clicks, 72/73 shoulders, 74–77 face buttons, 78/79 Start/Back, 80/81 D-pad horizontal/vertical as provided by the existing expression map. These are observations, not a binding editor. Consult `crates/skate-core/src/input/gameplay_map.rs` for the retained source mapping; no new native input semantics are introduced.
`on_event` receives one of:
| `name` | Additional fields | Boundary |
| --- | --- | --- |
| `world_changed` | `map={name,generation}` | PreUpdate after successful map publication and mod resource retirement |
| `bail_changed` | `bailing:boolean` | after fixed physics when observed bail flag changes |
| `player_state_changed` | `previous:integer,state:integer` | after fixed physics when native state identifier changes |
| `grind_changed` | `grind={active,name,kind,distance}` | after fixed physics when accepted grind active flag changes |
Fixed event order is bail, state, grind, then `on_fixed_update`. These are sampled transitions, not a complete native event bus: multiple transitions within one engine tick can be coalesced. Initial observations can emit state changes from the host's initial zero/false baseline. No trick-completed, combo or score event is promised by SDK 1; a native state number must not be relabelled as proof of a scored trick. The separate scoring owner can add a source-backed API in a future compatible revision.
### Body-animation asset format
This reuses `animation_pose/authored_clips.rs`, not a parallel animation system. `sdk.animation.info()` supplies exact installed names and timing; author original global joint transforms with your own tools and write:
```text
{
"version": 1,
"bone_names": [exact installed skeleton order],
"clips": {
"supported slot name": {
"fps": exact installed slot fps,
"frames": [ [ [16 floats for bone 0], [16 floats for bone 1], ... ], ... ]
}
}
}
```
Matrices are affine, column-major 4×4 global joint transforms. Every frame must contain every bone, in exact name order. The existing parser requires finite matrices, positive determinants, scale components between 0.9 and 1.1, homogeneous last row approximately `(0,0,0,1)`, and exact stock frame count/FPS. It reconstructs board and helper targets and only substitutes its supported body range, leaving stock trajectory, board, timing, graph and channel metadata authoritative. Empty `clips` removes that owner's effective substitutions. Supported slots are `R_ANTIC_OLLIE_N_0_INTO`, `R_ANTIC_OLLIE_N_0_CYC`, `R_ANTIC_360SHUVIT_N_0_CYC`, `360FLIP_D_HIGH_G`, `360FLIP_D_HIGH_A`, `360FLIP_D_LOW_G`, `360FLIP_D_LOW_A`. No other slot is accepted. Call replacement at load and after world changes; parsing is synchronous and should not be repeated per frame. The pre-existing optional private replacement loader remains beneath mod overrides. This SDK does not activate or rename any disabled private clip or climbing file.
## Trust, assets, compatibility and limits
Only install mods from authors you trust. Isolation is an engine fault-containment boundary, **not** an OS sandbox or a promise to safely execute hostile code. There is no raw world/entity access, pointer API, FFI, raw socket/network access, writable Lua filesystem API, `io`, `os`, `debug`, `package`, `require`, coroutine library, `load`, `dofile`, `loadfile`, `pcall`, `xpcall`, or Lua GC controls. Standard base operations plus table/string/math/UTF8 are available. Removing protected calls prevents catching and endlessly retrying quota errors. Scripts must be UTF-8 source returning a callback table. Errors are reported rather than caught by mods.
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 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, arbitrary network messaging, 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.
The runtime pins `mlua = 0.12.1`, with `lua54`, `vendored`, `serde`, `send`; Cargo.lock pins transitive build inputs. `send` supports Bevy Resource storage, but host scheduling keeps execution on the main thread. Lua is compiled into the application using the vendored static build; no player-side DLL/Lua install is required. The dependency has no Bevy coupling and compiles against the engine's Bevy 0.18.1/local patches. Release uses Windows MSVC with `-C target-feature=+crt-static` and no default game features. Primary selection references: [mlua source/readme](https://github.com/mlua-rs/mlua), [Lua memory and hook API](https://docs.rs/mlua/0.12.1/mlua/struct.Lua.html). A lockfile entry for the optional LuaJIT source builder does not enable LuaJIT: the selected language feature is Lua 5.4.
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 build
```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
```
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.
## Native trainer and examples
`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.
`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. Both complete
example ZIPs are committed in top-level mods/, including the prepared kart model and rider clips.
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.
## Multiplayer
The integrated launcher includes native multiplayer. Matching enabled mod packages
automatically replicate world cubes and vehicles. Trainer/animation/teleport results
follow native player replication; UI and input remain local. `sdk.net.info()`,
`sdk.net.publish(key,value)` and `sdk.net.read(peer,key)` expose bounded, owner-scoped
latest state for custom Lua rules. This is not an exactly-once event API.
Read [Multiplayer mod SDK](multiplayer-mods.md) before authoring shared scores,
races or world objects. It specifies exact matching, string player IDs, limits,
collision authority, cleanup, host changes and supported versus local-only features.
## Combined engine integration
The Mods menu sits alongside Updates, Teleport, Multiplayer and both character
menus. Trainer grind-pop tuning applies to the current grind launch controller;
normal settings retain native behavior. Existing map-change cleanup and crash
reporting remain enabled. Protocol 5 is required for multiplayer mod state, so
all peers must update together. The bundled trainer and kart packages are included;
private authoring sources and per-feature development launchers are excluded.
+129
View File
@@ -0,0 +1,129 @@
# Mixamo to native vehicle animations
This is the workflow used for the working Mario Kart rider. The game plays a native
Skate skeleton pose on the currently selected compatible character. It does not swap
the rider to the mesh bundled in the Mixamo download. Other characters must first be
converted to the supported native render rig; arbitrary skeletons are not interchangeable.
Different proportions may still need contact adjustments to reach a wheel or pedals.
## 1. Keep a matched calibration pair
Use your owned stock `assets/private/skater.glb` as the native reference. Upload the
same stock character mesh to Mixamo, rig it there, and retain its returned rest/T-pose
with skin and skeleton. Convert that returned reference FBX to GLB without altering
its bind pose (Blender import FBX, export glTF/GLB with skin). This paired stock return
is the calibration source, not a Mario mesh or a posed entry/exit frame.
The converter's `--calibrate` path accepts a GLB. It verifies mesh bounds and records
the reference hash, source hash, scale and Mixamo bind transforms. Example:
```powershell
python tools/mixamo_to_skate/main.py returned-stock.glb --reference assets/private/skater.glb --profile calibration.json --calibrate --noninteractive
```
Keep calibration.json with that exact stock reference. For this local kart, the matched
profile is `your converter’s calibration.json`. Do not replace it
with a profile built from another character. Calibration transfers the change from a
Mixamo bind pose into the original native bind basis; copying raw rotations ignores
bone axes and is what produced the forward shoulders in the earlier attempt.
## 2. Download the animations from the same Mixamo character
Select Entering Car and Exiting Car on that same uploaded stock character. Download FBX
**with skin** for this pipeline, retaining its skeleton. The skin is useful for verifying
the reference and is not used as the in-game rider. Use consistent frame rate and no
keyframe reduction. Preserve root motion; these clips need to move between standing
and seated positions. Keep the original downloads untouched.
The accepted local inputs were `Entering Car (2).fbx` and `Exiting Car (2).fbx` in Downloads.
The earlier `(1)` files used another character and are not the calibrated source.
## 3. Verify the raw transfer before fitting a vehicle
Run Blender in background mode with the stock GLB and matched profile. Use fresh output
directories: the tools refuse to overwrite existing preview folders.
```powershell
& $blender -b --python tools/preview_mixamo_animation.py -- --source 'Entering Car (2).fbx' --reference assets/private/skater.glb --profile calibration.json --output .local/raw-enter
& $blender -b --python tools/preview_mixamo_animation.py -- --source 'Exiting Car (2).fbx' --reference assets/private/skater.glb --profile calibration.json --output .local/raw-exit
```
`$blender` is the path to your Blender executable. Open each `Raw-Skate-Animation.blend`.
Check the first, middle and final frames, especially shoulders, wrists, hips and feet.
The tool checks the calibration hash and source bind compatibility. Do not bypass a
mismatch by forcing rotation offsets into the native rest skeleton. Native rest bones
and skin inverse-bind matrices must remain unchanged. Fix the calibration/source first.
## 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 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
wheel and footwell targets. It bakes a separate fitted action, retains the raw action,
checks unchanged rest bones and writes a fit report. Review both .blend files before
export. Feet need clearance over the side pod, hands need a reachable wheel position,
and the last entry pose should match the first exit pose. The seated pose is also
used for the driving loop. Fingers are limited by the native character's hand rig.
Targets in this script use Blender Z-up: seat `(0, .20, .73)`, wheel grips
`(+/-.19, -.36, .90)`, feet `(+/-.17, -.55, .33)`; the preview kart is raised `.55`.
These are **kart example values**. For another vehicle adjust alignment, contact targets,
clearance and torso lean in the authoring script or bake equivalent edits in Blender.
Do not change rest bones, and keep the fitted armature object transform identity.
## 5. Export in the game's exact skeleton order
Obtain the order from the installed native bank (or `sdk.animation.info().bone_names`).
Do not use the larger reference list in the vendor importer: this build's bank has 36
bones, including reparented helpers. Generate the JSON list with the provided utility:
```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 sdk/examples/mario-kart/rider.json
```
The exporter preserves the glTF skin bind basis while converting Blender poses into
column-major native matrices. It subtracts the preview seat, exports enter/exit clips,
uses the final entry pose as drive, and generates left/right steering endpoints with
length-preserving arm reach. It renders both endpoints for inspection. The host blends
between them continuously with steering input. For another cockpit, change the exporter
seat, steering-wheel centre/axis and steering angle alongside the fitted scene targets.
The example's game seat is `[0, .18, -.20]`: convert the Blender seat to Y-up and subtract
the preview kart's .55 m lift. Model fitting offsets are not an excuse to alter native
bone axes. Export fps comes from each scene; retain its timing instead of relabelling
frames with a different rate. The output is a full native pose, not additive rotations.
## 6. Package, validate and playtest
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.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
promise exact contact for every body size. Gameplay is never launched by these tools.
Source code and authoring scripts are reusable. The supplied Mario Kart model and
fitted rider.json are committed as the bundled example, including mods/mario-kart.zip.
Raw FBX/Blender authoring files and the stock skater mesh remain local and ignored.
+142
View File
@@ -0,0 +1,142 @@
# 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.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.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. Both installable example ZIPs are committed, along with the
Mario Kart model/rider source assets, so a fresh checkout can use or rebuild both mods.
ZIP metadata is deterministic; an unchanged source folder produces identical package bytes.
## 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.
## Multiplayer
Use matching enabled ZIPs and the same integrated build on all peers. World objects
and vehicles synchronize through the SDK; Lua shared rules use `sdk.net`.
See [Multiplayer mod SDK](multiplayer-mods.md) for ownership, collisions,
late joins and shared-state examples.
+139
View File
@@ -0,0 +1,139 @@
# Multiplayer mod SDK (protocol 5)
Use the same game build and map on every PC. Install and enable the same mod ZIP
on each PC for that mod's shared objects. Launch `PLAY.bat`, then use
**Escape > Multiplayer** to host or join. The build includes the optional Steam
relay; Steam must be running for Steam lobbies. Direct local testing remains
available. `Build.ps1` builds and stages both executables without launching them.
## What synchronizes automatically
| SDK feature | Multiplayer behavior |
| --- | --- |
| `scene.cube` / `scene.remove` | Owner-scoped shared visual objects; updates, removal and late joins supported. Cubes remain non-colliding. |
| `vehicle.spawn/remove/reset/tune/control` | Matching package supplies the model and definition. Owner sends chassis pose, velocities, wheel state, controls, tuning and occupancy. Other peers render and collide with replicas. |
| Vehicle entry/exit/steering | The final displayed native skeleton, including stance blending and arm steering, is replicated. Seated riders follow the same car render sample. |
| Vehicle engine audio | Each occupied car has a smoothed engine voice; other players' cars attenuate with distance. |
| Vehicle crash/inversion | The owning player decides the ejection, preserving pre-impact momentum. Their native wipeout/ragdoll then uses existing player replication. |
| Trainer / animation replacements / teleport | Run on the owning player's game. Their resulting movement, pose and ragdoll replicate through native player networking. Remote tuning never overwrites your local controls. |
| UI, mod settings windows, input, timers and logs | Local to each player. No duplicate remote input execution. |
| Custom Lua scores, checkpoints or game rules | Use `sdk.net.publish/read`; Lua variables alone are not shared. |
This applies to the SDK, not a special Mario Kart network script. Existing trainer
beacons/trails and vehicle mods use these paths without additional networking code.
The imported multiplayer renderer still displays remote players using the stock
skater; custom character appearance transfer is a separate feature.
## Matching and ownership
A world/state record carries its mod ID and a portable fingerprint of package-relative
file names and file contents, including scripts and assets. Absolute installation
paths and ZIP timestamps do not participate. Different assets or scripts count as
a different package even if the version text is unchanged. The receiver must have
the matching package enabled and running. Mismatches appear in the multiplayer HUD.
No ZIP download, remote Lua execution, or arbitrary remote asset path is allowed.
Object identity is `(player ID, mod ID, key)`. Both players may spawn `kart` without
replacing each other's car. The spawning player remains the simulation owner and
driver; transferring a vehicle to another player or sharing a passenger seat is not
implemented. Disconnecting, disabling a mod or removing an object removes its
replicas. Resetting a car updates its existing replica. Joining later receives the
current state rather than replaying callbacks. Stable keys should be reused.
Settings are personal. Shared vehicle tuning follows its owner's settings. For a
single shared course or scoreboard, have one designated peer (usually the lobby
host) own it; do not run the same authoritative rule independently on every peer.
Check `local_id`, `active` and `is_host` each update because connections and hosts
can change. Objects owned by a departed peer disappear; a replacement host must
recreate host-owned game rules/objects if your mod needs that behavior.
## Shared-state API
```lua
local previous_id
return {
on_update = function(e)
local net = sdk.net.info()
-- Keep player IDs as strings; they are 64-bit and can exceed Lua's exact range.
if previous_id ~= net.local_id then
previous_id = net.local_id
sdk.net.publish("ready", {ready=true})
end
local mine = sdk.net.read(net.local_id, "ready")
-- net.states[sdk.mod_id] maps peer ID strings to that peer's state keys.
for peer, values in pairs((net.states or {})[sdk.mod_id] or {}) do
local ready = sdk.net.read(peer, "ready")
-- Display results locally; only mutate your own authoritative state.
end
end
}
```
- `sdk.net.info()` returns `{active, local_id, is_host, states, status}`. Offline ID
is `"0"` and `is_host=true`. State is still readable locally while offline.
- `sdk.net.publish(key, value)` stores this mod's latest value under the local
player. Values can be JSON-compatible scalars/tables, at most 512 encoded bytes.
Keys follow normal SDK key rules (1..64 characters). `nil` clears a value.
- `sdk.net.read(peer_id, key)` reads only this mod's value for that peer. Missing
peers/keys return `nil`. Poll this from an update callback; no remote callback
runs and there is no exactly-once event API.
- Publish durable state, such as a counter or `{sequence=12, checkpoint=3}`, rather
than a one-frame pulse. Fast updates may coalesce into the newest value.
- A Lua callback's commands still commit only after it succeeds. Failing callbacks
do not send their queued state changes.
## Collision and authority contract
Native players already use owner simulation. Cars follow the same model: your
Rapier world owns your cars, and remote cars use kinematic collision proxies.
Chassis collisions affect your local simulation and can trigger a local rider bail;
the resulting motion is sent back. Two locally owned cars use ordinary dynamic
Rapier contacts. Remote cars include the occupied rider capsule.
Native skating/board collision queries include nearby car chassis. Rapier also
receives nearby native player/board proxies (small spheres around active physical
parts). This bridges the two physics engines; it is an approximation of the native
articulated hitboxes. A seated driver's native standing hitboxes are disabled in
network snapshots so they cannot collide at their old skating position.
Remote positions use bounded extrapolation (150 ms), limited correction velocity,
and fixed-step render interpolation. Rider attachment uses the car's displayed
transform. Large resets snap instead of drawing a car through the map. This is
not a host-authoritative shared Rapier world: delayed impacts can differ across
peers and are corrected from each owner's next state. It does not promise exactly
conserved cross-client momentum or competitive anti-cheat.
## Bounds and failure behavior
Application datagrams share the native lobby's byte budget and MTU (1200 bytes).
Native body/pose traffic has priority. Latest records are acknowledged, resent
with varied retry intervals after loss, and relayed only by authenticated owners.
Records contain at most 1024 value bytes and 128 key bytes; 256 wire keys per player
per session includes deletion tombstones. SDK data has a 128-live-record limit;
vehicle state is additional. Reuse keys rather than generating unlimited unique
IDs. A full network record includes metadata, so oversized world descriptors are
reported in the multiplayer HUD instead of being truncated. Typical kart and
trainer records fit. Heavy object loads lower effective update frequency.
The existing limits remain 8 vehicles per mod and 32 local vehicles. Up to 288
remote vehicle replicas are admitted across the other nine players. No peer may
write another peer's state; stale revisions and oversized packets are rejected.
Leaving a lobby clears remote replicas. Map changes leave the lobby and reset the
native collision schema.
## Verification
Run headless checks (these do not start the game):
```powershell
cargo test --locked -p skate-net -p skate-mods -p skate-vehicles
cargo check --locked -p skate-game --bin skate3rust --no-default-features
```
Coverage includes loss/reordering, late joins, tombstones/disconnects, ten-player
record replication, spoofed owners and bounds, portable asset fingerprints, Lua
state transaction rollback, remote chassis collisions and crash ejection. Manual
follow-up: two PCs with matching enabled ZIPs; spawn separate cars, drive into one
another, hit a standing player, bail, reset, join late, disable/re-enable a mod, and
check trainer markers and mod mismatch messages. No game was launched as part of
these automated checks.
+305
View File
@@ -0,0 +1,305 @@
# Vehicle SDK v1
`skate-vehicles` pins Rapier 3D 0.35.3 and owns an independent, fixed-step rigid-body
world. The existing skating solver remains authoritative outside vehicles. Rapier
uses the installed map's collision triangles and resolves chassis collisions with
the map and other vehicles. Wheels use Rapier raycast suspension. Each host tick
is split into substeps of approximately 1/120 second or smaller.
## First mod: Mario Kart
Run PLAY-MARIO-KART.bat, enable **Mario Kart** in Mods, resume and press **F10**.
Walk/skate within four metres and press **E** to enter. **W/S** accelerate/reverse,
**A/D** steer, **Space** brakes, **left Shift** handbrakes, **R / right-stick click** rights/resets.
**E** exits once speed is below 3 m/s. F10 replaces a parked kart or resets an
occupied kart. Live engine, speed, brake, steering and grip controls appear in its
own draggable, resizable, scrollable mod window.
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.
`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 <kart-source.zip> sdk/examples/mario-kart
```
NumPy is required by the model importer. Editable sources live in `sdk/examples/`;
`Build.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
All keys belong to the calling mod. Commands cannot control another mod's vehicle.
The host allows eight vehicles per mod and 32 total. Definitions are validated
before allocation. Commands from failed Lua callbacks are discarded. Disabling,
removing or reloading a mod removes its vehicles and releases its driver.
```lua
sdk.vehicle.spawn('kart', 'vehicle.json', {x, y, z}, heading_radians)
sdk.vehicle.enter('kart')
sdk.vehicle.control('kart', {
throttle = 1, -- -1 reverse through +1 forward
steering = 0.25, -- -1 right through +1 left
brake = 0, -- 0..1
handbrake = false,
})
sdk.vehicle.tune('kart', {engine_force=1800, max_speed=25, tire_grip=3})
local car = sdk.vehicle.read('kart')
sdk.vehicle.exit('kart')
sdk.vehicle.reset('kart', {x, y, z}, heading_radians)
sdk.vehicle.remove('kart')
```
`read` returns nil for an absent vehicle, otherwise position, quaternion rotation
(x/y/z/w), heading, signed speed in m/s, occupied, ready and phase. Phases are
`parked`, `entering`, `driving`, `exiting`. Snapshots update at host callback boundaries;
a spawn command is visible on a later callback. `input()` returns normalized
keyboard/controller throttle, steering, brake, handbrake, interact and pad_buttons.
Do not pass the full input table to `control`; copy only its four control fields.
`control` must be refreshed; after 0.25 seconds without a command, throttle clears
and a parking brake is applied. Controls are ignored during enter/exit animations.
`tune` supports optional engine_force (0..100000 N), max_speed (1..100 m/s),
engine_volume (0..1), brake_impulse (0..10000), steering_angle (0.01..1.2 radians), tire_grip (0.1..20).
Maximum speed limits engine application; it is not an absolute downhill speed cap.
Events passed to `on_event`: `vehicle_spawned`, `vehicle_entering`, `vehicle_entered`,
`vehicle_exited`, `vehicle_exit_blocked`, `vehicle_reset`, `vehicle_removed`,
`vehicle_bailed`.
Each includes owner and key. Map changes use the existing `world_changed` event;
spawn new vehicles in the new world as needed. Invalid assets/commands appear as
mod errors; they retire the mod's vehicles instead of crashing the game.
## Vehicle definition
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.
Each wheel defines a chassis-local suspension mounting `position`, radius,
steering/driven flags and optional unique model node name. Support is 2..8 wheels
with at least one driven wheel. Wheel nodes should have local +Y steering and +X
axle axes. Pivots must be correctly positioned; the host adds suspension travel,
steering and spin to their rest transforms. The supplied kart importer does this.
Seat and exit are chassis-local offsets. Exit checks the nearby fixed ground and
standing clearance; if unavailable, it returns the rider to the saved entry spot.
The camera follows using camera_distance and camera_height. Model GLBs must embed
all textures and buffers; filesystem/network references inside GLBs are rejected.
A package may total 64 MiB; a model is limited to 32 MiB and an animation file to
16 MiB. Paths stay within the owning mod folder.
## Rider animation support
The local kart package supplies fitted clips. Other definitions default their animation slots to null. Without a matching
clip, the skater is hidden and entering/exiting completes immediately. This avoids
showing an unrelated standing/skating pose on the kart.
Set `animations.file` to a package-relative JSON file and set slot names:
```json
"animations": {
"file": "rider.json",
"enter": "get_in",
"exit": "get_out",
"drive": "drive_loop",
"idle": "seated_idle",
"reverse": "look_back",
"brake": "braking",
"steer_left": "turn_left",
"steer_right": "turn_right"
}
```
The file is:
```json
{
"version": 1,
"bone_names": ["EXACT_NAMES_FROM_sdk.animation.info().bone_names"],
"clips": {
"drive_loop": {
"fps": 30,
"frames": [["ONE_16_NUMBER_MATRIX_PER_BONE"]]
}
}
}
```
The strings in the example frames/bone list are explanatory placeholders.
Actual frames must contain one 16-number, column-major, native model-space matrix
per bone, in the exact native skeleton order. Matrices use metres and +Y up,
matching the existing authored animation convention. They are full poses, not
additive deltas. Obtain names from `sdk.animation.info()`; do not invent bone names.
The renderer performs the existing native-to-GLB skin-basis conversion. Author the
pose relative to the seat offset; enter/exit root motion is visual and does not
move the Rapier chassis. The native skater is restored through its teleport path
when exiting. Files with a mismatched skeleton, invalid matrices or missing named
clips are rejected. Maximum 32 clips, 3600 frames per clip, 1..120 fps.
Enter/exit clips play once and their frame count/fps determines transition duration.
Driving slots loop; brake and reverse select their respective base clips, with drive
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 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
The native skating simulation is suspended while driving and resumes on exit.
Offline vehicle time pauses with Escape; online physics continues while menus are
open and unattended controls fall back to braking. Replay entry and session-marker
controls are blocked while driving. Vehicle motion is not recorded in skating replays.
Vehicles collide with the map, other cars and nearby native player/board proxies.
Native skating collision queries include car chassis. Matching enabled packages
replicate vehicles, tuning, wheel/rider poses, occupancy and engine audio. Crash bails
return to normal native ragdoll networking. See [Multiplayer mod SDK](multiplayer-mods.md)
for owner simulation, latency limits, hitbox approximations and late joins. Driving
another player's vehicle, passengers, weapons, damage and race rules are not built in.
Verification uses headless Rapier tests and window-free Lua tests. The game is
not launched automatically; rendering, entry/exit and handling need manual playtesting.
### Rider transitions and steering
The host blends vehicle phase changes over 0.4 seconds and the return to vanilla over
0.5 seconds, using the game's bone-local translation/scale lerp and quaternion slerp.
Steering input is smoothed and blends the current driving pose towards steer_left or
steer_right continuously, so these slots should contain compatible seated poses.
Vehicle camera hand-offs ease over 0.5 seconds. While driving, chassis, wheels, rider
and camera share the same interpolated fixed-step motion sample, avoiding relative
jitter from separate camera damping. Resets discard the old motion sample.
The board root is scaled away during vehicle playback and restored by the vanilla blend.
The local Mario Kart package supplies fitted entry/exit, seated and steering clips;
its fitted rider.json is included in source control with the prepared kart model.
## Ramp clearance and mass distribution
These are shared definition fields, available to any mod, not Mario-specific code:
| Field | Meaning | Default |
| --- | --- | --- |
| `collider_offset` | Chassis-local collision centre, metres | `[0,0,0]` |
| `collider_rounding` | Rounded edge radius; below 95% of smallest half extent | `0` |
| `chassis_friction` | Body contact friction, 0..2 | `0.3` |
| `center_of_mass` | Chassis-local mass centre, metres | `[0,0,0]` |
| `inertia_half_extents` | Box dimensions used for mass distribution, independent of contact shape | null: use half_extents |
Rounding stays inside half_extents. Shorten low front/rear overhangs so wheel contact
can lift the chassis before the body catches the ramp. Keep the collider large enough
to protect the cockpit; the visual model does not define collision. Inertia dimensions
let a shorter collision shape retain stable pitching/rolling behaviour. A lower centre
of mass helps resist nose-diving under braking. These fields require respawning.
The example passes headless 20/30/40-degree incline tests plus braking and steering tests;
this does not guarantee every map seam or vertical ledge is traversable. Wheels remain
raycasts, and vertical walls remain obstacles. No teleporting or artificial ramp boost is used.
## Engine sound
Opt in per definition:
```json
"engine_audio": { "enabled": true, "volume": 0.45, "idle_pitch": 0.7, "max_pitch": 2.8 }
```
The host synthesizes an original layered exhaust pulse with filtered noise. No downloaded
sound asset or extra file is required. It plays for occupied local and remote vehicles, with distance attenuation for remote engines; parked engines
are silent. Pitch follows speed and absolute throttle, including reverse and free revving
while stopped. Full throttle increases volume; releasing it smoothly drops the engine back
towards idle. Pitch/volume changes are smoothed, Escape/replay fades it silent, and exiting
or disabling the mod fades/removes the voice. This is a driver-focused mono sound, not a
spatial multi-car mixer or a simulated gearbox. There is currently no custom sample slot.
Volume is 0..1, idle_pitch 0.25..2, max_pitch idle_pitch..5 (multipliers of the 80 Hz
synth fundamental). All are validated. `sdk.vehicle.tune(key,{engine_volume=0.5})`
changes volume live; use zero to mute. Mario Kart exposes this in its mod settings.
## Authoring and reuse
See [Mixamo vehicle animation workflow](mixamo-vehicle-workflow.md) for the full sequence,
calibration, Blender fitting, native export and packaging commands. The host's animation
slots, steering blend, stance hand-offs, board hiding and shared motion interpolation
apply automatically to every vehicle definition. The example fitting scripts contain
kart geometry targets; adjust those targets for another vehicle without changing the host.
## Rider hitbox and crash ejection
Every vehicle can use the shared `rider_safety` definition. It is enabled by default;
explicit example values are shown below. Changes require respawning the vehicle.
```json
"rider_safety": {
"enabled": true,
"offset": [0, 0.5, 0],
"radius": 0.25,
"half_height": 0.25,
"crash_delta_v": 6,
"hit_impulse": 180,
"inverted_up_y": -0.2,
"inverted_seconds": 0.2,
"eject_up_speed": 2
}
```
A solid capsule is attached to the occupied chassis, centred at `seat + offset`.
It covers the seated torso/head, including roof/ground/overhang contact when rolled.
It is not a sensor: contacts affect the vehicle. It adds no mass, so existing mass and
inertia tuning remains authoritative. Empty vehicles have no active rider collider.
The capsule is an approximation, not separate animated hand/foot hitboxes; adjust it
for your cockpit and expected character proportions. It is active through entry/exit
as well as driving, and is removed from collision when ownership ends. Disabling
rider_safety disables both this collider and automatic ejection.
Ejection triggers on any of:
- A chassis collision changing linear velocity by at least crash_delta_v in a physics
substep. This is delta speed in m/s, not total driving speed; braking/ordinary ramps
should not meet the default threshold.
- A rider-capsule contact impulse at least hit_impulse, in N·s. This is an impulse
threshold, not force in newtons, and is evaluated on actual Rapier contacts.
- Chassis-local up having world Y below inverted_up_y continuously for inverted_seconds.
The timer clears when upright again. Reset clears pending impacts/inversion history.
The host stops driver controls, releases occupancy and fades engine audio. It queues
an actor reset just above the seat, enters the native Wipeout ragdoll after normal reset
initialization, and seeds the native body and board velocities. Seat point velocity
includes the vehicle's angular motion. Pre-impact velocity is retained when the car
stops abruptly; if a hit accelerates the car from rest, the stronger post-impact point
velocity is used instead. A small world-up launch speed helps clear the seat. Output
linear/angular speeds are bounded to 60 m/s and 15 rad/s. This is a momentum hand-off,
not a fully coupled passenger rigid-body simulation or an exact seated ragdoll pose.
The crash visual hand-off lasts 0.12 seconds; normal entry/exit blends remain unchanged.
Native map collision and ordinary bail recovery take over. After release, native rider
collision against the Rapier car itself is still not bridged, as described above.
Lua receives **vehicle_bailed** instead of the normal vehicle_exited event:
```lua
on_event = function(event)
if event.name == 'vehicle_bailed' then
-- event.key is this mod's vehicle key; reason is crash/rider_impact/inverted.
sdk.log('Ejected: ' .. event.reason)
-- event.position: world seat position at detection, metres
-- event.velocity: carried world velocity including launch lift, m/s
-- event.angular_velocity: world angular velocity, rad/s
end
end
```
The vehicle remains spawned and can be reset or entered again after bail recovery.
Use vehicle.read().occupied/phase for ownership; do not keep sending driving input
while unoccupied. Physics events are delivered through the existing mod callback queue.
The shared Rust simulation also exposes set_occupied and take_ejection for host tests;
Lua cannot directly write native ragdoll bodies or bypass the hand-off lifecycle.
Bounds: offset components ±5 m, radius 0.1..1 m, half_height 0.05..1 m (capsule cylinder
half-length; total height is 2*(half_height+radius)), crash_delta_v 1..50 m/s,
hit_impulse 10..10000 N·s, inverted_up_y -1..0.5, inverted_seconds 0.05..3 s,
eject_up_speed 0..10 m/s. Nonfinite values are rejected. Headless tests cover wall
momentum retention, overhang rider hits and sustained inversion versus empty vehicles.
Native rendering/recovery and unusual map geometry still need manual playtesting.