From 9ebb566b2a36f3f5bcb9622fe00dffc8c75ab8ae Mon Sep 17 00:00:00 2001
From: Eric Myllyoja <ericmyllyoja@gmail.com>
Date: Thu, 22 Sep 2022 06:34:03 -0400
Subject: [PATCH] WIP on conductor rework

---
 source/funkin/Conductor.hx                    | 128 +++++-
 source/funkin/FreeplayState.hx                |   6 +-
 source/funkin/InitState.hx                    |   2 +
 source/funkin/LatencyState.hx                 |   2 +-
 source/funkin/Note.hx                         |   2 +
 source/funkin/PauseSubState.hx                |  15 +-
 source/funkin/SongLoad.hx                     |  30 +-
 source/funkin/StoryMenuState.hx               |   3 +
 source/funkin/TitleState.hx                   |   4 +-
 source/funkin/charting/ChartingState.hx       |   8 +-
 source/funkin/play/PicoFight.hx               |   3 +-
 source/funkin/play/PlayState.hx               | 431 ++++++++++++++++--
 source/funkin/play/Strumline.hx               |   2 +
 source/funkin/play/character/BaseCharacter.hx |   8 +
 source/funkin/play/song/Song.hx               |  82 +++-
 source/funkin/play/song/SongData.hx           | 151 +++++-
 source/funkin/play/song/SongValidator.hx      |   9 +-
 17 files changed, 810 insertions(+), 76 deletions(-)

diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx
index 660ef8adf..590e066a4 100644
--- a/source/funkin/Conductor.hx
+++ b/source/funkin/Conductor.hx
@@ -1,11 +1,10 @@
 package funkin;
 
 import funkin.SongLoad.SwagSong;
+import funkin.play.song.Song.SongDifficulty;
+import funkin.play.song.SongData.ConductorTimeChange;
+import funkin.play.song.SongData.SongTimeChange;
 
-/**
- * ...
- * @author
- */
 typedef BPMChangeEvent =
 {
 	var stepTime:Int;
@@ -16,12 +15,40 @@ typedef BPMChangeEvent =
 class Conductor
 {
 	/**
-	 * Beats per minute of the song.
+	 * The list of time changes in the song.
+	 * There should be at least one time change (at the beginning of the song) to define the BPM.
 	 */
-	public static var bpm:Float = 100;
+	private static var timeChanges:Array<ConductorTimeChange> = [];
 
 	/**
-	 * Duration of a beat in millisecond.
+	 * The current time change.
+	 */
+	private static var currentTimeChange:ConductorTimeChange;
+
+	/**
+	 * The current position in the song in milliseconds.
+	 * Updated every frame based on the audio position.
+	 */
+	public static var songPosition:Float;
+
+	/**
+	 * Beats per minute of the current song at the current time.
+	 */
+	public static var bpm(get, null):Float = 100;
+
+	static function get_bpm():Float
+	{
+		if (currentTimeChange == null)
+			return 100;
+
+		return currentTimeChange.bpm;
+	}
+
+	// OLD, replaced with timeChanges.
+	public static var bpmChangeMap:Array<BPMChangeEvent> = [];
+
+	/**
+	 * Duration of a beat in millisecond. Calculated based on bpm.
 	 */
 	public static var crochet(get, null):Float;
 
@@ -31,7 +58,7 @@ class Conductor
 	}
 
 	/**
-	 * Duration of a step in milliseconds.
+	 * Duration of a step in milliseconds. Calculated based on bpm.
 	 */
 	public static var stepCrochet(get, null):Float;
 
@@ -40,19 +67,62 @@ class Conductor
 		return crochet / 4;
 	}
 
-	/**
-	 * The current position in the song in milliseconds.
-	 */
-	public static var songPosition:Float;
+	public static var currentBeat(get, null):Float;
+
+	static function get_currentBeat():Float
+	{
+		return currentBeat;
+	}
+
+	public static var currentStep(get, null):Int;
+
+	static function get_currentStep():Int
+	{
+		return currentStep;
+	}
 
 	public static var lastSongPos:Float;
 	public static var visualOffset:Float = 0;
 	public static var audioOffset:Float = 0;
 	public static var offset:Float = 0;
 
-	public static var bpmChangeMap:Array<BPMChangeEvent> = [];
+	public function new()
+	{
+	}
 
-	public function new() {}
+	public static function getLastBPMChange()
+	{
+		var lastChange:BPMChangeEvent = {
+			stepTime: 0,
+			songTime: 0,
+			bpm: 0
+		}
+		for (i in 0...Conductor.bpmChangeMap.length)
+		{
+			if (Conductor.songPosition >= Conductor.bpmChangeMap[i].songTime)
+				lastChange = Conductor.bpmChangeMap[i];
+
+			if (Conductor.songPosition < Conductor.bpmChangeMap[i].songTime)
+				break;
+		}
+		return lastChange;
+	}
+
+	public static function forceBPM(bpm:Float)
+	{
+		// TODO: Get rid of this and use song metadata instead.
+		Conductor.bpm = bpm;
+	}
+
+	/**
+	 * Update the conductor with the current song position.
+	 * BPM, current step, etc. will be re-calculated based on the song position.
+	 */
+	public static function update(songPosition:Float)
+	{
+		Conductor.songPosition = songPosition;
+		Conductor.bpm = Conductor.getLastBPMChange().bpm;
+	}
 
 	public static function mapBPMChanges(song:SwagSong)
 	{
@@ -80,4 +150,34 @@ class Conductor
 		}
 		trace("new BPM map BUDDY " + bpmChangeMap);
 	}
+
+	public static function mapTimeChanges(currentChart:SongDifficulty)
+	{
+		var songTimeChanges:Array<SongTimeChange> = currentChart.timeChanges;
+
+		timeChanges = [];
+
+		for (songTimeChange in timeChanges)
+		{
+			var prevTimeChange:ConductorTimeChange = timeChanges.length == 0 ? null : timeChanges[timeChanges.length - 1];
+			var currentTimeChange:ConductorTimeChange = cast songTimeChange;
+
+			if (prevTimeChange != null)
+			{
+				var deltaTime:Float = currentTimeChange.timeStamp - prevTimeChange.timeStamp;
+				var deltaSteps:Int = Math.round(deltaTime / (60 / prevTimeChange.bpm) * 1000 / 4);
+
+				currentTimeChange.stepTime = prevTimeChange.stepTime + deltaSteps;
+			}
+			else
+			{
+				// We know the time and steps of this time change is 0, since this is the first time change.
+				currentTimeChange.stepTime = 0;
+			}
+
+			timeChanges.push(currentTimeChange);
+		}
+
+		// Done.
+	}
 }
diff --git a/source/funkin/FreeplayState.hx b/source/funkin/FreeplayState.hx
index f9f0681ae..fbb90ed98 100644
--- a/source/funkin/FreeplayState.hx
+++ b/source/funkin/FreeplayState.hx
@@ -27,6 +27,7 @@ import funkin.freeplayStuff.FreeplayScore;
 import funkin.freeplayStuff.SongMenuItem;
 import funkin.play.HealthIcon;
 import funkin.play.PlayState;
+import funkin.play.song.SongData.SongDataParser;
 import funkin.shaderslmfao.AngleMask;
 import funkin.shaderslmfao.PureColor;
 import funkin.shaderslmfao.StrokeShader;
@@ -97,7 +98,7 @@ class FreeplayState extends MusicBeatSubstate
 		}
 
 		if (StoryMenuState.weekUnlocked[2] || isDebug)
-			addWeek(['Bopeebo', 'Fresh', 'Dadbattle'], 1, ['dad']);
+			addWeek(['Bopeebo', 'Bopeebo_new', 'Fresh', 'Dadbattle'], 1, ['dad']);
 
 		if (StoryMenuState.weekUnlocked[2] || isDebug)
 			addWeek(['Spookeez', 'South', 'Monster'], 2, ['spooky', 'spooky', 'monster']);
@@ -520,8 +521,10 @@ class FreeplayState extends MusicBeatSubstate
 			}*/
 
 			PlayState.currentSong = SongLoad.loadFromJson(poop, songs[curSelected].songName.toLowerCase());
+			PlayState.currentSong_NEW = SongDataParser.fetchSong(songs[curSelected].songName.toLowerCase());
 			PlayState.isStoryMode = false;
 			PlayState.storyDifficulty = curDifficulty;
+			PlayState.storyDifficulty_NEW = 'easy';
 			// SongLoad.curDiff = Highscore.formatSong()
 
 			SongLoad.curDiff = switch (curDifficulty)
@@ -562,6 +565,7 @@ class FreeplayState extends MusicBeatSubstate
 		intendedScore = FlxG.random.int(0, 100000);
 
 		PlayState.storyDifficulty = curDifficulty;
+		PlayState.storyDifficulty_NEW = 'easy';
 
 		grpDifficulties.group.forEach(function(spr)
 		{
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index 36deb4e2b..558127482 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -191,8 +191,10 @@ class InitState extends FlxTransitionableState
 		var dif = getDif();
 
 		PlayState.currentSong = SongLoad.loadFromJson(song, song);
+		PlayState.currentSong_NEW = SongDataParser.fetchSong(song);
 		PlayState.isStoryMode = isStoryMode;
 		PlayState.storyDifficulty = dif;
+		PlayState.storyDifficulty_NEW = 'easy';
 		SongLoad.curDiff = switch (dif)
 		{
 			case 0: 'easy';
diff --git a/source/funkin/LatencyState.hx b/source/funkin/LatencyState.hx
index d3a790104..79f3f217a 100644
--- a/source/funkin/LatencyState.hx
+++ b/source/funkin/LatencyState.hx
@@ -70,7 +70,7 @@ class LatencyState extends MusicBeatSubstate
 
 		// funnyStatsGraph.hi
 
-		Conductor.bpm = 60;
+		Conductor.forceBPM(60);
 
 		noteGrp = new FlxTypedGroup<Note>();
 		add(noteGrp);
diff --git a/source/funkin/Note.hx b/source/funkin/Note.hx
index 90d01666f..9db44775e 100644
--- a/source/funkin/Note.hx
+++ b/source/funkin/Note.hx
@@ -2,6 +2,8 @@ package funkin;
 
 import flixel.FlxSprite;
 import flixel.math.FlxMath;
+import funkin.noteStuff.NoteBasic.NoteData;
+import funkin.noteStuff.NoteBasic.NoteType;
 import funkin.play.PlayState;
 import funkin.play.Strumline.StrumlineStyle;
 import funkin.shaderslmfao.ColorSwap;
diff --git a/source/funkin/PauseSubState.hx b/source/funkin/PauseSubState.hx
index 8a9648795..8f8a2752f 100644
--- a/source/funkin/PauseSubState.hx
+++ b/source/funkin/PauseSubState.hx
@@ -1,17 +1,15 @@
 package funkin;
 
 import flixel.FlxSprite;
-import flixel.FlxSubState;
 import flixel.addons.transition.FlxTransitionableState;
 import flixel.group.FlxGroup.FlxTypedGroup;
-import flixel.input.keyboard.FlxKey;
 import flixel.system.FlxSound;
 import flixel.text.FlxText;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
 import flixel.util.FlxColor;
-import funkin.Controls.Control;
 import funkin.play.PlayState;
+import funkin.play.song.SongData.SongDataParser;
 
 class PauseSubState extends MusicBeatSubstate
 {
@@ -61,7 +59,14 @@ class PauseSubState extends MusicBeatSubstate
 		add(metaDataGrp);
 
 		var levelInfo:FlxText = new FlxText(20, 15, 0, "", 32);
-		levelInfo.text += PlayState.currentSong.song;
+		if (PlayState.instance.currentChart != null)
+		{
+			levelInfo.text += '${PlayState.instance.currentChart.songName} - ${PlayState.instance.currentChart.songArtist}';
+		}
+		else
+		{
+			levelInfo.text += PlayState.currentSong.song;
+		}
 		levelInfo.scrollFactor.set();
 		levelInfo.setFormat(Paths.font("vcr.ttf"), 32);
 		levelInfo.updateHitbox();
@@ -180,9 +185,11 @@ class PauseSubState extends MusicBeatSubstate
 						close();
 					case "EASY" | 'NORMAL' | "HARD":
 						PlayState.currentSong = SongLoad.loadFromJson(PlayState.currentSong.song.toLowerCase(), PlayState.currentSong.song.toLowerCase());
+						PlayState.currentSong_NEW = SongDataParser.fetchSong(PlayState.currentSong.song.toLowerCase());
 						SongLoad.curDiff = daSelected.toLowerCase();
 
 						PlayState.storyDifficulty = curSelected;
+						PlayState.storyDifficulty_NEW = 'easy';
 
 						PlayState.needsReset = true;
 
diff --git a/source/funkin/SongLoad.hx b/source/funkin/SongLoad.hx
index b2baaf17c..acbeaab09 100644
--- a/source/funkin/SongLoad.hx
+++ b/source/funkin/SongLoad.hx
@@ -2,6 +2,7 @@ package funkin;
 
 import funkin.Section.SwagSection;
 import funkin.noteStuff.NoteBasic.NoteData;
+import funkin.play.PlayState;
 import haxe.Json;
 import lime.utils.Assets;
 
@@ -47,7 +48,21 @@ class SongLoad
 
 	public static function loadFromJson(jsonInput:String, ?folder:String):SwagSong
 	{
-		var rawJson = Assets.getText(Paths.json('songs/${folder.toLowerCase()}/${jsonInput.toLowerCase()}')).trim();
+		var rawJson:Dynamic = null;
+		try
+		{
+			rawJson = Assets.getText(Paths.json('songs/${folder.toLowerCase()}/${jsonInput.toLowerCase()}')).trim();
+		}
+		catch (e)
+		{
+			trace('Failed to load song data: ${e}');
+			rawJson = null;
+		}
+
+		if (rawJson == null)
+		{
+			return null;
+		}
 
 		while (!rawJson.endsWith("}"))
 		{
@@ -112,6 +127,11 @@ class SongLoad
 
 	public static function getSpeed(?diff:String):Float
 	{
+		if (PlayState.instance != null && PlayState.instance.currentChart != null)
+		{
+			return getSpeed_NEW(diff);
+		}
+
 		if (diff == null)
 			diff = SongLoad.curDiff;
 
@@ -137,6 +157,14 @@ class SongLoad
 		return speedShit;
 	}
 
+	public static function getSpeed_NEW(?diff:String):Float
+	{
+		if (PlayState.instance == null || PlayState.instance.currentChart == null || PlayState.instance.currentChart.scrollSpeed == 0.0)
+			return 1.0;
+
+		return PlayState.instance.currentChart.scrollSpeed;
+	}
+
 	public static function getDefaultSwagSong():SwagSong
 	{
 		return {
diff --git a/source/funkin/StoryMenuState.hx b/source/funkin/StoryMenuState.hx
index d3023b4a3..b5e4a6bc7 100644
--- a/source/funkin/StoryMenuState.hx
+++ b/source/funkin/StoryMenuState.hx
@@ -12,6 +12,7 @@ import flixel.util.FlxColor;
 import flixel.util.FlxTimer;
 import funkin.MenuItem.WeekType;
 import funkin.play.PlayState;
+import funkin.play.song.SongData.SongDataParser;
 import lime.net.curl.CURLCode;
 import openfl.Assets;
 
@@ -372,10 +373,12 @@ class StoryMenuState extends MusicBeatState
 			selectedWeek = true;
 
 			PlayState.currentSong = SongLoad.loadFromJson(PlayState.storyPlaylist[0].toLowerCase(), PlayState.storyPlaylist[0].toLowerCase());
+			PlayState.currentSong_NEW = SongDataParser.fetchSong(PlayState.storyPlaylist[0].toLowerCase());
 			PlayState.storyWeek = curWeek;
 			PlayState.campaignScore = 0;
 
 			PlayState.storyDifficulty = curDifficulty;
+			PlayState.storyDifficulty_NEW = 'easy';
 			SongLoad.curDiff = switch (curDifficulty)
 			{
 				case 0:
diff --git a/source/funkin/TitleState.hx b/source/funkin/TitleState.hx
index 3ae12dd43..200198f5c 100644
--- a/source/funkin/TitleState.hx
+++ b/source/funkin/TitleState.hx
@@ -140,7 +140,7 @@ class TitleState extends MusicBeatState
 		{
 			FlxG.sound.playMusic(Paths.music('freakyMenu'), 0);
 			FlxG.sound.music.fadeIn(4, 0, 0.7);
-			Conductor.bpm = Constants.FREAKY_MENU_BPM;
+			Conductor.forceBPM(Constants.FREAKY_MENU_BPM);
 		}
 
 		persistentUpdate = true;
@@ -474,7 +474,7 @@ class TitleState extends MusicBeatState
 		var spec:SpectogramSprite = new SpectogramSprite(FlxG.sound.music);
 		add(spec);
 
-		Conductor.bpm = 190;
+		Conductor.forceBPM(190);
 		FlxG.camera.flash(FlxColor.WHITE, 1);
 		FlxG.sound.play(Paths.sound('confirmMenu'), 0.7);
 	}
diff --git a/source/funkin/charting/ChartingState.hx b/source/funkin/charting/ChartingState.hx
index 90b0de853..cb0faa74e 100644
--- a/source/funkin/charting/ChartingState.hx
+++ b/source/funkin/charting/ChartingState.hx
@@ -148,7 +148,7 @@ class ChartingState extends MusicBeatState
 		updateGrid();
 
 		loadSong(_song.song);
-		Conductor.bpm = _song.bpm;
+		// Conductor.bpm = _song.bpm;
 		Conductor.mapBPMChanges(_song);
 
 		bpmTxt = new FlxText(1000, 50, 0, "", 16);
@@ -549,7 +549,7 @@ class ChartingState extends MusicBeatState
 			{
 				tempBpm = nums.value;
 				Conductor.mapBPMChanges(_song);
-				Conductor.bpm = nums.value;
+				Conductor.forceBPM(nums.value);
 			}
 			else if (wname == 'note_susLength')
 			{
@@ -1223,7 +1223,7 @@ class ChartingState extends MusicBeatState
 
 		if (SongLoad.getSong()[curSection].changeBPM && SongLoad.getSong()[curSection].bpm > 0)
 		{
-			Conductor.bpm = SongLoad.getSong()[curSection].bpm;
+			Conductor.forceBPM(SongLoad.getSong()[curSection].bpm);
 			FlxG.log.add('CHANGED BPM!');
 		}
 		else
@@ -1233,7 +1233,7 @@ class ChartingState extends MusicBeatState
 			for (i in 0...curSection)
 				if (SongLoad.getSong()[i].changeBPM)
 					daBPM = SongLoad.getSong()[i].bpm;
-			Conductor.bpm = daBPM;
+			Conductor.forceBPM(daBPM);
 		}
 
 		/* // PORT BULLSHIT, INCASE THERE'S NO SUSTAIN DATA FOR A NOTE
diff --git a/source/funkin/play/PicoFight.hx b/source/funkin/play/PicoFight.hx
index 286d13550..b6adb811c 100644
--- a/source/funkin/play/PicoFight.hx
+++ b/source/funkin/play/PicoFight.hx
@@ -5,6 +5,7 @@ import flixel.addons.effects.FlxTrail;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.math.FlxMath;
 import flixel.util.FlxColor;
+import flixel.util.FlxDirectionFlags;
 import funkin.audiovis.PolygonSpectogram;
 import funkin.noteStuff.NoteBasic.NoteData;
 
@@ -35,7 +36,7 @@ class PicoFight extends MusicBeatState
 		FlxG.sound.playMusic(Paths.inst("blazin"));
 
 		SongLoad.loadFromJson('blazin', "blazin");
-		Conductor.bpm = SongLoad.songData.bpm;
+		Conductor.forceBPM(SongLoad.songData.bpm);
 
 		for (dumbassSection in SongLoad.songData.noteMap['hard'])
 		{
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index b10206cd7..5fbfccde1 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -28,6 +28,10 @@ import funkin.play.Strumline.StrumlineStyle;
 import funkin.play.character.BaseCharacter;
 import funkin.play.character.CharacterData;
 import funkin.play.scoring.Scoring;
+import funkin.play.song.Song;
+import funkin.play.song.SongData.SongNoteData;
+import funkin.play.song.SongData.SongPlayableChar;
+import funkin.play.song.SongValidator;
 import funkin.play.stage.Stage;
 import funkin.play.stage.StageData;
 import funkin.ui.PopUpStuff;
@@ -62,6 +66,8 @@ class PlayState extends MusicBeatState
 	 */
 	public static var currentSong:SwagSong = null;
 
+	public static var currentSong_NEW:Song = null;
+
 	/**
 	 * Whether the game is currently in Story Mode. If false, we are in Free Play Mode.
 	 */
@@ -116,6 +122,8 @@ class PlayState extends MusicBeatState
 	 */
 	public var currentStage:Stage = null;
 
+	public var currentChart(get, null):SongDifficulty;
+
 	/**
 	 * The internal ID of the currently active Stage.
 	 * Used to retrieve the data required to build the `currentStage`.
@@ -166,6 +174,12 @@ class PlayState extends MusicBeatState
 	 */
 	private var healthLerp:Float = 1;
 
+	/**
+	 * Forcibly disables all update logic while the game moves back to the Menu state.
+	 * This is used only when a critical error occurs and the game cannot continue.
+	 */
+	private var criticalFailure:Bool = false;
+
 	/**
 	 * RENDER OBJECTS
 	 */
@@ -244,6 +258,7 @@ class PlayState extends MusicBeatState
 	public static var storyWeek:Int = 0;
 	public static var storyPlaylist:Array<String> = [];
 	public static var storyDifficulty:Int = 1;
+	public static var storyDifficulty_NEW:String = "normal";
 	public static var seenCutscene:Bool = false;
 	public static var campaignScore:Int = 0;
 
@@ -279,8 +294,10 @@ class PlayState extends MusicBeatState
 	{
 		super.create();
 
-		if (currentSong == null)
+		if (currentSong == null && currentSong_NEW == null)
 		{
+			criticalFailure = true;
+
 			lime.app.Application.current.window.alert("There was a critical error while accessing the selected song. Click OK to return to the main menu.",
 				"Error loading PlayState");
 			FlxG.switchState(new MainMenuState());
@@ -308,28 +325,48 @@ class PlayState extends MusicBeatState
 			FlxG.sound.music.stop();
 
 		// Prepare the current song to be played.
-		FlxG.sound.cache(Paths.inst(currentSong.song));
-		FlxG.sound.cache(Paths.voices(currentSong.song));
+		if (currentChart != null)
+		{
+			currentChart.cacheInst();
+			currentChart.cacheVocals();
+		}
+		else
+		{
+			FlxG.sound.cache(Paths.inst(currentSong.song));
+			FlxG.sound.cache(Paths.voices(currentSong.song));
+		}
 
-		Conductor.songPosition = -5000;
+		Conductor.update(-5000);
 
 		// Initialize stage stuff.
 		initCameras();
 
-		if (currentSong == null)
-			currentSong = SongLoad.loadFromJson('tutorial');
-
-		Conductor.mapBPMChanges(currentSong);
-		Conductor.bpm = currentSong.bpm;
-
-		switch (currentSong.song.toLowerCase())
+		if (currentSong == null && currentSong_NEW == null)
 		{
-			case 'senpai':
-				dialogue = CoolUtil.coolTextFile(Paths.txt('songs/senpai/senpaiDialogue'));
-			case 'roses':
-				dialogue = CoolUtil.coolTextFile(Paths.txt('songs/roses/rosesDialogue'));
-			case 'thorns':
-				dialogue = CoolUtil.coolTextFile(Paths.txt('songs/thorns/thornsDialogue'));
+			currentSong = SongLoad.loadFromJson('tutorial');
+		}
+
+		if (currentSong_NEW != null)
+		{
+			Conductor.mapTimeChanges(currentChart);
+			// Conductor.bpm = currentChart.getStartingBPM();
+
+			// TODO: Support for dialog.
+		}
+		else
+		{
+			Conductor.mapBPMChanges(currentSong);
+			// Conductor.bpm = currentSong.bpm;
+
+			switch (currentSong.song.toLowerCase())
+			{
+				case 'senpai':
+					dialogue = CoolUtil.coolTextFile(Paths.txt('songs/senpai/senpaiDialogue'));
+				case 'roses':
+					dialogue = CoolUtil.coolTextFile(Paths.txt('songs/roses/rosesDialogue'));
+				case 'thorns':
+					dialogue = CoolUtil.coolTextFile(Paths.txt('songs/thorns/thornsDialogue'));
+			}
 		}
 
 		if (dialogue != null)
@@ -379,7 +416,14 @@ class PlayState extends MusicBeatState
 
 		add(grpNoteSplashes);
 
-		generateSong();
+		if (currentSong_NEW != null)
+		{
+			generateSong_NEW();
+		}
+		else
+		{
+			generateSong();
+		}
 
 		resetCamera();
 
@@ -442,6 +486,13 @@ class PlayState extends MusicBeatState
 		#end
 	}
 
+	function get_currentChart():SongDifficulty
+	{
+		if (currentSong_NEW == null || storyDifficulty_NEW == null)
+			return null;
+		return currentSong_NEW.getDifficulty(storyDifficulty_NEW);
+	}
+
 	/**
 	 * Initializes the game and HUD cameras.
 	 */
@@ -460,6 +511,12 @@ class PlayState extends MusicBeatState
 
 	function initStage()
 	{
+		if (currentSong_NEW != null)
+		{
+			initStage_NEW();
+			return;
+		}
+
 		// TODO: Move stageId to the song file.
 		switch (currentSong.song.toLowerCase())
 		{
@@ -487,9 +544,6 @@ class PlayState extends MusicBeatState
 				currentStageId = 'schoolEvil';
 			case 'guns' | 'stress' | 'ugh':
 				currentStageId = 'tankmanBattlefield';
-			case 'experimental-phase' | 'perfection':
-				// SERIOUSLY REVAMP THE CHART FORMAT ALREADY
-				currentStageId = "breakout";
 			default:
 				currentStageId = "mainStage";
 		}
@@ -497,8 +551,33 @@ class PlayState extends MusicBeatState
 		loadStage(currentStageId);
 	}
 
+	function initStage_NEW()
+	{
+		if (currentChart == null)
+		{
+			trace('Song difficulty could not be loaded.');
+		}
+
+		if (currentChart.stage != null && currentChart.stage != '')
+		{
+			currentStageId = currentChart.stage;
+		}
+		else
+		{
+			currentStageId = SongValidator.DEFAULT_STAGE;
+		}
+
+		loadStage(currentStageId);
+	}
+
 	function initCharacters()
 	{
+		if (currentSong_NEW != null)
+		{
+			initCharacters_NEW();
+			return;
+		}
+
 		iconP1 = new HealthIcon(currentSong.player1, 0);
 		iconP1.y = healthBar.y - (iconP1.height / 2);
 		add(iconP1);
@@ -615,6 +694,111 @@ class PlayState extends MusicBeatState
 		}
 	}
 
+	function initCharacters_NEW()
+	{
+		if (currentSong_NEW == null || currentChart == null)
+		{
+			trace('Song difficulty could not be loaded.');
+		}
+
+		// TODO: Switch playable character by manipulating this value.
+		// TODO: How to choose which one to use for story mode?
+		var currentPlayer = 'bf';
+
+		var currentCharData:SongPlayableChar = currentChart.getPlayableChar(currentPlayer);
+
+		//
+		// GIRLFRIEND
+		//
+		var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharData.girlfriend);
+
+		if (girlfriend != null)
+		{
+			girlfriend.characterType = CharacterType.GF;
+		}
+		else if (currentCharData.girlfriend != '')
+		{
+			trace('WARNING: Could not load girlfriend character with ID ${currentCharData.girlfriend}, skipping...');
+		}
+		else
+		{
+			// Chosen GF was '' so we don't load one.
+		}
+
+		//
+		// DAD
+		//
+		var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharData.opponent);
+
+		if (dad != null)
+		{
+			dad.characterType = CharacterType.DAD;
+		}
+
+		// TODO: Cut out this code/make it generic.
+		switch (currentCharData.opponent)
+		{
+			case 'gf':
+				if (isStoryMode)
+				{
+					cameraFollowPoint.x += 600;
+					tweenCamIn();
+				}
+		}
+
+		//
+		// OPPONENT HEALTH ICON
+		//
+		iconP2 = new HealthIcon(currentCharData.opponent, 1);
+		iconP2.y = healthBar.y - (iconP2.height / 2);
+		add(iconP2);
+
+		//
+		// BOYFRIEND
+		//
+		var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentPlayer);
+
+		if (boyfriend != null)
+		{
+			boyfriend.characterType = CharacterType.BF;
+		}
+
+		//
+		// PLAYER HEALTH ICON
+		//
+		iconP1 = new HealthIcon(currentPlayer, 0);
+		iconP1.y = healthBar.y - (iconP1.height / 2);
+		add(iconP1);
+
+		//
+		// ADD CHARACTERS TO SCENE
+		//
+
+		if (currentStage != null)
+		{
+			// Characters get added to the stage, not the main scene.
+			if (girlfriend != null)
+			{
+				currentStage.addCharacter(girlfriend, GF);
+			}
+
+			if (boyfriend != null)
+			{
+				currentStage.addCharacter(boyfriend, BF);
+			}
+
+			if (dad != null)
+			{
+				currentStage.addCharacter(dad, DAD);
+				// Camera starts at dad.
+				cameraFollowPoint.setPosition(dad.cameraFocusPoint.x, dad.cameraFocusPoint.y);
+			}
+
+			// Rearrange by z-indexes.
+			currentStage.refresh();
+		}
+	}
+
 	/**
 	 * Removes any references to the current stage, then clears the stage cache,
 	 * then reloads all the stages.
@@ -794,7 +978,14 @@ class PlayState extends MusicBeatState
 			// if (FlxG.sound.music != null)
 			// FlxG.sound.music.play(true);
 			// else
-			FlxG.sound.playMusic(Paths.inst(currentSong.song), 1, false);
+			if (currentChart != null)
+			{
+				currentChart.playInst(1.0, false);
+			}
+			else
+			{
+				FlxG.sound.playMusic(Paths.inst(currentSong.song), 1, false);
+			}
 		}
 
 		FlxG.sound.music.onComplete = endSong;
@@ -813,7 +1004,7 @@ class PlayState extends MusicBeatState
 	{
 		// FlxG.log.add(ChartParser.parse());
 
-		Conductor.bpm = currentSong.bpm;
+		Conductor.forceBPM(currentSong.bpm);
 
 		currentSong.song = currentSong.song;
 
@@ -836,6 +1027,32 @@ class PlayState extends MusicBeatState
 		generatedMusic = true;
 	}
 
+	private function generateSong_NEW():Void
+	{
+		if (currentChart == null)
+		{
+			trace('Song difficulty could not be loaded.');
+		}
+
+		Conductor.forceBPM(currentChart.getStartingBPM());
+
+		// TODO: Fix grouped vocals
+		vocals = currentChart.buildVocals();
+		vocals.members[0].onComplete = function()
+		{
+			vocalsFinished = true;
+		}
+
+		// Create the rendered note group.
+		activeNotes = new FlxTypedGroup<Note>();
+		activeNotes.zIndex = 1000;
+		add(activeNotes);
+
+		regenNoteData_NEW();
+
+		generatedMusic = true;
+	}
+
 	function regenNoteData():Void
 	{
 		// make unspawn notes shit def empty
@@ -950,6 +1167,133 @@ class PlayState extends MusicBeatState
 		});
 	}
 
+	function regenNoteData_NEW():Void
+	{
+		// Destroy inactive notes.
+		inactiveNotes = [];
+
+		// Destroy active notes.
+		activeNotes.forEach(function(nt)
+		{
+			nt.followsTime = false;
+			FlxTween.tween(nt, {y: FlxG.height + nt.y}, 0.5, {
+				ease: FlxEase.expoIn,
+				onComplete: function(twn)
+				{
+					nt.kill();
+					activeNotes.remove(nt, true);
+					nt.destroy();
+				}
+			});
+		});
+
+		var noteData:Array<SongNoteData> = currentChart.notes;
+
+		var oldNote:Note = null;
+		for (songNote in noteData)
+		{
+			var mustHitNote:Bool = songNote.getMustHitNote();
+
+			// TODO: Put this in the chart or something?
+			var strumlineStyle:StrumlineStyle = null;
+			switch (currentStageId)
+			{
+				case 'school':
+					strumlineStyle = PIXEL;
+				case 'schoolEvil':
+					strumlineStyle = PIXEL;
+				default:
+					strumlineStyle = NORMAL;
+			}
+
+			var newNote:Note = new Note(songNote.time, songNote.data, oldNote, false, strumlineStyle);
+			newNote.mustPress = mustHitNote;
+			newNote.data.sustainLength = songNote.length;
+			newNote.data.noteKind = songNote.kind;
+			newNote.scrollFactor.set(0, 0);
+
+			// Note positioning.
+			// TODO: Make this more robust.
+			if (newNote.mustPress)
+			{
+				if (playerStrumline != null)
+				{
+					// Align with the strumline arrow.
+					newNote.x = playerStrumline.getArrow(songNote.getDirection()).x;
+				}
+				else
+				{
+					// Assume strumline position.
+					newNote.x += FlxG.width / 2;
+				}
+			}
+			else
+			{
+				if (enemyStrumline != null)
+				{
+					newNote.x = enemyStrumline.getArrow(songNote.getDirection()).x;
+				}
+				else
+				{
+					// newNote.x += 0;
+				}
+			}
+
+			inactiveNotes.push(newNote);
+
+			oldNote = newNote;
+
+			// Generate X sustain notes.
+			var sustainSections = Math.round(songNote.length / Conductor.stepCrochet);
+			for (noteIndex in 0...sustainSections)
+			{
+				var noteTimeOffset:Float = Conductor.stepCrochet + (Conductor.stepCrochet * noteIndex);
+				var sustainNote:Note = new Note(songNote.time + noteTimeOffset, songNote.data, oldNote, true, strumlineStyle);
+				sustainNote.mustPress = mustHitNote;
+				sustainNote.data.noteKind = songNote.kind;
+				sustainNote.scrollFactor.set(0, 0);
+
+				if (sustainNote.mustPress)
+				{
+					if (playerStrumline != null)
+					{
+						// Align with the strumline arrow.
+						sustainNote.x = playerStrumline.getArrow(songNote.getDirection()).x;
+					}
+					else
+					{
+						// Assume strumline position.
+						sustainNote.x += FlxG.width / 2;
+					}
+				}
+				else
+				{
+					if (enemyStrumline != null)
+					{
+						sustainNote.x = enemyStrumline.getArrow(songNote.getDirection()).x;
+					}
+					else
+					{
+						// newNote.x += 0;
+					}
+				}
+
+				inactiveNotes.push(sustainNote);
+
+				oldNote = sustainNote;
+			}
+		}
+
+		// Sorting is an expensive operation.
+		// Assume it was done in the chart file.
+		/**
+			inactiveNotes.sort(function(a:Note, b:Note):Int
+			{
+				return SortUtil.byStrumtime(FlxSort.ASCENDING, a, b);
+			});
+		**/
+	}
+
 	function tweenCamIn():Void
 	{
 		FlxTween.tween(FlxG.camera, {zoom: 1.3 * FlxCamera.defaultZoom}, (Conductor.stepCrochet * 4 / 1000), {ease: FlxEase.elasticInOut});
@@ -986,7 +1330,7 @@ class PlayState extends MusicBeatState
 
 		vocals.pause();
 		FlxG.sound.music.play();
-		Conductor.songPosition = FlxG.sound.music.time + Conductor.offset;
+		Conductor.update(FlxG.sound.music.time + Conductor.offset);
 
 		if (vocalsFinished)
 			return;
@@ -999,6 +1343,9 @@ class PlayState extends MusicBeatState
 	{
 		super.update(elapsed);
 
+		if (criticalFailure)
+			return;
+
 		if (FlxG.keys.justPressed.U)
 		{
 			// hack for HaxeUI generation, doesn't work unless persistentUpdate is false at state creation!!
@@ -1027,7 +1374,16 @@ class PlayState extends MusicBeatState
 
 			currentStage.resetStage();
 
-			regenNoteData(); // loads the note data from start
+			// Delete all notes and reset the arrays.
+			if (currentChart != null)
+			{
+				regenNoteData_NEW();
+			}
+			else
+			{
+				regenNoteData();
+			}
+
 			health = 1;
 			songScore = 0;
 			combo = 0;
@@ -1058,7 +1414,7 @@ class PlayState extends MusicBeatState
 			if (Paths.SOUND_EXT == 'mp3')
 				Conductor.offset = -13; // DO NOT FORGET TO REMOVE THE HARDCODE! WHEN I MAKE BETTER OFFSET SYSTEM!
 
-			Conductor.songPosition = FlxG.sound.music.time + Conductor.offset; // 20 is THE MILLISECONDS??
+			Conductor.update(FlxG.sound.music.time + Conductor.offset);
 
 			if (!isGamePaused)
 			{
@@ -1177,7 +1533,7 @@ class PlayState extends MusicBeatState
 		}
 		FlxG.watch.addQuick("songPos", Conductor.songPosition);
 
-		if (currentSong.song == 'Fresh')
+		if (currentSong != null && currentSong.song == 'Fresh')
 		{
 			switch (curBeat)
 			{
@@ -1307,7 +1663,7 @@ class PlayState extends MusicBeatState
 
 				if (!daNote.mustPress && daNote.wasGoodHit && !daNote.tooLate)
 				{
-					if (currentSong.song != 'Tutorial')
+					if (currentSong != null && currentSong.song != 'Tutorial')
 						camZooming = true;
 
 					var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, daNote, combo, true);
@@ -1324,7 +1680,7 @@ class PlayState extends MusicBeatState
 					else
 					{
 						// Volume of DAD.
-						if (currentSong.needsVoices)
+						if (currentSong != null && currentSong.needsVoices)
 							vocals.volume = 1;
 					}
 				}
@@ -1412,8 +1768,9 @@ class PlayState extends MusicBeatState
 			}
 			daPos += 4 * (1000 * 60 / daBPM);
 		}
-		Conductor.songPosition = FlxG.sound.music.time = daPos;
-		Conductor.songPosition += Conductor.offset;
+
+		FlxG.sound.music.time = daPos;
+		Conductor.update(FlxG.sound.music.time + Conductor.offset);
 		updateCurStep();
 		resyncVocals();
 	}
@@ -1857,7 +2214,7 @@ class PlayState extends MusicBeatState
 		{
 			if (SongLoad.getSong()[Math.floor(curStep / 16)].changeBPM)
 			{
-				Conductor.bpm = SongLoad.getSong()[Math.floor(curStep / 16)].bpm;
+				Conductor.forceBPM(SongLoad.getSong()[Math.floor(curStep / 16)].bpm);
 				FlxG.log.add('CHANGED BPM!');
 			}
 		}
@@ -2118,8 +2475,14 @@ class PlayState extends MusicBeatState
 	function performCleanup()
 	{
 		// Uncache the song.
-		openfl.utils.Assets.cache.clear(Paths.inst(currentSong.song));
-		openfl.utils.Assets.cache.clear(Paths.voices(currentSong.song));
+		if (currentChart != null)
+		{
+		}
+		else
+		{
+			openfl.utils.Assets.cache.clear(Paths.inst(currentSong.song));
+			openfl.utils.Assets.cache.clear(Paths.voices(currentSong.song));
+		}
 
 		// Remove reference to stage and remove sprites from it to save memory.
 		if (currentStage != null)
diff --git a/source/funkin/play/Strumline.hx b/source/funkin/play/Strumline.hx
index a93be5c94..5efe21863 100644
--- a/source/funkin/play/Strumline.hx
+++ b/source/funkin/play/Strumline.hx
@@ -4,9 +4,11 @@ import flixel.FlxSprite;
 import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
 import flixel.math.FlxPoint;
 import flixel.tweens.FlxEase;
+import flixel.tweens.FlxTween;
 import funkin.noteStuff.NoteBasic.NoteColor;
 import funkin.noteStuff.NoteBasic.NoteDir;
 import funkin.noteStuff.NoteBasic.NoteType;
+import funkin.ui.PreferencesMenu;
 import funkin.util.Constants;
 
 /**
diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx
index 7b739c138..ca708da8d 100644
--- a/source/funkin/play/character/BaseCharacter.hx
+++ b/source/funkin/play/character/BaseCharacter.hx
@@ -273,6 +273,10 @@ class BaseCharacter extends Bopper
 	{
 		if (!isOpponent)
 		{
+			if (PlayState.instance.iconP1 == null)
+			{
+				trace('[WARN] Player 1 health icon not found!');
+			}
 			PlayState.instance.iconP1.characterId = _data.healthIcon.id;
 			PlayState.instance.iconP1.size.set(_data.healthIcon.scale, _data.healthIcon.scale);
 			PlayState.instance.iconP1.offset.x = _data.healthIcon.offsets[0];
@@ -281,6 +285,10 @@ class BaseCharacter extends Bopper
 		}
 		else
 		{
+			if (PlayState.instance.iconP2 == null)
+			{
+				trace('[WARN] Player 2 health icon not found!');
+			}
 			PlayState.instance.iconP2.characterId = _data.healthIcon.id;
 			PlayState.instance.iconP2.size.set(_data.healthIcon.scale, _data.healthIcon.scale);
 			PlayState.instance.iconP2.offset.x = _data.healthIcon.offsets[0];
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index aa9095f8c..56053bcf8 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -1,5 +1,7 @@
 package funkin.play.song;
 
+import funkin.VoicesGroup;
+import funkin.play.song.SongData.SongChartData;
 import funkin.play.song.SongData.SongDataParser;
 import funkin.play.song.SongData.SongEventData;
 import funkin.play.song.SongData.SongMetadata;
@@ -45,14 +47,18 @@ class Song // implements IPlayStateScriptedClass
 		cacheCharts();
 	}
 
-	function populateFromMetadata()
+	/**
+	 * Populate the song data from the provided metadata,
+	 * including data from individual difficulties. Does not load chart data.
+	 */
+	function populateFromMetadata():Void
 	{
 		// Variations may have different artist, time format, generatedBy, etc.
 		for (metadata in _metadata)
 		{
 			for (diffId in metadata.playData.difficulties)
 			{
-				var difficulty = new SongDifficulty(diffId, metadata.variation);
+				var difficulty:SongDifficulty = new SongDifficulty(this, diffId, metadata.variation);
 
 				variations.push(metadata.variation);
 
@@ -83,25 +89,27 @@ class Song // implements IPlayStateScriptedClass
 	/**
 	 * Parse and cache the chart for all difficulties of this song.
 	 */
-	public function cacheCharts()
+	public function cacheCharts():Void
 	{
 		trace('Caching ${variations.length} chart files for song $songId');
 		for (variation in variations)
 		{
-			var chartData = SongDataParser.parseSongChartData(songId, variation);
+			var chartData:SongChartData = SongDataParser.parseSongChartData(songId, variation);
+			var chartNotes = chartData.notes;
 
-			for (diffId in chartData.notes.keys())
+			for (diffId in chartNotes.keys())
 			{
-				trace('  Difficulty $diffId');
-				var difficulty = difficulties.get(diffId);
+				// Retrieve the cached difficulty data.
+				var difficulty:Null<SongDifficulty> = difficulties.get(diffId);
 				if (difficulty == null)
 				{
 					trace('Could not find difficulty $diffId for song $songId');
 					continue;
 				}
-
+				// Add the chart data to the difficulty.
 				difficulty.notes = chartData.notes.get(diffId);
-				difficulty.scrollSpeed = chartData.scrollSpeed.get(diffId);
+				difficulty.scrollSpeed = chartData.getScrollSpeed(diffId);
+
 				difficulty.events = chartData.events;
 			}
 		}
@@ -111,7 +119,7 @@ class Song // implements IPlayStateScriptedClass
 	/**
 	 * Retrieve the metadata for a specific difficulty, including the chart if it is loaded.
 	 */
-	public function getDifficulty(diffId:String):SongDifficulty
+	public inline function getDifficulty(diffId:String):SongDifficulty
 	{
 		return difficulties.get(diffId);
 	}
@@ -119,7 +127,7 @@ class Song // implements IPlayStateScriptedClass
 	/**
 	 * Purge the cached chart data for each difficulty of this song.
 	 */
-	public function clearCharts()
+	public function clearCharts():Void
 	{
 		for (diff in difficulties)
 		{
@@ -135,6 +143,11 @@ class Song // implements IPlayStateScriptedClass
 
 class SongDifficulty
 {
+	/**
+	 * The parent song for this difficulty.
+	 */
+	public final song:Song;
+
 	/**
 	 * The difficulty ID, such as `easy` or `hard`.
 	 */
@@ -162,8 +175,9 @@ class SongDifficulty
 	public var notes:Array<SongNoteData>;
 	public var events:Array<SongEventData>;
 
-	public function new(diffId:String, variation:String)
+	public function new(song:Song, diffId:String, variation:String)
 	{
+		this.song = song;
 		this.difficulty = diffId;
 		this.variation = variation;
 	}
@@ -172,4 +186,48 @@ class SongDifficulty
 	{
 		notes = null;
 	}
+
+	public function getStartingBPM():Float
+	{
+		if (timeChanges.length == 0)
+		{
+			return 0;
+		}
+
+		return timeChanges[0].bpm;
+	}
+
+	public function getPlayableChar(id:String):SongPlayableChar
+	{
+		return chars.get(id);
+	}
+
+	public inline function cacheInst()
+	{
+		// DEBUG: Remove this.
+		// FlxG.sound.cache(Paths.inst(this.song.songId));
+		FlxG.sound.cache(Paths.inst('bopeebo'));
+	}
+
+	public inline function playInst(volume:Float = 1.0, looped:Bool = false)
+	{
+		// DEBUG: Remove this.
+		// FlxG.sound.playMusic(Paths.inst(this.song.songId), volume, looped);
+		FlxG.sound.playMusic(Paths.inst('bopeebo'), volume, looped);
+	}
+
+	public inline function cacheVocals()
+	{
+		// DEBUG: Remove this.
+		// FlxG.sound.cache(Paths.voices(this.song.songId));
+		FlxG.sound.cache(Paths.voices('bopeebo'));
+	}
+
+	public inline function buildVocals(charId:String = "bf"):VoicesGroup
+	{
+		// DEBUG: Remove this.
+		// var result:VoicesGroup = new VoicesGroup(this.song.songId, null, false);
+		var result:VoicesGroup = new VoicesGroup('bopeebo', null, false);
+		return result;
+	}
 }
diff --git a/source/funkin/play/song/SongData.hx b/source/funkin/play/song/SongData.hx
index bdf9d9043..04d3d2305 100644
--- a/source/funkin/play/song/SongData.hx
+++ b/source/funkin/play/song/SongData.hx
@@ -339,6 +339,11 @@ abstract SongNoteData(RawSongNoteData)
 		return Math.floor(this.d / strumlineSize);
 	}
 
+	public inline function getMustHitNote(strumlineSize:Int = 4):Bool
+	{
+		return getStrumlineIndex(strumlineSize) == 0;
+	}
+
 	public var length(get, set):Float;
 
 	public function get_length():Float
@@ -522,7 +527,7 @@ abstract SongPlayableChar(RawSongPlayableChar)
 	}
 }
 
-typedef SongChartData =
+typedef RawSongChartData =
 {
 	var version:Version;
 
@@ -532,6 +537,32 @@ typedef SongChartData =
 	var generatedBy:String;
 };
 
+@:forward
+abstract SongChartData(RawSongChartData)
+{
+	public function new(scrollSpeed:DynamicAccess<Float>, events:Array<SongEventData>, notes:DynamicAccess<Array<SongNoteData>>)
+	{
+		this = {
+			version: SongMigrator.CHART_VERSION,
+
+			events: events,
+			notes: notes,
+			scrollSpeed: scrollSpeed,
+			generatedBy: SongValidator.DEFAULT_GENERATEDBY
+		}
+	}
+
+	public function getScrollSpeed(diff:String = 'default'):Float
+	{
+		var result:Float = this.scrollSpeed.get(diff);
+
+		if (result == 0.0 && diff != 'default')
+			return getScrollSpeed('default');
+
+		return (result == 0.0) ? 1.0 : result;
+	}
+}
+
 typedef RawSongTimeChange =
 {
 	/**
@@ -569,6 +600,17 @@ typedef RawSongTimeChange =
 	var bt:OneOfTwo<Int, Array<Int>>;
 }
 
+typedef RawConductorTimeChange =
+{
+	> RawSongTimeChange,
+
+	/**
+	 * The time in the song (in steps) that this change occurs at.
+	 * This time is somewhat weird because the rate it increases is dependent on the BPM at that point in the song.
+	 */
+	public var st:Float;
+}
+
 /**
  * Add aliases to the minimalized property names of the typedef,
  * to improve readability.
@@ -667,6 +709,113 @@ abstract SongTimeChange(RawSongTimeChange)
 	}
 }
 
+abstract ConductorTimeChange(RawConductorTimeChange)
+{
+	public function new(timeStamp:Float, beatTime:Int, bpm:Float, timeSignatureNum:Int = 4, timeSignatureDen:Int = 4, beatTuplets:Array<Int>)
+	{
+		this = {
+			t: timeStamp,
+			b: beatTime,
+			bpm: bpm,
+			n: timeSignatureNum,
+			d: timeSignatureDen,
+			bt: beatTuplets,
+			st: 0.0
+		}
+	}
+
+	public var timeStamp(get, set):Float;
+
+	public function get_timeStamp():Float
+	{
+		return this.t;
+	}
+
+	public function set_timeStamp(value:Float):Float
+	{
+		return this.t = value;
+	}
+
+	public var beatTime(get, set):Int;
+
+	public function get_beatTime():Int
+	{
+		return this.b;
+	}
+
+	public function set_beatTime(value:Int):Int
+	{
+		return this.b = value;
+	}
+
+	public var bpm(get, set):Float;
+
+	public function get_bpm():Float
+	{
+		return this.bpm;
+	}
+
+	public function set_bpm(value:Float):Float
+	{
+		return this.bpm = value;
+	}
+
+	public var timeSignatureNum(get, set):Int;
+
+	public function get_timeSignatureNum():Int
+	{
+		return this.n;
+	}
+
+	public function set_timeSignatureNum(value:Int):Int
+	{
+		return this.n = value;
+	}
+
+	public var timeSignatureDen(get, set):Int;
+
+	public function get_timeSignatureDen():Int
+	{
+		return this.d;
+	}
+
+	public function set_timeSignatureDen(value:Int):Int
+	{
+		return this.d = value;
+	}
+
+	public var beatTuplets(get, set):Array<Int>;
+
+	public function get_beatTuplets():Array<Int>
+	{
+		if (Std.isOfType(this.bt, Int))
+		{
+			return [this.bt];
+		}
+		else
+		{
+			return this.bt;
+		}
+	}
+
+	public function set_beatTuplets(value:Array<Int>):Array<Int>
+	{
+		return this.bt = value;
+	}
+
+	public var stepTime(get, set):Float;
+
+	public function get_stepTime():Float
+	{
+		return this.st;
+	}
+
+	public function set_stepTime(value:Float):Float
+	{
+		return this.st = value;
+	}
+}
+
 enum abstract SongTimeFormat(String) from String to String
 {
 	var TICKS = "ticks";
diff --git a/source/funkin/play/song/SongValidator.hx b/source/funkin/play/song/SongValidator.hx
index 8e52bd33e..592ca818d 100644
--- a/source/funkin/play/song/SongValidator.hx
+++ b/source/funkin/play/song/SongValidator.hx
@@ -5,6 +5,7 @@ import funkin.play.song.SongData.SongMetadata;
 import funkin.play.song.SongData.SongPlayData;
 import funkin.play.song.SongData.SongTimeChange;
 import funkin.play.song.SongData.SongTimeFormat;
+import funkin.util.Constants;
 
 /**
  * For SongMetadata and SongChartData objects,
@@ -17,10 +18,16 @@ class SongValidator
 	public static final DEFAULT_TIMEFORMAT:SongTimeFormat = SongTimeFormat.MILLISECONDS;
 	public static final DEFAULT_DIVISIONS:Int = -1;
 	public static final DEFAULT_LOOP:Bool = false;
-	public static final DEFAULT_GENERATEDBY:String = "Unknown";
 	public static final DEFAULT_STAGE:String = "mainStage";
 	public static final DEFAULT_SCROLLSPEED:Float = 1.0;
 
+	public static var DEFAULT_GENERATEDBY(get, null):String;
+
+	static function get_DEFAULT_GENERATEDBY():String
+	{
+		return '${Constants.TITLE} - ${Constants.VERSION}';
+	}
+
 	/**
 	 * Validates the fields of a SongMetadata object (excluding the version field).
 	 *