Files
RustRaptor/docs/modding-api.md

30 KiB
Raw Permalink Blame History

Rust modding API, version 1

This is an experimental API for the Rust engine. It provides local content packages, deterministic Rhai callbacks, checked commands, an in-game manager and save compatibility checks. The rewrite is still undergoing gameplay parity work; this document describes the implemented API, not a claim that the full remaster has passed its acceptance checks. API compatibility is currently exact: api_version = 1.

Try the example

mods/aurora/ adds two projectiles, a pulse cannon, a repair cell, two enemies, a mission and a campaign. The weapon adds angled shots in Rhai; enemy movement, item rewards and mission objectives also use scripts. Its gameplay sprites reference original catalog asset IDs. The package also includes authored SD/HD mission-briefing artwork for the menu; these illustrations do not replace its gameplay sprites.

Validate the package against your original game data:

cargo run --no-default-features -- --data data --mods mods --validate-mods aurora

Run its mission without a window:

cargo run --no-default-features -- --data data --mods mods \
  --enabled aurora --headless aurora:field_trials --ticks 700

The manager discovers the bundled mods/ folder by default when no user mods folder exists. You can also copy mods/aurora into the mods/ directory under $XDG_DATA_HOME/RaptorReplica (default ~/.local/share/RaptorReplica/mods/), or start the Rust engine with --mods mods. Open Mods, toggle Aurora, then select Apply selections. Select its mission from the mission menu. Existing pilots can add mods while preserving required versions, hashes and relative load order; the next save records the expanded active set. Disabling, changing or reordering required mods shows compatibility details and asks whether to load anyway. Missing equipment and progression remain saved until their packages are restored.

In the Mods screen, Enter toggles a package or activates the selected action. Enabling a package immediately checks versions, API compatibility, cycles and conflicts, then selects its installed dependencies recursively. A failed check leaves the selection unchanged. Disabling includes dependent mods and dependencies that become unused, preserving dependencies required by other remaining packages. A single-package disable toggles immediately; changes affecting multiple packages show an Are you sure? popup listing every affected mod, with Cancel selected initially. Cancel or Back preserves the pending selection. Rescan after editing a package on disk. Dependencies load before dependents; independent packages retain your chosen priority. Later explicit overrides win. Changes made through the pause menu remain pending until the main menu. Candidate registries and scripts are validated before the active set changes.

Package layout and manifest

mods/
  aurora/
    mod.toml
    content.json
    scripts/
      weapon.rhai
    art/
      cannon.png
id = "aurora"
name = "Aurora Field Trials"
version = "1.0.0"
api_version = 1
content = "content.json"
scripts = ["scripts/weapon.rhai"]
description = "A new weapon and mission."
conflicts = ["alternate_aurora"]

[dependencies]
common_tools = "^1.2"

IDs use lowercase ASCII letters, digits, _ and -, with a maximum of 64 bytes. base is reserved. Package versions and dependency requirements use semantic version syntax. The in-game manager selects compatible installed dependencies when enabling a package. It never downloads or installs missing packages. The CLI still requires a complete explicit --enabled set. Cycles, missing packages, unsupported API versions, incompatible dependency versions and declared conflicts reject activation. Two installed folders claiming the same ID make that ID unusable; rename or remove one folder, then rescan.

All package paths are relative. Absolute paths, parent traversal, symlinks and special files are rejected. Filenames must be UTF-8 and cannot contain literal backslashes or colons. Hashes use forward-slash paths and UTF-8 component ordering on both Linux and Windows, preserving existing Linux save pins. Keep the original file bytes when copying packages between systems; the repository disables line-ending conversion for mods. Packages are limited to 10,000 files, 16 directory levels, 64 MiB per file and 256 MiB in total. Manifests are limited to 64 KiB; display names to 128 bytes; descriptions to 4 KiB; script declarations to 64, with a 1 MiB limit per source file. Every file, including artwork and documentation, contributes to the package SHA-256 hash. A package changed after discovery requires rescanning before it can be applied.

Content definitions and overrides

content.json has optional weapons, items, enemies, missions, campaigns, sounds, music and overrides arrays. Unknown fields are rejected. IDs have the form namespace:name, for example aurora:pulse_cannon. IDs are stable save references and unique across content types. Keep item and projectile IDs distinct: the item aurora:pulse_cannon refers to the weapon/projectile aurora:pulse_projectile through its weapon property.

Items may include an optional description of up to 4096 UTF-8 bytes for shop information. The shop displays the description, artwork, effective target types, firing mode, price, ownership and inventory limits.

The item price buys initial_count units, up to its capacity. Selling an ordinary item removes one unit and returns half its unit purchase price, rounded down. Original energy refills and super-shield modules retain their half-price sale per refill/module. This prevents multi-unit mod packs from creating credits through repeated buying and selling.

New definitions must belong to the package namespace. An existing definition may be replaced only when its ID appears in overrides and the replacement retains the same content type. Overrides are complete replacements, not field merges. An override targeting a missing ID, a replacement without explicit permission, an override without its replacement, or duplicate IDs within a pack rejects the whole pack. The manager exposes the override history and its winning owner.

{
  "overrides": ["base:projectile_forward_guns"],
  "weapons": [{
    "id": "base:projectile_forward_guns",
    "name": "Modified Forward Guns",
    "damage": 2,
    "speed": 8,
    "cooldown": 2,
    "behavior": "straight",
    "target": "air",
    "offsets": [[-8, 0], [8, 0]]
  }]
}

legacy fields exist to map original numeric definitions into the base registries. New mods should omit them: they select legacy engine behavior and are not a general scripting extension mechanism.

Definition Properties and behavior
Weapon/projectile Damage, integer speed, cooldown in simulation ticks, targeting (air, ground, all), behavior (straight, homing, beam, spread, bomb, energy_grab, scripted), firing offsets, optional animation and script. Scripts can emit additional projectiles with explicit velocity. scripted requires a script and emits no automatic projectile.
Item Price, maximum and initial count, optional weapon, shield/credit effects, consumable/special flags, artwork and script. initial_count controls how many units a purchase or pickup adds.
Enemy Health, speed, rewards, air/ground and boss flags, firing cooldown, path points and repeat flag, weapon, dropped item, animation and script. Scripts can query entities and change enemy movement.
Mission Duration, auto_complete (default true), optional briefing artwork, ordered tick-based spawns, terrain tile definitions and row-major layout, column count, next mission, optional legacy music asset or namespaced music_track, and script. Scripts can manage objectives, spawn waves and decide success/failure.
Campaign Name, optional menu artwork and ordered mission IDs. Missions and campaigns appear through the same registries as original content.
Music Namespaced ID, display name, package-relative MUS file and looping (default true). Mission music_track references this ID; playback uses original GENMIDI FM and the Music volume control.

For non-legacy missions, an explicit next takes priority. Otherwise progression uses the following entry in the first containing campaign, ordered by stable campaign ID. The last stage stays selected for replay, and completing it again does not duplicate its completion record. This lets an optional assembly mod join independently owned missions without overriding their scripts or artwork.

Simulation uses integer coordinates in the original 320×200 logical canvas and runs at 70/3 Hz: one tick is 3/70 seconds. Artwork detail does not enlarge collision boxes. Script movement and projectile speed are bounded to 128 logical pixels per tick; spawn and movement coordinates must stay within ±32,768.

JSON Schemas are checked into schemas/: mod-v1.json, content-v1.json, hook-v1.json, weapon-projectile-v1.json, item-v1.json, enemy-v1.json, mission-v1.json, campaign-v1.json, sound-v1.json and music-v1.json. Schemas describe serialization shape; the validation CLI also checks references, numeric limits, package paths, dependencies, artwork and script compilation. Generate them from the Rust types with:

cargo run --no-default-features -- --schemas schemas

Artwork

An art object requires exactly one of asset (an original numeric catalog ID) or png (a package-relative PNG path), plus logical_width and logical_height. frames defaults to 1. nearest = true opts that artwork out of Scale2x/3x/4x filtering.

{
  "png": "art/cannon.png",
  "logical_width": 16,
  "logical_height": 16,
  "frames": 4
}

PNG animations are horizontal strips of equally sized frames. For example, four 64×64 frames use a 256×64 PNG and may declare logical dimensions of 16×16. The strip width must divide evenly by frames; supply consistent canvases and alignment across frames. Gameplay rendering treats alpha below 128 as transparent and maps opaque PNG colors to ordinary entries 0–239 of the selected game palette. Plain gameplay PNGs carry no palette-animation labels, so their highlights cannot acquire reserved water, fire or blinking indices 240–255. Artwork already detailed enough for the selected rendering scale bypasses automatic sprite enlargement. Original asset animations use consecutive catalog IDs and retain their native palette labels. Terrain artwork and destroyed terrain replacements follow the same format.

Mission briefing PNGs keep their full RGB colors in mission menus. They follow the same checked artwork paths, logical dimensions, horizontal frame strips and SD/HD selection rules as other artwork. The menu displays frame 0 in a shared 132×70 logical-pixel landscape viewport, scaling without distortion and cropping centrally to fill it. Keep the aircraft and destination readable near the center; titles, mission numbers and controls belong in menu text. Original missions use data/briefings/sector_<sector>_wave_<wave>_{sd,hd}.png. These new menu illustrations do not increase Original sprite or terrain remaster coverage.

Both --verify-assets and mod activation use the shared referenced-PNG artwork validator for base and mod owners. It checks weapon, item, enemy, mission, campaign, terrain and destroyed-terrain references, including bundled Original mission briefings. PNG file errors identify the content ID, owner and artwork path. Native catalog sprites keep their existing catalog validation path. A candidate mod selection is applied only after its artwork and other content checks succeed.

Artwork dimensions and decoding allocation are bounded. PNGs may be up to 4096 pixels in either direction, with at most 4,194,304 pixels and a 64 MiB decoding allocation limit. Logical dimensions are separately validated and stay independent of physical PNG resolution. The packaged original catalog, approved runtime remasters and authored mod artwork/sounds are included in Git; working remaster proofs and source references remain external.

Rhai callbacks

Every callback takes (event, state, random) and returns a map containing commands and state. All scripts referenced by definitions must appear in manifest.scripts. A missing optional callback does nothing; a declared callback with the wrong parameter count is rejected during compilation.

fn on_fire(event, state, random) {
    let shots = if state.contains("shots") { state.shots + 1 } else { 1 };
    #{
        commands: [#{
            op: "projectile",
            weapon: "aurora:pulse_projectile",
            x: event.x,
            y: event.y,
            vx: (random[0] % 3) - 1,
            vy: -8
        }],
        state: #{ shots: shots }
    }
}
Callback Definition and event fields
on_spawn Enemy: entity, x, y.
on_tick Enemy: entity, x, y, health, tick, player_x, player_y. Mission: tick, player_x, player_y.
on_fire Weapon or firing enemy: entity, x, y, hostile, tick; enemy callbacks also include weapon. Player entity is zero. The normal shot has already been emitted, except for scripted weapons, whose script supplies all attacks. Enemy callbacks run for each gun discharge.
on_hit Weapon/projectile and damaged enemy: entity, projectile, damage, x, y; enemy events also include weapon and remaining health. Scripted enemy damage uses scripted = true and omits projectile-specific fields.
on_death Enemy: entity, x, y. The entity is retired; use queries to act on surviving entities.
on_pickup Item: pickup entity. The item and its standard effects have already been awarded.
on_use Selected special item without a weapon: player entity = 0, x, y, tick. Triggered on the rising edge of special input; standard effects and consumption happen first.
on_mission_start Mission: tick = 0. Runs when the first simulation step dispatches events.
on_mission_end Mission: success, tick.

Every World-dispatched event also includes tick, difficulty, actor, player, entities, projectiles, pickups and objectives. difficulty is the active World difficulty as an integer: 0 Training, 1 Rookie, 2 Veteran, 3 Elite. It is supplied centrally at dispatch for every hook, including events raised by commands; scripts can use it to select their own patterns. It does not change spawn difficulty gates or add a command that changes difficulty. Pilot saves retain difficulty alongside script state and RNG; recreating a mission from that pilot supplies the restored difficulty to its callbacks. actor is the addressed enemy's current snapshot, with exactly the same fields as its entities entry, including ground. It is null for mission callbacks, player entity 0, nonenemy addresses and enemies already removed. A zero-health enemy still present in the World has an actor snapshot with health = 0; scripts should check presence and health before issuing commands. The actor is resolved when the callback dispatches, so it reflects movement, damage or removal committed by earlier callbacks; event-local x, y or health can still describe the earlier queued event. Scripts can read event.actor directly instead of scanning event.entities for themselves. This reuses the existing enemy-snapshot traversal and does not add another enemy scan or change the cost of constructing entities. player is a snapshot including position, velocity, shields, credits, inventory and selected equipment. entities is the current enemy array; each entry includes entity, definition, position, width, height, health, animation, movement state and the definition's ground boolean. Filter on ground when an attack affects only airborne or ground targets. Projectiles and pickups likewise carry stable entity IDs. These are read-only snapshots: return commands to change the simulation. Later callbacks in a tick can observe commands committed by earlier callbacks. Events raised by commands, such as a spawned enemy, are dispatched in subsequent batches of the same tick, within the 8,192-event limit. Rejected command batches report the mod, script path and callback, and roll back that simulation step and script state.

Use behavior: "scripted" for hitscan, chain lightning, proximity zaps or other attacks implemented entirely with commands. Player selection, firing input and weapon cooldowns still apply. An owned item with special: false fires its weapon alongside other ordinary equipment when Fire is held; special: true makes it selectable. Neither player nor enemy scripted weapons receive a default engine projectile. A script may explicitly emit projectile, or pair damage with arc for an immediate visible discharge. target describes the weapon's capability for the shop and governs explicit projectiles; scripts must enforce the intended targets and range for their own damage commands.

state is JSON-serializable and shared by all callbacks in the same script file. It starts as an empty map, is keyed by mod_id/script_path, and is saved with the pilot. Avoid values dependent on wall time, filesystem access or rendering rate. random contains 32 deterministic nonnegative integers in the range 0–32,767. Each successful callback advances that script's saved RNG by exactly 32 values, whether or not the script reads them. Missing callbacks do not advance state or RNG.

Commands apply only after a callback returns and passes validation. If a callback or any command fails, the complete simulation tick restores its previous world, script state and script RNG. Runtime errors identify the mod, source file, callback and Rhai source location when available. In the desktop engine a script failure returns to the pause screen with the error; fix the package, rescan and reload at the main menu.

Commands

The 20-slot inventory limit counts physical super-shield modules separately. player.super_shield_reserves lists the stored charge of reserve modules; player.inventory["base:super_shield"] is the equipped module's charge. For the built-in base:energy and base:super_shield, give_item and consume_item change charge units, not the number of modules. Zero-charge shield entries remain valid. Other items are removed when their quantity reaches zero, releasing their slot and selecting another available special item when needed.

Use the op field shown below. All fields listed are required unless stated otherwise. Entity zero identifies the player for damage; movement commands currently address enemies.

op Fields Effect
spawn enemy, x, y Spawn a registered enemy.
projectile weapon, x, y, vx, vy, optional hostile (false) Spawn a projectile using a registered weapon.
damage entity, amount Deal 0–100,000 damage to the player or an existing enemy.
despawn entity Remove an existing enemy or projectile.
move entity, x, y Move an enemy and rebase its movement path.
velocity entity, vx, vy Set enemy velocity.
animation entity, frame (integer or null) Pin the displayed frame of an existing nonlegacy enemy. The zero-based frame must be below its artwork's declared frames; null resumes automatic animation.
give_item item, count Add registered inventory, clamped to item capacity; adding a new entry beyond the 20-item limit fails.
consume_item item, count Remove owned inventory; insufficient quantity fails.
credits signed amount Adjust credits with saturation at valid limits.
shield signed amount Adjust ordinary shield, clamped to 0–100.
effect x, y, optional kind (air_blast) Add a native effect at a bounded position. Kinds: ground_blast, small_ground, large_air, medium_air, small_air, air_blast, energy, person, platoon, flare, sparkle.
arc x, y, to_x, to_y, optional ticks (3), anchor, to_anchor Draw cosmetic branching lightning with a hot white core, blue glow and flicker between two positions. Optional anchors are player (0) or existing enemy entity IDs. Endpoints must be within ±32,768; lifetime must be 1–12 simulation ticks; at most 256 arcs may be active. Pair with damage to attack.
sound numeric asset Queue an original digital sound asset for playback.
play_sound namespaced sound ID Queue a validated package-local sound.
objective key, integer value Set an objective counter. Use a namespaced key.
complete none Complete the mission.
fail none Fail the mission.

animation supports visible boss phases without changing their collision canvas, movement, firing cadence or gameplay RNG. It requires an enemy with declared artwork and rejects original legacy enemies. A selected frame stays visible across simulation ticks until another command changes it or supplies null. Enemy snapshots include pinned_art_frame while a frame is selected. The override survives serialization of a world; pilots save the script state that determines the phase, and mission scripts should reset/reapply it at the appropriate spawn or mission-start callback. No live mission snapshot is implied by pilot saving. Invalid entities or frames roll back the whole command batch and callback tick like other command failures.

Lightning arcs render in both SD and HD using irregular fractal channels, thin tapered forks and glow that brightens the scene. Geometry and brightness change with simulation age; each discharge receives a cosmetic seed derived from its creation tick and queue position so repeated shots vary. Drawing the same simulation state again gives the same image, and pausing freezes the effect. Arcs persist until their simulation lifetime expires. Forks are visual only and do not hit additional targets. Arcs use a separate cosmetic pool and consume no gameplay RNG, entity IDs or projectile capacity. The pool is excluded from saves and the gameplay digest. Invalid endpoints, lifetime or capacity reject the complete command batch and roll back the tick like other command failures.

anchor attaches the starting endpoint, and to_anchor independently attaches the destination. Supply absolute world coordinates at creation; each endpoint preserves its offset from the anchor's top-left x/y origin as the ship moves. Omitting an anchor keeps that endpoint fixed. Both anchors may be 0 to make a ring segment follow the player, or use anchor: 0 and to_anchor: enemy.entity for a ship-to-ship bolt. Anchor IDs must exist when the command runs; a missing anchor rejects the batch. Endpoints update after simulation movement and API movement commands, and freeze at their last position when the anchored enemy disappears. Cosmetic coordinates clamp to ±32,768 when an attached ship moves beyond those bounds.

Rhai uses an explicit package allowlist for arithmetic, strings, collections, iteration and function helpers. The default language/time packages are excluded, so wall-clock timestamps and native sleep cannot be reached, including through function pointers. There are no exposed filesystem, network or OS interfaces. Dynamic eval and imports are disabled. Limits are 100,000 operations per callback and a shared 500,000-operation budget per simulation tick; 32 call levels; expression depths of 64/32; arrays of 4,096 entries; object maps with at most 16,384 aggregate properties across their nested map tree; strings of 16 KiB; 256 commands per callback; and 64 KiB of serialized state per script. See the Rhai execution-limit API for the underlying engine controls. Rhai counts nested properties recursively, so the map bound accommodates complete enemy/projectile snapshots rather than only a map's top-level fields. The separate 64 KiB serialized callback-state and command/operation bounds remain unchanged. Scripts cannot increase these limits.

Saves and compatibility

Rust pilots use separate, versioned JSON files under $XDG_DATA_HOME/RaptorReplica, and settings use TOML under $XDG_CONFIG_HOME/RaptorReplica. Atomic writes replace completed saves after file and directory synchronization. Legacy saves are read and imported; their original bytes are not modified.

Each pilot records the ordered mod IDs, package versions, full content hashes, script states and script RNG positions. The load screen reports missing packages, changed versions or hashes, changed required load order, and unavailable equipment or missions. Changing a package without changing its version still changes its hash and therefore requires confirmation. This includes cosmetic changes to display names, artwork filenames inside the package or artwork bytes. Keep stable content IDs when updating presentation: the usual changed-mod confirmation applies, while equipment and progression retain their existing ID references. Cancel leaves the current pilot and original save untouched; choosing to load accepts the differences for that pilot session.

Unavailable equipment stays in the pilot's inventory with its saved quantity and occupies its original inventory slots, but cannot be fired, selected or traded until its definition is available. Unavailable completed missions remain recorded. If the saved current mission is missing, choose an available mission in the hangar before flying. Runtime saves retain inactive package pins, script state and script RNG alongside the active packages, so restoring a disabled package can recover its data. Current active versions and hashes are recorded after an accepted load is successfully saved; the original save is backed up before its first replacement. Structural corruption, unsupported save versions and invalid IDs still cannot be loaded. This API does not yet provide scripted mod save migrations, so a changed script must handle its earlier serializable state.

For Rust tooling, Pilot::inspect_load returns the unmodified pilot and a structured SaveCompatibility report. Pilot::load remains strict for callers that require a fully matching environment. Only consume PilotLoad::confirm_load after explicit acceptance. Use validate_tolerant and complete_mission_tolerant for that accepted session, and merge_runtime_snapshot to retain saved state and pins belonging to inactive packages.

Validation tests

cargo test --no-default-features --test modding uses synthetic definitions and temporary packages. It checks dependency ordering, version/API failures, conflicts, cycles, duplicate namespaces, changed hashes, traversal/symlinks, file/depth limits, explicit override priority, registry rollback, script budgets, command failure rollback, RNG resumption, incompatible saves and example callbacks. These tests do not require the original game assets. The validation CLI above provides local integration coverage against the separately installed asset catalog.

SD/HD artwork and local sound effects

An artwork definition may add hd_png, a separate package-relative PNG with the same logical canvas, frame count and aspect ratio as the SD png or original asset. HD inputs require at least twice the logical resolution. The Options HD toggle selects it; missing variants retain SD artwork. Detailed mod terrain is drawn on the higher-resolution canvas over the joined SD fallback. Authored mod art/audio, the bundled original game data and approved HD runtime artwork are included in Git. Working source references and review captures remain outside mod packages.

sounds definitions have id, name, file (relative path) and optional volume (0–127, default127), pan (0–255, default127), pitch (0–255, default128) and priority (0–100000, default1). Lower numeric priority wins, matching the original DSP. Play one with #{op:"play_sound",sound:"your_mod:shot"}. WAV inputs must be uncompressed PCM8/16, mono/stereo, 8–48kHz, at most30seconds/8MiB. Stereo is mixed to mono and converted to the original unsigned8-bit digital format; channel/pan/pitch/volume use the original mixer. Original DMX patches are also accepted. Invalid/missing audio rejects activation.

For objective-driven missions set auto_complete=false and explicitly return complete or fail. Scripted endings dispatch on_mission_end in the same transaction. Commands can create further hook events, bounded by the shared execution budget and an8192-event tick limit.

Package mission music

Register a track in the optional music array. A music definition contains id, name, a package-relative .mus file and optional looping (default true). A mission selects it with music_track; setting both music_track and legacy numeric music rejects activation. Declare a dependency on a separate music package so enabling the mission also selects its installed soundtrack. Music IDs use the same namespace, duplicate and explicit-override rules as other content. Winning overrides resolve their score from the winning package.

{
  "music": [{"id":"your_mod:theme", "name":"Mission theme", "file":"music/theme.mus", "looping":true}],
  "missions": [{"id":"your_mod:mission", "name":"New mission", "duration":2000, "music_track":"your_mod:theme"}]
}

Only checked MUS scores are accepted, played through the original GENMIDI FM synthesizer and the Music volume control. Digital effects keep their separate volume. External MIDI and SoundFont playback remain deferred. Scores are limited to 128 KiB, 16,384 events, 256 events per service tick and 30 minutes of score time. The MUS header, event types, controller values, primary-channel count and end marker are checked by the existing decoder. Zero-duration scores, missing files, escaping paths, invalid scores and unsupported formats reject activation with the package/file identified. Score bytes participate in the normal mod hash and pilot compatibility checks. A bounded score cache is cleared when mods are applied.

The selected track starts with the mission, honors its loop flag, and can be scheduled before the SDL device is ready. Selecting the same active track preserves playback position; ordinary menus and classic cinematics select their original tracks. See music validation for the registry, file, volume, loop and device-attachment checks.