mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-27 19:54:40 +00:00
Merge Lua modding SDK, packaged mods and vehicle support
This commit is contained in:
commit
57742e0e54
97 files changed
+9637
-42
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user