Skip to main content
The Primitive format is Phichain’s internal intermediate representation used for format conversion. It provides a normalized, simplified structure that bridges different chart formats.

Purpose

The Primitive format serves as the conversion hub for all chart formats: This architecture:
  • Simplifies conversion logic (N formats need 2N conversions, not N²)
  • Provides a stable internal API
  • Enables easy addition of new formats
  • Normalizes data structures

When It’s Used

Format Conversion

Every format conversion goes through Primitive as an intermediate step.

Chart Compilation

The compiler converts Phichain → Primitive before advanced processing.

Testing

Unit tests use Primitive for format-agnostic chart validation.

Tool Development

Custom tools can work with Primitive instead of dealing with multiple formats.

File Structure

{
  "format": 1,
  "offset": 0.0,
  "bpm_list": [
    {
      "beat": [0, 0, 1],
      "bpm": 120.0
    }
  ],
  "lines": [
    {
      "notes": [],
      "events": []
    }
  ]
}

Field Reference

PrimitiveChart

format
integer
default:"1"
Primitive format version. Always 1 (stable).
offset
number
required
Audio offset in milliseconds.
bpm_list
array
required
BPM change points. Same structure as Phichain format.
lines
array
required
Array of simplified Line objects.

Line

A minimal line representation with only notes and events:
{
  "notes": [
    // Note objects
  ],
  "events": [
    // LineEvent objects
  ]
}
notes
array
required
Notes attached to this line. Same structure as Phichain.
events
array
required
Line events. Always uses Transition form (no Constant values).
Lines have no metadata in Primitive format:
  • No name field
  • No child lines
  • No curve note tracks
These features are Phichain-specific and don’t exist in Primitive.

LineEvent

Simplified event structure:
{
  "kind": "x",
  "start_beat": [0, 0, 1],
  "end_beat": [4, 0, 1],
  "start": 0.0,
  "end": 100.0,
  "easing": "linear"
}
kind
string
required
Event type: "x", "y", "rotation", "opacity", or "speed".
start_beat
array
required
Event start beat [bar, numerator, denominator].
end_beat
array
required
Event end beat.
start
number
required
Starting value.
end
number
required
Ending value.
easing
string
required
Easing function name.
Key Difference from Phichain:Primitive events always use the flat structure with start, end, and easing fields.Phichain’s LineEventValue::Constant is converted to Transition with start == end.

Conversion Examples

Phichain → Primitive

use phichain_chart::serialization::PhichainChart;
use phichain_chart::primitive::{Format, PrimitiveChart};

let phichain: PhichainChart = serde_json::from_str(&json)?;
let primitive = phichain.into_primitive()?;

println!("Converted to {} lines", primitive.lines.len());
What happens:
  • LineEventValue::Constant(v)start: v, end: v, easing: Linear
  • LineEventValue::Transition{...} → kept as-is
  • Child lines are flattened (not preserved)
  • Curve note tracks are dropped
  • Line names are lost

Official → Primitive

use phichain_chart::format::official::OfficialChart;
use phichain_chart::primitive::Format;

let official: OfficialChart = serde_json::from_str(&json)?;
let primitive = official.into_primitive()?;
What happens:
  • Time values converted to beats: beat = time * 60.0 / 1.875
  • Position normalized: x = (positionX / 18.0 - 0.5) * CANVAS_WIDTH
  • Opacity scaled: opacity = disappear_value * 255.0
  • Speed scaled: speed = value / 2.0 * 9.0
  • Per-line BPM → first line’s BPM becomes global BPM

RPE → Primitive

use phichain_chart::format::rpe::RpeChart;
use phichain_chart::primitive::Format;

let rpe: RpeChart = serde_json::from_str(&json)?;
let primitive = rpe.into_primitive()?;
What happens:
  • Event layers flattened into single layer
  • Rotation values negated: rotation = -rpe_rotation
  • Easing ID mapped to Easing enum
  • Custom bezier → Easing::Custom(x1, y1, x2, y2)

Rust API

The Format trait defines conversion methods:
pub trait Format: Serialize + DeserializeOwned {
    fn into_primitive(self) -> anyhow::Result<PrimitiveChart>;
    fn from_primitive(primitive: PrimitiveChart) -> anyhow::Result<Self>
    where
        Self: Sized;
}
Implemented for:
  • PrimitiveChart (identity conversion)
  • PhichainChart
  • OfficialChart
  • RpeChart

Usage Example

use phichain_chart::primitive::{Format, PrimitiveChart};
use phichain_chart::format::official::OfficialChart;
use phichain_chart::format::rpe::RpeChart;

// RPE → Official conversion via Primitive
let rpe: RpeChart = serde_json::from_str(&input)?;
let primitive = rpe.into_primitive()?;
let official = OfficialChart::from_primitive(primitive)?;
let output = serde_json::to_string_pretty(&official)?;

Core Types

Primitive format reuses core types from phichain-chart:
use phichain_chart::primitive::PrimitiveChart;
use phichain_chart::primitive::line::Line;
use phichain_chart::primitive::event::LineEvent;
use phichain_chart::note::Note;  // Shared type
use phichain_chart::beat::Beat;  // Shared type
use phichain_chart::bpm_list::BpmList;  // Shared type
Source files:
  • phichain-chart/src/primitive/mod.rs - PrimitiveChart
  • phichain-chart/src/primitive/line.rs - Line
  • phichain-chart/src/primitive/event.rs - LineEvent

Complete Example

{
  "format": 1,
  "offset": 0.0,
  "bpm_list": [
    {
      "beat": [0, 0, 1],
      "bpm": 150.0
    }
  ],
  "lines": [
    {
      "notes": [
        {
          "kind": { "tap": null },
          "above": true,
          "beat": [0, 0, 1],
          "x": 0.0,
          "speed": 1.0
        },
        {
          "kind": {
            "hold": {
              "hold_beat": [1, 0, 1]
            }
          },
          "above": true,
          "beat": [2, 0, 1],
          "x": 200.0,
          "speed": 1.0
        }
      ],
      "events": [
        {
          "kind": "x",
          "start_beat": [0, 0, 1],
          "end_beat": [100, 0, 1],
          "start": 0.0,
          "end": 0.0,
          "easing": "linear"
        },
        {
          "kind": "y",
          "start_beat": [0, 0, 1],
          "end_beat": [100, 0, 1],
          "start": 0.0,
          "end": 0.0,
          "easing": "linear"
        },
        {
          "kind": "rotation",
          "start_beat": [0, 0, 1],
          "end_beat": [8, 0, 1],
          "start": 0.0,
          "end": 720.0,
          "easing": "ease_in_out_cubic"
        },
        {
          "kind": "opacity",
          "start_beat": [0, 0, 1],
          "end_beat": [100, 0, 1],
          "start": 255.0,
          "end": 255.0,
          "easing": "linear"
        },
        {
          "kind": "speed",
          "start_beat": [0, 0, 1],
          "end_beat": [100, 0, 1],
          "start": 9.0,
          "end": 9.0,
          "easing": "linear"
        }
      ]
    }
  ]
}

Design Philosophy

Without Primitive, converting between N formats requires N×(N-1) conversion functions:
  • 3 formats: 6 conversions
  • 4 formats: 12 conversions
  • 5 formats: 20 conversions
With Primitive, you only need 2N conversions (to/from Primitive):
  • 3 formats: 6 conversions (same!)
  • 4 formats: 8 conversions (33% fewer)
  • 5 formats: 10 conversions (50% fewer)
Plus, Primitive provides a stable API that doesn’t change when new formats are added.
Primitive represents the common subset of all formats:
  • Official format has no child lines → Primitive can’t preserve them
  • Official has linear easing only → but Primitive keeps full easing (approximated on export)
  • RPE has event layers → Primitive flattens them
Features unique to one format (like Phichain’s curve notes) can’t round-trip through formats that don’t support them.
Even though Official format only supports linear interpolation, Primitive preserves easing information:
  • Allows round-trip Phichain → Official → Phichain without losing easing
  • Enables future formats to support easing
  • Approximation to linear segments happens only during Official export
This is a pragmatic choice: accept file size increase for Official, gain preservation for other formats.

Use Cases

Debugging Conversions

# Convert to Primitive to see normalized structure
phichain convert input.json output.primitive.json

# Inspect the Primitive representation
cat output.primitive.json | jq .

Custom Tools

use phichain_chart::primitive::{Format, PrimitiveChart};

// Generic chart analyzer works with any format
fn analyze_chart(json: &str) -> anyhow::Result<()> {
    let chart: PrimitiveChart = serde_json::from_str(json)?;
    
    for (i, line) in chart.lines.iter().enumerate() {
        println!("Line {}: {} notes, {} events",
            i, line.notes.len(), line.events.len());
    }
    
    Ok(())
}

Testing

#[test]
fn test_round_trip_conversion() {
    let original = create_test_chart();
    let primitive = original.clone().into_primitive().unwrap();
    let restored = PhichainChart::from_primitive(primitive).unwrap();
    
    // Compare note counts, event types, etc.
    assert_eq!(original.lines.len(), restored.lines.len());
}

Limitations

Not for Long-Term StoragePrimitive format is designed for temporary conversions, not archival:
  • Loses format-specific metadata
  • No version migration support
  • Minimal validation
Always save charts in their native format (Phichain, Official, or RPE).
Identity ConversionPrimitiveChart implements the Format trait with identity conversions:
impl Format for PrimitiveChart {
    fn into_primitive(self) -> Result<PrimitiveChart> {
        Ok(self)  // Already primitive!
    }
    
    fn from_primitive(p: PrimitiveChart) -> Result<Self> {
        Ok(p)  // Already primitive!
    }
}

Source Code

Primitive format definition:
  • phichain-chart/src/primitive/mod.rs - Main types
  • phichain-chart/src/primitive/line.rs - Line struct
  • phichain-chart/src/primitive/event.rs - LineEvent struct

Format Overview

Understanding multi-format support

Conversion Tool

Working with format conversions