Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/Quiet-Wolfe/Rustic-Engine/llms.txt

Use this file to discover all available pages before exploring further.

Rustic Engine reads the standard Psych Engine JSON chart format. Charts are stored as { "song": { ... } } wrapper objects. The parse_chart() function deserializes the payload, normalizes legacy direction encoding to psych_v1, and returns a flat list of NoteData and EventNote values sorted by strum time.

File structure

A chart file contains a single top-level song key:
chart.json
{
  "song": {
    "song": "Bopeebo",
    "bpm": 100.0,
    "speed": 2.7,
    "needsVoices": true,
    "player1": "bf",
    "player2": "dad",
    "gfVersion": "gf",
    "stage": "stage",
    "notes": [ ... ],
    "events": [ ... ]
  }
}
Some older charts double-nest the payload as { "song": { "song": { ... } } }. parse_chart() detects this automatically by checking whether the inner value has a notes key.

SwagSong fields

body.song
string
default:""
Display name of the song. Null values are treated as an empty string.
body.notes
SwagSection[]
required
Array of chart sections. Each section holds a batch of notes and optional per-section BPM changes.
body.events
array
Top-level events array. Each entry is [strum_time, [[name, value1, value2], ...]]. These are merged with any inline events found inside sectionNotes.
body.bpm
number
required
Starting BPM of the song. Used to build the conductor’s BPM change map.
body.speed
number
default:"1.0"
Note scroll speed multiplier. Higher values scroll faster.
body.offset
number
default:"0.0"
Audio offset in milliseconds applied before gameplay begins.
body.player1
string
default:"bf"
Character key for the player (Boyfriend). Null values fall back to "bf".
body.player2
string
default:"dad"
Character key for the opponent. Null values fall back to "dad".
body.gfVersion
string
default:"gf"
Character key for the girlfriend/spectator. Null values fall back to "gf".
body.stage
string
default:"stage"
Stage key used to look up the stage JSON file.
body.format
string
default:""
Chart format identifier. "psych_v1" and "psych_v1_convert" skip the legacy direction-remapping step. Any other value (including an absent field) triggers conversion.
body.needsVoices
boolean
default:"true"
Whether the song has a separate vocals track. When false, only the instrumental is loaded.
body.gameOverChar
string
default:""
Character override for the game-over screen.
body.gameOverSound
string
default:""
Sound file override for the game-over death sound.
body.gameOverLoop
string
default:""
Sound file override for the game-over loop music.
body.gameOverEnd
string
default:""
Sound file override for the game-over end sound.
body.disableNoteRgb
boolean
default:"false"
Disable RGB color tinting on notes.
body.arrowSkin
string
default:""
Default note skin override for all strums.
body.splashSkin
string
default:""
Note splash skin override.
body.arrowSkinDAD
string
default:""
Opponent note skin override. VS Retrospecter extension.
body.arrowSkinBF
string
default:""
Player note skin override. VS Retrospecter extension.

SwagSection fields

Each entry in notes[] is a SwagSection:
body.sectionNotes
array[]
required
Array of note tuples. Each tuple is [strum_time, direction, sustain_length] or [strum_time, direction, sustain_length, note_type]. Event notes embedded here use a negative or string direction value and carry [strum_time, event_name, value1, value2].
body.sectionBeats
number
default:"4.0"
Number of beats in this section. Used to calculate section length for BPM change maps.
body.mustHitSection
boolean
default:"false"
Legacy format only. When true, directions 0–3 belong to the player and 4–7 to the opponent. When false, the ownership is flipped. After conversion this field is ignored — directions become absolute.
body.altAnim
boolean
default:"false"
When true, all notes in this section use the -alt animation variant.
body.gfSection
boolean
default:"false"
When true, the girlfriend character sings during this section.
body.bpm
number
default:"0.0"
New BPM that takes effect at the start of this section. Only applied when changeBpm is true.
body.changeBpm
boolean
default:"false"
Whether this section introduces a BPM change.

Format variants

Note directions are relative to mustHitSection. The same physical lane number means different ownership depending on which section you are in:
DirectionmustHitSection = truemustHitSection = false
0–3PlayerOpponent
4–7OpponentPlayer
parse_chart() calls convert_to_psych_v1() automatically for any chart whose format field does not start with "psych_v1".

Legacy conversion logic

The conversion mirrors Song.hx lines 113–114 in Psych Engine:
crates/rustic-core/src/chart.rs
fn convert_to_psych_v1(song: &mut SwagSong) {
    for section in &mut song.notes {
        for sn in &mut section.section_notes {
            let dir = sn[1].as_f64() as i32;   // original direction

            // Determine true ownership from mustHitSection + direction range
            let is_player = if dir < 4 {
                section.must_hit_section         // low dirs belong to hit-section owner
            } else {
                !section.must_hit_section        // high dirs belong to the other side
            };

            // Rewrite to absolute: 0-3 = player, 4-7 = opponent
            let new_dir = (dir % 4) + if is_player { 0 } else { 4 };
            sn[1] = serde_json::Value::from(new_dir);
        }
    }
    song.format = "psych_v1".to_string();
}
After conversion, every note carries an absolute direction. The parser then derives ownership from a simple range check:
let must_press = direction < 4;   // 0-3 = player, 4-7 = opponent
let lane = direction % 4;         // normalize to 0-3 lane index

parse_chart()

crates/rustic-core/src/chart.rs
pub fn parse_chart(json_data: &str) -> Result<ParsedChart, ChartError>
Parses a Psych Engine chart JSON string. Returns a ParsedChart containing:
song
SwagSong
required
The deserialized song metadata with defaults applied for any null or missing fields.
notes
NoteData[]
required
All gameplay notes sorted by strum_time ascending. Player notes have must_press = true, opponent notes have must_press = false.
events
EventNote[]
required
All chart events (from both song.events and inline section events) sorted by strum_time ascending. Each event carries a name, value1, and value2 string.

Errors

pub enum ChartError {
    Parse(String),   // JSON deserialization failure
    NotFound(String) // File not found (raised by callers, not parse_chart itself)
}

Separate events file

Some charts ship a companion events.json with additional events not embedded in the main chart. Load it with:
pub fn parse_events_file(json_data: &str) -> Result<Vec<EventNote>, ChartError>
The function accepts both { "song": { "events": [...] } } and { "events": [...] } layouts.

Example: full chart round-trip

example chart.json
{
  "song": {
    "song": "Test Song",
    "bpm": 150.0,
    "speed": 2.0,
    "player1": "bf",
    "player2": "dad",
    "gfVersion": "gf",
    "stage": "stage",
    "needsVoices": true,
    "notes": [
      {
        "sectionNotes": [
          [0.0, 0, 0],
          [500.0, 5, 200.0],
          [1000.0, 2, 0, "Alt Animation"]
        ],
        "mustHitSection": true,
        "sectionBeats": 4.0
      }
    ],
    "events": [
      [2000.0, [["Hey!", "BF", "0.6"]]]
    ]
  }
}
After parse_chart():
  • Note at 0ms: player, lane 0, tap — direction 0 in a mustHitSection=true section stays as player lane 0.
  • Note at 500ms: opponent, lane 1, hold 200ms — direction 5 (5 % 4 = 1) is an opponent lane in psych_v1.
  • Note at 1000ms: player, lane 2, NoteKind::Alt.
  • Event at 2000ms: name "Hey!", value1 "BF", value2 "0.6".
Notes and events are always returned sorted by strum_time. You can iterate them in order without a secondary sort step.

Build docs developers (and LLMs) love