{ "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-levelsong key:
chart.json
{ "song": { "song": { ... } } }. parse_chart() detects this automatically by checking whether the inner value has a notes key.
SwagSong fields
Display name of the song. Null values are treated as an empty string.
Array of chart sections. Each section holds a batch of notes and optional per-section BPM changes.
Top-level events array. Each entry is
[strum_time, [[name, value1, value2], ...]]. These are merged with any inline events found inside sectionNotes.Starting BPM of the song. Used to build the conductor’s BPM change map.
Note scroll speed multiplier. Higher values scroll faster.
Audio offset in milliseconds applied before gameplay begins.
Character key for the player (Boyfriend). Null values fall back to
"bf".Character key for the opponent. Null values fall back to
"dad".Character key for the girlfriend/spectator. Null values fall back to
"gf".Stage key used to look up the stage JSON file.
Chart format identifier.
"psych_v1" and "psych_v1_convert" skip the legacy direction-remapping step. Any other value (including an absent field) triggers conversion.Whether the song has a separate vocals track. When false, only the instrumental is loaded.
Character override for the game-over screen.
Sound file override for the game-over death sound.
Sound file override for the game-over loop music.
Sound file override for the game-over end sound.
Disable RGB color tinting on notes.
Default note skin override for all strums.
Note splash skin override.
Opponent note skin override. VS Retrospecter extension.
Player note skin override. VS Retrospecter extension.
SwagSection fields
Each entry innotes[] is a SwagSection:
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].Number of beats in this section. Used to calculate section length for BPM change maps.
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.When
true, all notes in this section use the -alt animation variant.When
true, the girlfriend character sings during this section.New BPM that takes effect at the start of this section. Only applied when
changeBpm is true.Whether this section introduces a BPM change.
Format variants
- legacy (pre-psych_v1)
- psych_v1
Note directions are relative to
mustHitSection. The same physical lane number means different ownership depending on which section you are in:| Direction | mustHitSection = true | mustHitSection = false |
|---|---|---|
| 0–3 | Player | Opponent |
| 4–7 | Opponent | Player |
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 mirrorsSong.hx lines 113–114 in Psych Engine:
crates/rustic-core/src/chart.rs
parse_chart()
crates/rustic-core/src/chart.rs
ParsedChart containing:
The deserialized song metadata with defaults applied for any null or missing fields.
All gameplay notes sorted by
strum_time ascending. Player notes have must_press = true, opponent notes have must_press = false.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
Separate events file
Some charts ship a companionevents.json with additional events not embedded in the main chart. Load it with:
{ "song": { "events": [...] } } and { "events": [...] } layouts.
Example: full chart round-trip
example chart.json
parse_chart():
- Note at
0ms: player, lane 0, tap — direction 0 in amustHitSection=truesection stays as player lane 0. - Note at
500ms: opponent, lane 1, hold 200ms — direction 5 (5 % 4 = 1) is an opponent lane inpsych_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.