diff --git a/assets b/assets
index 482ef7658..3b05b0fdd 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 482ef76582208a484a2f6450ce5ad9e278db08f8
+Subproject commit 3b05b0fdd8e3b2cd09b9e4e415c186bae8e3b7d3
diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
index e79e1a3f4..6fd54b8b6 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -674,6 +674,22 @@ class SongNoteDataRaw
     this.kind = kind;
   }
 
+  /**
+   * The direction of the note, if applicable.
+   * Strips the strumline index from the data.
+   *
+   * 0 = left, 1 = down, 2 = up, 3 = right
+   */
+  public inline function getDirection(strumlineSize:Int = 4):Int
+  {
+    return this.data % strumlineSize;
+  }
+
+  public function getDirectionName(strumlineSize:Int = 4):String
+  {
+    return SongNoteData.buildDirectionName(this.data, strumlineSize);
+  }
+
   @:jignored
   var _stepTime:Null<Float> = null;
 
@@ -730,22 +746,6 @@ abstract SongNoteData(SongNoteDataRaw) from SongNoteDataRaw to SongNoteDataRaw
     this = new SongNoteDataRaw(time, data, length, kind);
   }
 
-  /**
-   * The direction of the note, if applicable.
-   * Strips the strumline index from the data.
-   *
-   * 0 = left, 1 = down, 2 = up, 3 = right
-   */
-  public inline function getDirection(strumlineSize:Int = 4):Int
-  {
-    return this.data % strumlineSize;
-  }
-
-  public function getDirectionName(strumlineSize:Int = 4):String
-  {
-    return SongNoteData.buildDirectionName(this.data, strumlineSize);
-  }
-
   public static function buildDirectionName(data:Int, strumlineSize:Int = 4):String
   {
     switch (data % strumlineSize)
diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx
index 674c96fb6..efb3ee623 100644
--- a/source/funkin/save/Save.hx
+++ b/source/funkin/save/Save.hx
@@ -5,12 +5,17 @@ import funkin.save.migrator.SaveDataMigrator;
 import thx.semver.Version;
 import funkin.input.Controls.Device;
 import funkin.save.migrator.RawSaveData_v1_0_0;
+import funkin.save.migrator.SaveDataMigrator;
+import funkin.ui.debug.charting.ChartEditorState.ChartEditorLiveInputStyle;
+import funkin.ui.debug.charting.ChartEditorState.ChartEditorTheme;
+import thx.semver.Version;
 
 @:nullSafety
 @:forward(volume, mute)
 abstract Save(RawSaveData)
 {
-  public static final SAVE_DATA_VERSION:thx.semver.Version = "2.0.0";
+  // Version 2.0.1 adds attributes to `optionsChartEditor`, that should return default values if they are null.
+  public static final SAVE_DATA_VERSION:thx.semver.Version = "2.0.1";
   public static final SAVE_DATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x";
 
   // We load this version's saves from a new save path, to maintain SOME level of backwards compatibility.
@@ -94,6 +99,18 @@ abstract Save(RawSaveData)
         optionsChartEditor:
           {
             // Reasonable defaults.
+            previousFiles: [],
+            noteQuant: 3,
+            chartEditorLiveInputStyle: ChartEditorLiveInputStyle.None,
+            theme: ChartEditorTheme.Light,
+            playtestStartTime: false,
+            downscroll: false,
+            metronomeEnabled: true,
+            hitsoundsEnabledPlayer: true,
+            hitsoundsEnabledOpponent: true,
+            instVolume: 1.0,
+            voicesVolume: 1.0,
+            playbackSpeed: 1.0,
           },
       };
   }
@@ -124,7 +141,9 @@ abstract Save(RawSaveData)
 
   function set_ngSessionId(value:Null<String>):Null<String>
   {
-    return this.api.newgrounds.sessionId = value;
+    this.api.newgrounds.sessionId = value;
+    flush();
+    return this.api.newgrounds.sessionId;
   }
 
   public var enabledModIds(get, set):Array<String>;
@@ -136,7 +155,213 @@ abstract Save(RawSaveData)
 
   function set_enabledModIds(value:Array<String>):Array<String>
   {
-    return this.mods.enabledMods = value;
+    this.mods.enabledMods = value;
+    flush();
+    return this.mods.enabledMods;
+  }
+
+  public var chartEditorPreviousFiles(get, set):Array<String>;
+
+  function get_chartEditorPreviousFiles():Array<String>
+  {
+    if (this.optionsChartEditor.previousFiles == null) this.optionsChartEditor.previousFiles = [];
+
+    return this.optionsChartEditor.previousFiles;
+  }
+
+  function set_chartEditorPreviousFiles(value:Array<String>):Array<String>
+  {
+    // Set and apply.
+    this.optionsChartEditor.previousFiles = value;
+    flush();
+    return this.optionsChartEditor.previousFiles;
+  }
+
+  public var chartEditorNoteQuant(get, set):Int;
+
+  function get_chartEditorNoteQuant():Int
+  {
+    if (this.optionsChartEditor.noteQuant == null) this.optionsChartEditor.noteQuant = 3;
+
+    return this.optionsChartEditor.noteQuant;
+  }
+
+  function set_chartEditorNoteQuant(value:Int):Int
+  {
+    // Set and apply.
+    this.optionsChartEditor.noteQuant = value;
+    flush();
+    return this.optionsChartEditor.noteQuant;
+  }
+
+  public var chartEditorLiveInputStyle(get, set):ChartEditorLiveInputStyle;
+
+  function get_chartEditorLiveInputStyle():ChartEditorLiveInputStyle
+  {
+    if (this.optionsChartEditor.chartEditorLiveInputStyle == null) this.optionsChartEditor.chartEditorLiveInputStyle = ChartEditorLiveInputStyle.None;
+
+    return this.optionsChartEditor.chartEditorLiveInputStyle;
+  }
+
+  function set_chartEditorLiveInputStyle(value:ChartEditorLiveInputStyle):ChartEditorLiveInputStyle
+  {
+    // Set and apply.
+    this.optionsChartEditor.chartEditorLiveInputStyle = value;
+    flush();
+    return this.optionsChartEditor.chartEditorLiveInputStyle;
+  }
+
+  public var chartEditorDownscroll(get, set):Bool;
+
+  function get_chartEditorDownscroll():Bool
+  {
+    if (this.optionsChartEditor.downscroll == null) this.optionsChartEditor.downscroll = false;
+
+    return this.optionsChartEditor.downscroll;
+  }
+
+  function set_chartEditorDownscroll(value:Bool):Bool
+  {
+    // Set and apply.
+    this.optionsChartEditor.downscroll = value;
+    flush();
+    return this.optionsChartEditor.downscroll;
+  }
+
+  public var chartEditorPlaytestStartTime(get, set):Bool;
+
+  function get_chartEditorPlaytestStartTime():Bool
+  {
+    if (this.optionsChartEditor.playtestStartTime == null) this.optionsChartEditor.playtestStartTime = false;
+
+    return this.optionsChartEditor.playtestStartTime;
+  }
+
+  function set_chartEditorPlaytestStartTime(value:Bool):Bool
+  {
+    // Set and apply.
+    this.optionsChartEditor.playtestStartTime = value;
+    flush();
+    return this.optionsChartEditor.playtestStartTime;
+  }
+
+  public var chartEditorTheme(get, set):ChartEditorTheme;
+
+  function get_chartEditorTheme():ChartEditorTheme
+  {
+    if (this.optionsChartEditor.theme == null) this.optionsChartEditor.theme = ChartEditorTheme.Light;
+
+    return this.optionsChartEditor.theme;
+  }
+
+  function set_chartEditorTheme(value:ChartEditorTheme):ChartEditorTheme
+  {
+    // Set and apply.
+    this.optionsChartEditor.theme = value;
+    flush();
+    return this.optionsChartEditor.theme;
+  }
+
+  public var chartEditorMetronomeEnabled(get, set):Bool;
+
+  function get_chartEditorMetronomeEnabled():Bool
+  {
+    if (this.optionsChartEditor.metronomeEnabled == null) this.optionsChartEditor.metronomeEnabled = true;
+
+    return this.optionsChartEditor.metronomeEnabled;
+  }
+
+  function set_chartEditorMetronomeEnabled(value:Bool):Bool
+  {
+    // Set and apply.
+    this.optionsChartEditor.metronomeEnabled = value;
+    flush();
+    return this.optionsChartEditor.metronomeEnabled;
+  }
+
+  public var chartEditorHitsoundsEnabledPlayer(get, set):Bool;
+
+  function get_chartEditorHitsoundsEnabledPlayer():Bool
+  {
+    if (this.optionsChartEditor.hitsoundsEnabledPlayer == null) this.optionsChartEditor.hitsoundsEnabledPlayer = true;
+
+    return this.optionsChartEditor.hitsoundsEnabledPlayer;
+  }
+
+  function set_chartEditorHitsoundsEnabledPlayer(value:Bool):Bool
+  {
+    // Set and apply.
+    this.optionsChartEditor.hitsoundsEnabledPlayer = value;
+    flush();
+    return this.optionsChartEditor.hitsoundsEnabledPlayer;
+  }
+
+  public var chartEditorHitsoundsEnabledOpponent(get, set):Bool;
+
+  function get_chartEditorHitsoundsEnabledOpponent():Bool
+  {
+    if (this.optionsChartEditor.hitsoundsEnabledOpponent == null) this.optionsChartEditor.hitsoundsEnabledOpponent = true;
+
+    return this.optionsChartEditor.hitsoundsEnabledOpponent;
+  }
+
+  function set_chartEditorHitsoundsEnabledOpponent(value:Bool):Bool
+  {
+    // Set and apply.
+    this.optionsChartEditor.hitsoundsEnabledOpponent = value;
+    flush();
+    return this.optionsChartEditor.hitsoundsEnabledOpponent;
+  }
+
+  public var chartEditorInstVolume(get, set):Float;
+
+  function get_chartEditorInstVolume():Float
+  {
+    if (this.optionsChartEditor.instVolume == null) this.optionsChartEditor.instVolume = 1.0;
+
+    return this.optionsChartEditor.instVolume;
+  }
+
+  function set_chartEditorInstVolume(value:Float):Float
+  {
+    // Set and apply.
+    this.optionsChartEditor.instVolume = value;
+    flush();
+    return this.optionsChartEditor.instVolume;
+  }
+
+  public var chartEditorVoicesVolume(get, set):Float;
+
+  function get_chartEditorVoicesVolume():Float
+  {
+    if (this.optionsChartEditor.voicesVolume == null) this.optionsChartEditor.voicesVolume = 1.0;
+
+    return this.optionsChartEditor.voicesVolume;
+  }
+
+  function set_chartEditorVoicesVolume(value:Float):Float
+  {
+    // Set and apply.
+    this.optionsChartEditor.voicesVolume = value;
+    flush();
+    return this.optionsChartEditor.voicesVolume;
+  }
+
+  public var chartEditorPlaybackSpeed(get, set):Float;
+
+  function get_chartEditorPlaybackSpeed():Float
+  {
+    if (this.optionsChartEditor.playbackSpeed == null) this.optionsChartEditor.playbackSpeed = 1.0;
+
+    return this.optionsChartEditor.playbackSpeed;
+  }
+
+  function set_chartEditorPlaybackSpeed(value:Float):Float
+  {
+    // Set and apply.
+    this.optionsChartEditor.playbackSpeed = value;
+    flush();
+    return this.optionsChartEditor.playbackSpeed;
   }
 
   /**
@@ -699,4 +924,77 @@ typedef SaveControlsData =
 /**
  * An anonymous structure containing all the user's options and preferences, specific to the Chart Editor.
  */
-typedef SaveDataChartEditorOptions = {};
+typedef SaveDataChartEditorOptions =
+{
+  /**
+   * Previous files opened in the Chart Editor.
+   * @default `[]`
+   */
+  var ?previousFiles:Array<String>;
+
+  /**
+   * Note snapping level in the Chart Editor.
+   * @default `3`
+   */
+  var ?noteQuant:Int;
+
+  /**
+   * Live input style in the Chart Editor.
+   * @default `ChartEditorLiveInputStyle.None`
+   */
+  var ?chartEditorLiveInputStyle:ChartEditorLiveInputStyle;
+
+  /**
+   * Theme in the Chart Editor.
+   * @default `ChartEditorTheme.Light`
+   */
+  var ?theme:ChartEditorTheme;
+
+  /**
+   * Downscroll in the Chart Editor.
+   * @default `false`
+   */
+  var ?downscroll:Bool;
+
+  /**
+   * Metronome sounds in the Chart Editor.
+   * @default `true`
+   */
+  var ?metronomeEnabled:Bool;
+
+  /**
+   * If true, playtest songs from the current position in the Chart Editor.
+   * @default `false`
+   */
+  var ?playtestStartTime:Bool;
+
+  /**
+   * Player note hit sounds in the Chart Editor.
+   * @default `true`
+   */
+  var ?hitsoundsEnabledPlayer:Bool;
+
+  /**
+   * Opponent note hit sounds in the Chart Editor.
+   * @default `true`
+   */
+  var ?hitsoundsEnabledOpponent:Bool;
+
+  /**
+   * Instrumental volume in the Chart Editor.
+   * @default `1.0`
+   */
+  var ?instVolume:Float;
+
+  /**
+   * Voices volume in the Chart Editor.
+   * @default `1.0`
+   */
+  var ?voicesVolume:Float;
+
+  /**
+   * Playback speed in the Chart Editor.
+   * @default `1.0`
+   */
+  var ?playbackSpeed:Float;
+};
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 382bab592..67c4e1fc4 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -12,6 +12,7 @@ import flixel.math.FlxMath;
 import flixel.math.FlxPoint;
 import flixel.math.FlxRect;
 import flixel.sound.FlxSound;
+import flixel.system.FlxAssets.FlxSoundAsset;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
 import flixel.tweens.misc.VarTween;
@@ -32,6 +33,7 @@ import funkin.input.TurboKeyHandler;
 import funkin.modding.events.ScriptEvent;
 import funkin.play.character.BaseCharacter.CharacterType;
 import funkin.play.character.CharacterData;
+import funkin.play.character.CharacterData.CharacterDataParser;
 import funkin.play.components.HealthIcon;
 import funkin.play.notes.NoteSprite;
 import funkin.play.PlayState;
@@ -46,6 +48,7 @@ import funkin.data.song.SongDataUtils;
 import funkin.ui.debug.charting.commands.ChartEditorCommand;
 import funkin.ui.debug.charting.handlers.ChartEditorShortcutHandler;
 import funkin.play.stage.StageData;
+import funkin.save.Save;
 import funkin.ui.debug.charting.commands.AddEventsCommand;
 import funkin.ui.debug.charting.commands.AddNotesCommand;
 import funkin.ui.debug.charting.commands.ChartEditorCommand;
@@ -77,6 +80,7 @@ import funkin.util.SortUtil;
 import funkin.util.WindowUtil;
 import haxe.DynamicAccess;
 import haxe.io.Bytes;
+import haxe.io.Path;
 import haxe.ui.components.DropDown;
 import haxe.ui.components.Label;
 import haxe.ui.components.NumberStepper;
@@ -84,6 +88,7 @@ import haxe.ui.components.Slider;
 import haxe.ui.components.TextField;
 import haxe.ui.containers.dialogs.CollapsibleDialog;
 import haxe.ui.containers.Frame;
+import haxe.ui.containers.menus.Menu;
 import haxe.ui.containers.menus.MenuItem;
 import haxe.ui.containers.TreeView;
 import haxe.ui.containers.TreeViewNode;
@@ -95,6 +100,7 @@ import haxe.ui.focus.FocusManager;
 import haxe.ui.notifications.NotificationManager;
 import haxe.ui.notifications.NotificationType;
 import openfl.display.BitmapData;
+import funkin.util.FileUtil;
 
 using Lambda;
 
@@ -750,7 +756,9 @@ class ChartEditorState extends HaxeUIState
       }
     }
 
-    return saveDataDirty = value;
+    saveDataDirty = value;
+    applyWindowTitle();
+    return saveDataDirty;
   }
 
   /**
@@ -932,7 +940,7 @@ class ChartEditorState extends HaxeUIState
     var result:Null<SongMetadata> = songMetadata.get(selectedVariation);
     if (result == null)
     {
-      result = new SongMetadata('Dad Battle', 'Kawai Sprite', selectedVariation);
+      result = new SongMetadata('DadBattle', 'Kawai Sprite', selectedVariation);
       songMetadata.set(selectedVariation, result);
     }
     return result;
@@ -1229,11 +1237,6 @@ class ChartEditorState extends HaxeUIState
    */
   var gridGhostEvent:Null<ChartEditorEventSprite> = null;
 
-  /**
-   * The waveform which (optionally) displays over the grid, underneath the notes and playhead.
-   */
-  var gridSpectrogram:Null<PolygonSpectogram> = null;
-
   /**
    * The sprite used to display the note preview area.
    * We move this up and down to scroll the preview.
@@ -1272,6 +1275,16 @@ class ChartEditorState extends HaxeUIState
    */
   var playbarHeadLayout:Null<Component> = null;
 
+  /**
+   * The submenu in the menubar containing recently opened files.
+   */
+  var menubarOpenRecent:Null<Menu> = null;
+
+  /**
+   * The item in the menubar to save the currently opened chart.
+   */
+  var menubarItemSaveChart:Null<MenuItem> = null;
+
   /**
    * The playbar head slider.
    */
@@ -1326,9 +1339,68 @@ class ChartEditorState extends HaxeUIState
   var params:Null<ChartEditorParams>;
 
   /**
-   * The current file path which the chart editor is working with.
+   * A list of previous working file paths.
+   * Also known as the "recent files" list.
+   * The first element is [null] if the current working file has not been saved anywhere yet.
    */
-  public var currentWorkingFilePath:Null<String>;
+  public var previousWorkingFilePaths(default, set):Array<Null<String>> = [null];
+
+  function set_previousWorkingFilePaths(value:Array<Null<String>>):Array<Null<String>>
+  {
+    // Called only when the WHOLE LIST is overridden.
+    previousWorkingFilePaths = value;
+    applyWindowTitle();
+    populateOpenRecentMenu();
+    applyCanQuickSave();
+    return value;
+  }
+
+  /**
+   * The current file path which the chart editor is working with.
+   * If `null`, the current chart has not been saved yet.
+   */
+  public var currentWorkingFilePath(get, set):Null<String>;
+
+  function get_currentWorkingFilePath():Null<String>
+  {
+    return previousWorkingFilePaths[0];
+  }
+
+  function set_currentWorkingFilePath(value:Null<String>):Null<String>
+  {
+    if (value == previousWorkingFilePaths[0]) return value;
+
+    if (previousWorkingFilePaths.contains(null))
+    {
+      // Filter all instances of `null` from the array.
+      previousWorkingFilePaths = previousWorkingFilePaths.filter(function(x:Null<String>):Bool {
+        return x != null;
+      });
+    }
+
+    if (previousWorkingFilePaths.contains(value))
+    {
+      // Move the path to the front of the list.
+      previousWorkingFilePaths.remove(value);
+      previousWorkingFilePaths.unshift(value);
+    }
+    else
+    {
+      // Add the path to the front of the list.
+      previousWorkingFilePaths.unshift(value);
+    }
+
+    while (previousWorkingFilePaths.length > Constants.MAX_PREVIOUS_WORKING_FILES)
+    {
+      // Remove the last path in the list.
+      previousWorkingFilePaths.pop();
+    }
+
+    populateOpenRecentMenu();
+    applyWindowTitle();
+
+    return value;
+  }
 
   public function new(?params:ChartEditorParams)
   {
@@ -1386,6 +1458,8 @@ class ChartEditorState extends HaxeUIState
     // Show the mouse cursor.
     Cursor.show();
 
+    loadPreferences();
+
     fixCamera();
 
     // Get rid of any music from the previous state.
@@ -1401,11 +1475,11 @@ class ChartEditorState extends HaxeUIState
     this.updateTheme();
 
     buildGrid();
-    // buildSpectrogram(audioInstTrack);
     buildNotePreview();
     buildSelectionBox();
 
     buildAdditionalUI();
+    populateOpenRecentMenu();
     ChartEditorShortcutHandler.applyPlatformShortcutText(this);
 
     // Setup the onClick listeners for the UI after it's been created.
@@ -1419,22 +1493,31 @@ class ChartEditorState extends HaxeUIState
     if (params != null && params.fnfcTargetPath != null)
     {
       // Chart editor was opened from the command line. Open the FNFC file now!
-      if (ChartEditorImportExportHandler.loadFromFNFCPath(this, params.fnfcTargetPath))
+      var result:Null<Array<String>> = ChartEditorImportExportHandler.loadFromFNFCPath(this, params.fnfcTargetPath);
+      if (result != null)
       {
-        // Don't open the welcome dialog!
-
         #if !mac
         NotificationManager.instance.addNotification(
           {
             title: 'Success',
-            body: 'Loaded chart (${params.fnfcTargetPath})',
-            type: NotificationType.Success,
+            body: result.length == 0 ? 'Loaded chart (${params.fnfcTargetPath})' : 'Loaded chart (${params.fnfcTargetPath})\n${result.join("\n")}',
+            type: result.length == 0 ? NotificationType.Success : NotificationType.Warning,
             expiryMs: Constants.NOTIFICATION_DISMISS_TIME
           });
         #end
       }
       else
       {
+        #if !mac
+        NotificationManager.instance.addNotification(
+          {
+            title: 'Failure',
+            body: 'Failed to load chart (${params.fnfcTargetPath})',
+            type: NotificationType.Error,
+            expiryMs: Constants.NOTIFICATION_DISMISS_TIME
+          });
+        #end
+
         // Song failed to load, open the Welcome dialog so we aren't in a broken state.
         ChartEditorDialogHandler.openWelcomeDialog(this, false);
       }
@@ -1445,25 +1528,122 @@ class ChartEditorState extends HaxeUIState
     }
   }
 
-  override function destroy():Void
-  {
-    super.destroy();
-
-    cleanupAutoSave();
-
-    // Hide the mouse cursor on other states.
-    Cursor.hide();
-
-    @:privateAccess
-    ChartEditorNoteSprite.noteFrameCollection = null;
-  }
-
   function setupWelcomeMusic()
   {
     this.welcomeMusic.loadEmbedded(Paths.music('chartEditorLoop/chartEditorLoop'));
     this.welcomeMusic.looped = true;
-    // this.welcomeMusic.play();
-    // fadeInWelcomeMusic();
+  }
+
+  public function loadPreferences():Void
+  {
+    var save:Save = Save.get();
+
+    if (previousWorkingFilePaths[0] == null)
+    {
+      previousWorkingFilePaths = [null].concat(save.chartEditorPreviousFiles);
+    }
+    else
+    {
+      previousWorkingFilePaths = [currentWorkingFilePath].concat(save.chartEditorPreviousFiles);
+    }
+    noteSnapQuantIndex = save.chartEditorNoteQuant;
+    currentLiveInputStyle = save.chartEditorLiveInputStyle;
+    isViewDownscroll = save.chartEditorDownscroll;
+    playtestStartTime = save.chartEditorPlaytestStartTime;
+    currentTheme = save.chartEditorTheme;
+    isMetronomeEnabled = save.chartEditorMetronomeEnabled;
+    hitsoundsEnabledPlayer = save.chartEditorHitsoundsEnabledPlayer;
+    hitsoundsEnabledOpponent = save.chartEditorHitsoundsEnabledOpponent;
+
+    // audioInstTrack.volume = save.chartEditorInstVolume;
+    // audioInstTrack.pitch = save.chartEditorPlaybackSpeed;
+    // audioVocalTrackGroup.volume = save.chartEditorVoicesVolume;
+    // audioVocalTrackGroup.pitch = save.chartEditorPlaybackSpeed;
+  }
+
+  public function writePreferences():Void
+  {
+    var save:Save = Save.get();
+
+    // Can't use filter() because of null safety checking!
+    var filteredWorkingFilePaths:Array<String> = [];
+    for (chartPath in previousWorkingFilePaths)
+      if (chartPath != null) filteredWorkingFilePaths.push(chartPath);
+
+    save.chartEditorPreviousFiles = filteredWorkingFilePaths;
+    save.chartEditorNoteQuant = noteSnapQuantIndex;
+    save.chartEditorLiveInputStyle = currentLiveInputStyle;
+    save.chartEditorDownscroll = isViewDownscroll;
+    save.chartEditorPlaytestStartTime = playtestStartTime;
+    save.chartEditorTheme = currentTheme;
+    save.chartEditorMetronomeEnabled = isMetronomeEnabled;
+    save.chartEditorHitsoundsEnabledPlayer = hitsoundsEnabledPlayer;
+    save.chartEditorHitsoundsEnabledOpponent = hitsoundsEnabledOpponent;
+
+    // save.chartEditorInstVolume = audioInstTrack.volume;
+    // save.chartEditorVoicesVolume = audioVocalTrackGroup.volume;
+    // save.chartEditorPlaybackSpeed = audioInstTrack.pitch;
+  }
+
+  public function populateOpenRecentMenu():Void
+  {
+    if (menubarOpenRecent == null) return;
+
+    #if sys
+    menubarOpenRecent.removeAllComponents();
+
+    for (chartPath in previousWorkingFilePaths)
+    {
+      if (chartPath == null) continue;
+
+      var menuItemRecentChart:MenuItem = new MenuItem();
+      menuItemRecentChart.text = chartPath;
+      menuItemRecentChart.onClick = function(_event) {
+        stopWelcomeMusic();
+
+        // Load chart from file
+        var result:Null<Array<String>> = ChartEditorImportExportHandler.loadFromFNFCPath(this, chartPath);
+        if (result != null)
+        {
+          #if !mac
+          NotificationManager.instance.addNotification(
+            {
+              title: 'Success',
+              body: result.length == 0 ? 'Loaded chart (${chartPath.toString()})' : 'Loaded chart (${chartPath.toString()})\n${result.join("\n")}',
+              type: result.length == 0 ? NotificationType.Success : NotificationType.Warning,
+              expiryMs: Constants.NOTIFICATION_DISMISS_TIME
+            });
+          #end
+        }
+        else
+        {
+          #if !mac
+          NotificationManager.instance.addNotification(
+            {
+              title: 'Failure',
+              body: 'Failed to load chart (${chartPath.toString()})',
+              type: NotificationType.Error,
+              expiryMs: Constants.NOTIFICATION_DISMISS_TIME
+            });
+          #end
+        }
+      }
+
+      if (!FileUtil.doesFileExist(chartPath))
+      {
+        trace('Previously loaded chart file (${chartPath}) does not exist, disabling link...');
+        menuItemRecentChart.disabled = true;
+      }
+      else
+      {
+        menuItemRecentChart.disabled = false;
+      }
+
+      menubarOpenRecent.addComponent(menuItemRecentChart);
+    }
+    #else
+    menubarOpenRecent.hide();
+    #end
   }
 
   function fadeInWelcomeMusic():Void
@@ -1674,7 +1854,10 @@ class ChartEditorState extends HaxeUIState
   function setNotePreviewViewportBounds(bounds:FlxRect = null):Void
   {
     if (notePreviewViewport == null)
-      throw 'ERROR: Tried to set note preview viewport bounds, but notePreviewViewport is null! Check ChartEditorThemeHandler.updateTheme().';
+    {
+      trace('[WARN] Tried to set note preview viewport bounds, but notePreviewViewport is null!');
+      return;
+    }
 
     if (bounds == null)
     {
@@ -1692,16 +1875,6 @@ class ChartEditorState extends HaxeUIState
     }
   }
 
-  function buildSpectrogram(target:FlxSound):Void
-  {
-    gridSpectrogram = new PolygonSpectogram(FlxG.sound.music, FlxColor.RED, FlxG.height / 2, Math.floor(FlxG.height / 2));
-    gridSpectrogram.x += 170;
-    gridSpectrogram.scrollFactor.set();
-    gridSpectrogram.waveAmplitude = 50;
-    gridSpectrogram.visType = UPDATED;
-    add(gridSpectrogram);
-  }
-
   /**
    * Builds the group that will hold all the notes.
    */
@@ -1780,9 +1953,14 @@ class ChartEditorState extends HaxeUIState
 
     add(playbarHeadLayout);
 
+    menubarOpenRecent = findComponent('menubarOpenRecent', Menu);
+    if (menubarOpenRecent == null) throw "Could not find menubarOpenRecent!";
+
+    menubarItemSaveChart = findComponent('menubarItemSaveChart', MenuItem);
+    if (menubarItemSaveChart == null) throw "Could not find menubarItemSaveChart!";
+
     // Setup notifications.
     @:privateAccess
-    // NotificationManager.GUTTER_SIZE = 56;
     NotificationManager.GUTTER_SIZE = 20;
   }
 
@@ -1811,11 +1989,21 @@ class ChartEditorState extends HaxeUIState
 
     // Add functionality to the menu items.
 
-    addUIClickListener('menubarItemNewChart', _ -> this.openWelcomeDialog(true));
-    addUIClickListener('menubarItemOpenChart', _ -> this.openBrowseFNFC(true));
-    addUIClickListener('menubarItemSaveChartAs', _ -> this.exportAllSongData());
-    addUIClickListener('menubarItemLoadInst', _ -> this.openUploadInstDialog(true));
-    addUIClickListener('menubarItemImportChart', _ -> this.openImportChartDialog('legacy', true));
+    addUIClickListener('menubarItemNewChart', _ -> ChartEditorDialogHandler.openWelcomeDialog(this, true));
+    addUIClickListener('menubarItemOpenChart', _ -> ChartEditorDialogHandler.openBrowseFNFC(this, true));
+    addUIClickListener('menubarItemSaveChart', _ -> {
+      if (currentWorkingFilePath != null)
+      {
+        ChartEditorImportExportHandler.exportAllSongData(this, true, currentWorkingFilePath);
+      }
+      else
+      {
+        ChartEditorImportExportHandler.exportAllSongData(this, false);
+      }
+    });
+    addUIClickListener('menubarItemSaveChartAs', _ -> ChartEditorImportExportHandler.exportAllSongData(this));
+    addUIClickListener('menubarItemLoadInst', _ -> ChartEditorDialogHandler.openUploadInstDialog(this, true));
+    addUIClickListener('menubarItemImportChart', _ -> ChartEditorDialogHandler.openImportChartDialog(this, 'legacy', true));
     addUIClickListener('menubarItemExit', _ -> quitChartEditor());
 
     addUIClickListener('menubarItemUndo', _ -> undoLastCommand());
@@ -2012,6 +2200,39 @@ class ChartEditorState extends HaxeUIState
   /**
    * UPDATE FUNCTIONS
    */
+  function autoSave():Void
+  {
+    saveDataDirty = false;
+
+    // Auto-save preferences.
+    writePreferences();
+
+    // Auto-save the chart.
+    #if html5
+    // Auto-save to local storage.
+    // TODO: Implement this.
+    #else
+    // Auto-save to temp file.
+    ChartEditorImportExportHandler.exportAllSongData(this, true);
+    #end
+  }
+
+  function onWindowClose(exitCode:Int):Void
+  {
+    trace('Window exited with exit code: $exitCode');
+    trace('Should save chart? $saveDataDirty');
+
+    if (saveDataDirty)
+    {
+      ChartEditorImportExportHandler.exportAllSongData(this, true);
+    }
+  }
+
+  function cleanupAutoSave():Void
+  {
+    WindowUtil.windowExit.remove(onWindowClose);
+  }
+
   public override function update(elapsed:Float):Void
   {
     // Override F4 behavior to include the autosave.
@@ -2152,49 +2373,6 @@ class ChartEditorState extends HaxeUIState
     }
   }
 
-  /**
-   * Handle the playback of hitsounds.
-   */
-  function handleHitsounds(oldSongPosition:Float, newSongPosition:Float):Void
-  {
-    if (!hitsoundsEnabled) return;
-
-    // Assume notes are sorted by time.
-    for (noteData in currentSongChartNoteData)
-    {
-      // Check for notes between the old and new song positions.
-
-      if (noteData.time < oldSongPosition) // Note is in the past.
-        continue;
-
-      if (noteData.time > newSongPosition) // Note is in the future.
-        return; // Assume all notes are also in the future.
-
-      // Note was just hit.
-
-      // Character preview.
-
-      // NoteScriptEvent takes a sprite, ehe. Need to rework that.
-      var tempNote:NoteSprite = new NoteSprite(NoteStyleRegistry.instance.fetchDefault());
-      tempNote.noteData = noteData;
-      tempNote.scrollFactor.set(0, 0);
-      var event:NoteScriptEvent = new NoteScriptEvent(NOTE_HIT, tempNote, 1, true);
-      dispatchEvent(event);
-
-      // Calling event.cancelEvent() skips all the other logic! Neat!
-      if (event.eventCanceled) continue;
-
-      // Hitsounds.
-      switch (noteData.getStrumlineIndex())
-      {
-        case 0: // Player
-          if (hitsoundsEnabledPlayer) this.playSound(Paths.sound('chartingSounds/hitNotePlayer'));
-        case 1: // Opponent
-          if (hitsoundsEnabledOpponent) this.playSound(Paths.sound('chartingSounds/hitNoteOpponent'));
-      }
-    }
-  }
-
   /**
    * Handle using `renderedNotes` to display notes from `currentSongChartNoteData`.
    */
@@ -3463,63 +3641,6 @@ class ChartEditorState extends HaxeUIState
     }
   }
 
-  /**
-   * Handles passive behavior of the menu bar, such as updating labels or enabled/disabled status.
-   * Does not handle onClick ACTIONS of the menubar.
-   */
-  function handleMenubar():Void
-  {
-    if (commandHistoryDirty)
-    {
-      commandHistoryDirty = false;
-
-      // Update the Undo and Redo buttons.
-      var undoButton:Null<MenuItem> = findComponent('menubarItemUndo', MenuItem);
-
-      if (undoButton != null)
-      {
-        if (undoHistory.length == 0)
-        {
-          // Disable the Undo button.
-          undoButton.disabled = true;
-          undoButton.text = 'Undo';
-        }
-        else
-        {
-          // Change the label to the last command.
-          undoButton.disabled = false;
-          undoButton.text = 'Undo ${undoHistory[undoHistory.length - 1].toString()}';
-        }
-      }
-      else
-      {
-        trace('undoButton is null');
-      }
-
-      var redoButton:Null<MenuItem> = findComponent('menubarItemRedo', MenuItem);
-
-      if (redoButton != null)
-      {
-        if (redoHistory.length == 0)
-        {
-          // Disable the Redo button.
-          redoButton.disabled = true;
-          redoButton.text = 'Redo';
-        }
-        else
-        {
-          // Change the label to the last command.
-          redoButton.disabled = false;
-          redoButton.text = 'Redo ${redoHistory[redoHistory.length - 1].toString()}';
-        }
-      }
-      else
-      {
-        trace('redoButton is null');
-      }
-    }
-  }
-
   function handleToolboxes():Void
   {
     handleDifficultyToolbox();
@@ -3743,32 +3864,6 @@ class ChartEditorState extends HaxeUIState
     }
   }
 
-  /**
-   * Handles the note preview/scroll area on the right side.
-   * Notes are rendered here as small bars.
-   * This function also handles:
-   * - Moving the viewport preview box around based on its current position.
-   * - Scrolling the note preview area down if the note preview is taller than the screen,
-   *   and the viewport nears the end of the visible area.
-   */
-  function handleNotePreview():Void
-  {
-    if (notePreviewDirty && notePreview != null)
-    {
-      notePreviewDirty = false;
-
-      // TODO: Only update the notes that have changed.
-      notePreview.erase();
-      notePreview.addNotes(currentSongChartNoteData, Std.int(songLengthInMs));
-      notePreview.addEvents(currentSongChartEventData, Std.int(songLengthInMs));
-    }
-
-    if (notePreviewViewportBoundsDirty)
-    {
-      setNotePreviewViewportBounds(calculateNotePreviewViewportBounds());
-    }
-  }
-
   /**
    * Handle aligning the health icons next to the grid.
    */
@@ -3825,12 +3920,24 @@ class ChartEditorState extends HaxeUIState
       this.openBrowseFNFC(true);
     }
 
-    // CTRL + SHIFT + S = Save As
+    if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.S)
+    {
+      if (currentWorkingFilePath == null || FlxG.keys.pressed.SHIFT)
+      {
+        // CTRL + SHIFT + S = Save As
+        ChartEditorImportExportHandler.exportAllSongData(this, false);
+      }
+      else
+      {
+        // CTRL + S = Save Chart
+        ChartEditorImportExportHandler.exportAllSongData(this, true, currentWorkingFilePath);
+      }
+    }
+
     if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.S)
     {
       this.exportAllSongData(false);
     }
-
     // CTRL + Q = Quit to Menu
     if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Q)
     {
@@ -3846,6 +3953,8 @@ class ChartEditorState extends HaxeUIState
     // TODO: PR Flixel to make onComplete nullable.
     if (audioInstTrack != null) audioInstTrack.onComplete = null;
     FlxG.switchState(new MainMenuState());
+
+    resetWindowTitle();
   }
 
   /**
@@ -4119,48 +4228,6 @@ class ChartEditorState extends HaxeUIState
     performCommand(command, false);
   }
 
-  /**
-   * SAVE, AUTOSAVE, QUIT FUNCTIONS
-   */
-  // ====================
-
-  /**
-   * Called after 5 minutes without saving.
-   */
-  function autoSave():Void
-  {
-    saveDataDirty = false;
-
-    // Auto-save the chart.
-
-    #if html5
-    // Auto-save to local storage.
-    #else
-    // Auto-save to temp file.
-    this.exportAllSongData(true);
-    #end
-  }
-
-  /**
-   * Called when the window is closed while we are in the chart editor.
-   * @param exitCode The exit code of the window.
-   */
-  function onWindowClose(exitCode:Int):Void
-  {
-    trace('Window exited with exit code: $exitCode');
-    trace('Should save chart? $saveDataDirty');
-
-    if (saveDataDirty)
-    {
-      this.exportAllSongData(true);
-    }
-  }
-
-  function cleanupAutoSave():Void
-  {
-    WindowUtil.windowExit.remove(onWindowClose);
-  }
-
   /**
    * GRAPHICS FUNCTIONS
    */
@@ -4208,28 +4275,6 @@ class ChartEditorState extends HaxeUIState
     setComponentText('playbarPlay', '||');
   }
 
-  function stopAudioPlayback():Void
-  {
-    if (audioInstTrack != null) audioInstTrack.pause();
-    if (audioVocalTrackGroup != null) audioVocalTrackGroup.pause();
-
-    setComponentText('playbarPlay', '>');
-  }
-
-  function toggleAudioPlayback():Void
-  {
-    if (audioInstTrack == null) return;
-
-    if (audioInstTrack.playing)
-    {
-      stopAudioPlayback();
-    }
-    else
-    {
-      startAudioPlayback();
-    }
-  }
-
   /**
    * Play the metronome tick sound.
    * @param high Whether to play the full beat sound rather than the quarter beat sound.
@@ -4245,42 +4290,6 @@ class ChartEditorState extends HaxeUIState
     this.switchToInstrumental(currentInstrumentalId, currentSongMetadata.playData.characters.player, currentSongMetadata.playData.characters.opponent);
   }
 
-  function postLoadInstrumental():Void
-  {
-    if (audioInstTrack != null)
-    {
-      // Prevent the time from skipping back to 0 when the song ends.
-      audioInstTrack.onComplete = function() {
-        if (audioInstTrack != null) audioInstTrack.pause();
-        if (audioVocalTrackGroup != null) audioVocalTrackGroup.pause();
-      };
-
-      songLengthInMs = audioInstTrack.length;
-
-      if (gridTiledSprite != null) gridTiledSprite.height = songLengthInPixels;
-      if (gridPlayheadScrollArea != null)
-      {
-        gridPlayheadScrollArea.setGraphicSize(Std.int(gridPlayheadScrollArea.width), songLengthInPixels);
-        gridPlayheadScrollArea.updateHitbox();
-      }
-
-      buildSpectrogram(audioInstTrack);
-    }
-    else
-    {
-      trace('[WARN] Instrumental track was null!');
-    }
-
-    // Pretty much everything is going to need to be reset.
-    scrollPositionInPixels = 0;
-    playheadPositionInPixels = 0;
-    notePreviewDirty = true;
-    notePreviewViewportBoundsDirty = true;
-    noteDisplayDirty = true;
-    healthIconsDirty = true;
-    moveSongToScrollPosition();
-  }
-
   /**
    * CHART DATA FUNCTIONS
    */
@@ -4299,11 +4308,6 @@ class ChartEditorState extends HaxeUIState
     });
   }
 
-  function isNoteSelected(note:Null<SongNoteData>):Bool
-  {
-    return note != null && currentNoteSelection.indexOf(note) != -1;
-  }
-
   function isEventSelected(event:Null<SongEventData>):Bool
   {
     return event != null && currentEventSelection.indexOf(event) != -1;
@@ -4679,11 +4683,248 @@ class ChartEditorState extends HaxeUIState
   /**
    * Dismiss any existing HaxeUI notifications, if there are any.
    */
-  public static function dismissNotifications():Void
+  function handleNotePreview():Void
+  {
+    if (notePreviewDirty && notePreview != null)
+    {
+      notePreviewDirty = false;
+
+      // TODO: Only update the notes that have changed.
+      notePreview.erase();
+      notePreview.addNotes(currentSongChartNoteData, Std.int(songLengthInMs));
+      notePreview.addEvents(currentSongChartEventData, Std.int(songLengthInMs));
+    }
+
+    if (notePreviewViewportBoundsDirty)
+    {
+      setNotePreviewViewportBounds(calculateNotePreviewViewportBounds());
+    }
+  }
+
+  /**
+   * Handles passive behavior of the menu bar, such as updating labels or enabled/disabled status.
+   * Does not handle onClick ACTIONS of the menubar.
+   */
+  function handleMenubar():Void
+  {
+    if (commandHistoryDirty)
+    {
+      commandHistoryDirty = false;
+
+      // Update the Undo and Redo buttons.
+      var undoButton:Null<MenuItem> = findComponent('menubarItemUndo', MenuItem);
+
+      if (undoButton != null)
+      {
+        if (undoHistory.length == 0)
+        {
+          // Disable the Undo button.
+          undoButton.disabled = true;
+          undoButton.text = 'Undo';
+        }
+        else
+        {
+          // Change the label to the last command.
+          undoButton.disabled = false;
+          undoButton.text = 'Undo ${undoHistory[undoHistory.length - 1].toString()}';
+        }
+      }
+      else
+      {
+        trace('undoButton is null');
+      }
+
+      var redoButton:Null<MenuItem> = findComponent('menubarItemRedo', MenuItem);
+
+      if (redoButton != null)
+      {
+        if (redoHistory.length == 0)
+        {
+          // Disable the Redo button.
+          redoButton.disabled = true;
+          redoButton.text = 'Redo';
+        }
+        else
+        {
+          // Change the label to the last command.
+          redoButton.disabled = false;
+          redoButton.text = 'Redo ${redoHistory[redoHistory.length - 1].toString()}';
+        }
+      }
+      else
+      {
+        trace('redoButton is null');
+      }
+    }
+  }
+
+  /**
+   * Handle the playback of hitsounds.
+   */
+  function handleHitsounds(oldSongPosition:Float, newSongPosition:Float):Void
+  {
+    if (!hitsoundsEnabled) return;
+
+    // Assume notes are sorted by time.
+    for (noteData in currentSongChartNoteData)
+    {
+      // Check for notes between the old and new song positions.
+
+      if (noteData.time < oldSongPosition) // Note is in the past.
+        continue;
+
+      if (noteData.time > newSongPosition) // Note is in the future.
+        return; // Assume all notes are also in the future.
+
+      // Note was just hit.
+
+      // Character preview.
+
+      // NoteScriptEvent takes a sprite, ehe. Need to rework that.
+      var tempNote:NoteSprite = new NoteSprite(NoteStyleRegistry.instance.fetchDefault());
+      tempNote.noteData = noteData;
+      tempNote.scrollFactor.set(0, 0);
+      var event:NoteScriptEvent = new NoteScriptEvent(NOTE_HIT, tempNote, 1, true);
+      dispatchEvent(event);
+
+      // Calling event.cancelEvent() skips all the other logic! Neat!
+      if (event.eventCanceled) continue;
+
+      // Hitsounds.
+      switch (noteData.getStrumlineIndex())
+      {
+        case 0: // Player
+          if (hitsoundsEnabledPlayer) ChartEditorAudioHandler.playSound(this, Paths.sound('chartingSounds/hitNotePlayer'));
+        case 1: // Opponent
+          if (hitsoundsEnabledOpponent) ChartEditorAudioHandler.playSound(this, Paths.sound('chartingSounds/hitNoteOpponent'));
+      }
+    }
+  }
+
+  function stopAudioPlayback():Void
+  {
+    if (audioInstTrack != null) audioInstTrack.pause();
+    if (audioVocalTrackGroup != null) audioVocalTrackGroup.pause();
+
+    setComponentText('playbarPlay', '>');
+  }
+
+  function toggleAudioPlayback():Void
+  {
+    if (audioInstTrack == null) return;
+
+    if (audioInstTrack.playing)
+    {
+      stopAudioPlayback();
+    }
+    else
+    {
+      startAudioPlayback();
+    }
+  }
+
+  public function postLoadInstrumental():Void
+  {
+    if (audioInstTrack != null)
+    {
+      // Prevent the time from skipping back to 0 when the song ends.
+      audioInstTrack.onComplete = function() {
+        if (audioInstTrack != null) audioInstTrack.pause();
+        if (audioVocalTrackGroup != null) audioVocalTrackGroup.pause();
+      };
+
+      songLengthInMs = audioInstTrack.length;
+
+      if (gridTiledSprite != null) gridTiledSprite.height = songLengthInPixels;
+      if (gridPlayheadScrollArea != null)
+      {
+        gridPlayheadScrollArea.setGraphicSize(Std.int(gridPlayheadScrollArea.width), songLengthInPixels);
+        gridPlayheadScrollArea.updateHitbox();
+      }
+    }
+    else
+    {
+      trace('[WARN] Instrumental track was null!');
+    }
+
+    // Pretty much everything is going to need to be reset.
+    scrollPositionInPixels = 0;
+    playheadPositionInPixels = 0;
+    notePreviewDirty = true;
+    notePreviewViewportBoundsDirty = true;
+    noteDisplayDirty = true;
+    healthIconsDirty = true;
+    moveSongToScrollPosition();
+  }
+
+  /**
+   * Clear the voices group.
+   */
+  public function clearVocals():Void
+  {
+    if (audioVocalTrackGroup != null) audioVocalTrackGroup.clear();
+  }
+
+  function isNoteSelected(note:Null<SongNoteData>):Bool
+  {
+    return note != null && currentNoteSelection.indexOf(note) != -1;
+  }
+
+  override function destroy():Void
+  {
+    super.destroy();
+
+    cleanupAutoSave();
+
+    // Hide the mouse cursor on other states.
+    Cursor.hide();
+
+    @:privateAccess
+    ChartEditorNoteSprite.noteFrameCollection = null;
+  }
+
+  /**
+   * Dismiss any existing notifications, if there are any.
+   */
+  function dismissNotifications():Void
   {
     NotificationManager.instance.clearNotifications();
   }
 
+  function applyCanQuickSave():Void
+  {
+    if (menubarItemSaveChart == null) return;
+
+    if (currentWorkingFilePath == null)
+    {
+      menubarItemSaveChart.disabled = true;
+    }
+    else
+    {
+      menubarItemSaveChart.disabled = false;
+    }
+  }
+
+  function applyWindowTitle():Void
+  {
+    var inner:String = 'New Chart';
+    var cwfp:Null<String> = currentWorkingFilePath;
+    if (cwfp != null)
+    {
+      inner = cwfp;
+    }
+    if (currentWorkingFilePath == null || saveDataDirty)
+    {
+      inner += '*';
+    }
+    WindowUtil.setWindowTitle('Friday Night Funkin\' Chart Editor - ${inner}');
+  }
+
+  function resetWindowTitle():Void
+  {
+    WindowUtil.setWindowTitle('Friday Night Funkin\'');
+  }
+
   /**
    * Convert a note data value into a chart editor grid column number.
    */
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
index 5ea125eb4..f82a123a4 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
@@ -217,6 +217,18 @@ class ChartEditorAudioHandler
     snd.play();
   }
 
+  public static function wipeInstrumentalData(state:ChartEditorState):Void
+  {
+    state.audioInstTrackData.clear();
+    stopExistingInstrumental(state);
+  }
+
+  public static function wipeVocalData(state:ChartEditorState):Void
+  {
+    state.audioVocalTrackData.clear();
+    stopExistingVocals(state);
+  }
+
   /**
    * Create a list of ZIP file entries from the current loaded instrumental tracks in the chart eidtor.
    * @param state The chart editor state.
@@ -226,18 +238,27 @@ class ChartEditorAudioHandler
   {
     var zipEntries = [];
 
-    for (key in state.audioInstTrackData.keys())
+    var instTrackIds = state.audioInstTrackData.keys().array();
+    for (key in instTrackIds)
     {
       if (key == 'default')
       {
         var data:Null<Bytes> = state.audioInstTrackData.get('default');
-        if (data == null) continue;
+        if (data == null)
+        {
+          trace('[WARN] Failed to access inst track ($key)');
+          continue;
+        }
         zipEntries.push(FileUtil.makeZIPEntryFromBytes('Inst.ogg', data));
       }
       else
       {
         var data:Null<Bytes> = state.audioInstTrackData.get(key);
-        if (data == null) continue;
+        if (data == null)
+        {
+          trace('[WARN] Failed to access inst track ($key)');
+          continue;
+        }
         zipEntries.push(FileUtil.makeZIPEntryFromBytes('Inst-${key}.ogg', data));
       }
     }
@@ -254,10 +275,15 @@ class ChartEditorAudioHandler
   {
     var zipEntries = [];
 
+    var vocalTrackIds = state.audioVocalTrackData.keys().array();
     for (key in state.audioVocalTrackData.keys())
     {
       var data:Null<Bytes> = state.audioVocalTrackData.get(key);
-      if (data == null) continue;
+      if (data == null)
+      {
+        trace('[WARN] Failed to access vocal track ($key)');
+        continue;
+      }
       zipEntries.push(FileUtil.makeZIPEntryFromBytes('Voices-${key}.ogg', data));
     }
 
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
index f5cbccff6..a048bdbbe 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
@@ -85,11 +85,73 @@ class ChartEditorDialogHandler
     var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_WELCOME_LAYOUT, true, closable);
     if (dialog == null) throw 'Could not locate Welcome dialog';
 
+    state.isHaxeUIDialogOpen = true;
     dialog.onDialogClosed = function(_event) {
+      state.isHaxeUIDialogOpen = false;
       // Called when the Welcome dialog is closed while it is closable.
       state.stopWelcomeMusic();
     }
 
+    #if sys
+    var splashRecentContainer:Null<VBox> = dialog.findComponent('splashRecentContainer', VBox);
+    if (splashRecentContainer == null) throw 'Could not locate splashRecentContainer in Welcome dialog';
+
+    for (chartPath in state.previousWorkingFilePaths)
+    {
+      if (chartPath == null) continue;
+
+      var linkRecentChart:Link = new Link();
+      linkRecentChart.text = chartPath;
+      linkRecentChart.onClick = function(_event) {
+        dialog.hideDialog(DialogButton.CANCEL);
+        state.stopWelcomeMusic();
+
+        // Load chart from file
+        var result:Null<Array<String>> = ChartEditorImportExportHandler.loadFromFNFCPath(state, chartPath);
+        if (result != null)
+        {
+          #if !mac
+          NotificationManager.instance.addNotification(
+            {
+              title: 'Success',
+              body: result.length == 0 ? 'Loaded chart (${chartPath.toString()})' : 'Loaded chart (${chartPath.toString()})\n${result.join("\n")}',
+              type: result.length == 0 ? NotificationType.Success : NotificationType.Warning,
+              expiryMs: Constants.NOTIFICATION_DISMISS_TIME
+            });
+          #end
+        }
+        else
+        {
+          #if !mac
+          NotificationManager.instance.addNotification(
+            {
+              title: 'Failure',
+              body: 'Failed to load chart (${chartPath.toString()})',
+              type: NotificationType.Error,
+              expiryMs: Constants.NOTIFICATION_DISMISS_TIME
+            });
+          #end
+        }
+      }
+
+      if (!FileUtil.doesFileExist(chartPath))
+      {
+        trace('Previously loaded chart file (${chartPath}) does not exist, disabling link...');
+        linkRecentChart.disabled = true;
+      }
+
+      splashRecentContainer.addComponent(linkRecentChart);
+    }
+    #else
+    var splashRecentContainer:Null<VBox> = dialog.findComponent('splashRecentContainer', VBox);
+    if (splashRecentContainer == null) throw 'Could not locate splashRecentContainer in Welcome dialog';
+
+    var webLoadLabel:Label = new Label();
+    webLoadLabel.text = 'Click the button below to load a chart file (.fnfc) from your computer.';
+
+    splashRecentContainer.add(webLoadLabel);
+    #end
+
     // Create New Song "Easy/Normal/Hard"
     var linkCreateBasic:Null<Link> = dialog.findComponent('splashCreateFromSongBasic', Link);
     if (linkCreateBasic == null) throw 'Could not locate splashCreateFromSongBasic link in Welcome dialog';
@@ -181,6 +243,7 @@ class ChartEditorDialogHandler
     if (dialog == null) throw 'Could not locate Upload Chart dialog';
 
     dialog.onDialogClosed = function(_event) {
+      state.isHaxeUIDialogOpen = false;
       if (_event.button == DialogButton.APPLY)
       {
         // Simply let the dialog close.
@@ -195,6 +258,7 @@ class ChartEditorDialogHandler
     var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
     if (buttonCancel == null) throw 'Could not locate dialogCancel button in Upload Chart dialog';
 
+    state.isHaxeUIDialogOpen = true;
     buttonCancel.onClick = function(_event) {
       dialog.hideDialog(DialogButton.CANCEL);
     }
@@ -221,7 +285,8 @@ class ChartEditorDialogHandler
           {
             try
             {
-              if (ChartEditorImportExportHandler.loadFromFNFC(state, selectedFile.bytes))
+              var result:Null<Array<String>> = ChartEditorImportExportHandler.loadFromFNFC(state, selectedFile.bytes);
+              if (result != null)
               {
                 #if !mac
                 NotificationManager.instance.addNotification(
@@ -260,21 +325,33 @@ class ChartEditorDialogHandler
 
       try
       {
-        if (ChartEditorImportExportHandler.loadFromFNFCPath(state, path.toString()))
+        var result:Null<Array<String>> = ChartEditorImportExportHandler.loadFromFNFCPath(state, path.toString());
+        if (result != null)
         {
           #if !mac
           NotificationManager.instance.addNotification(
             {
               title: 'Success',
-              body: 'Loaded chart (${path.toString()})',
-              type: NotificationType.Success,
+              body: result.length == 0 ? 'Loaded chart (${path.toString()})' : 'Loaded chart (${path.toString()})\n${result.join("\n")}',
+              type: result.length == 0 ? NotificationType.Success : NotificationType.Warning,
               expiryMs: Constants.NOTIFICATION_DISMISS_TIME
             });
           #end
-
           dialog.hideDialog(DialogButton.APPLY);
           removeDropHandler(onDropFile);
         }
+        else
+        {
+          #if !mac
+          NotificationManager.instance.addNotification(
+            {
+              title: 'Failure',
+              body: 'Failed to load chart (${path.toString()})',
+              type: NotificationType.Error,
+              expiryMs: Constants.NOTIFICATION_DISMISS_TIME
+            });
+          #end
+        }
       }
       catch (err)
       {
@@ -320,6 +397,8 @@ class ChartEditorDialogHandler
             var uploadVocalsDialog:Dialog = openUploadVocalsDialog(state, closable); // var uploadVocalsDialog:Dialog
             uploadVocalsDialog.onDialogClosed = function(_event) {
               state.isHaxeUIDialogOpen = false;
+              state.currentWorkingFilePath = null; // Built from parts, so no .fnfc to save to.
+              state.switchToCurrentInstrumental();
               state.postLoadInstrumental();
             }
           }
@@ -359,6 +438,8 @@ class ChartEditorDialogHandler
             var uploadVocalsDialog:Dialog = openUploadVocalsDialog(state, closable); // var uploadVocalsDialog:Dialog
             uploadVocalsDialog.onDialogClosed = function(_event) {
               state.isHaxeUIDialogOpen = false;
+              state.currentWorkingFilePath = null; // New file, so no path.
+              state.switchToCurrentInstrumental();
               state.postLoadInstrumental();
             }
           }
@@ -396,6 +477,7 @@ class ChartEditorDialogHandler
             var uploadVocalsDialog:Dialog = openUploadVocalsDialog(state, closable); // var uploadVocalsDialog:Dialog
             uploadVocalsDialog.onDialogClosed = function(_event) {
               state.isHaxeUIDialogOpen = false;
+              state.currentWorkingFilePath = null; // New file, so no path.
               state.switchToCurrentInstrumental();
               state.postLoadInstrumental();
             }
@@ -454,6 +536,7 @@ class ChartEditorDialogHandler
                       var uploadVocalsDialogErect:Dialog = openUploadVocalsDialog(state, closable); // var uploadVocalsDialog:Dialog
                       uploadVocalsDialogErect.onDialogClosed = function(_event) {
                         state.isHaxeUIDialogOpen = false;
+                        state.currentWorkingFilePath = null; // New file, so no path.
                         state.switchToCurrentInstrumental();
                         state.postLoadInstrumental();
                       }
@@ -630,7 +713,9 @@ class ChartEditorDialogHandler
 
     var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
     if (buttonCancel == null) throw 'Could not locate dialogCancel button in Song Metadata dialog';
+    state.isHaxeUIDialogOpen = true;
     buttonCancel.onClick = function(_event) {
+      state.isHaxeUIDialogOpen = false;
       dialog.hideDialog(DialogButton.CANCEL);
     }
 
@@ -745,6 +830,7 @@ class ChartEditorDialogHandler
     var dialogContinue:Null<Button> = dialog.findComponent('dialogContinue', Button);
     if (dialogContinue == null) throw 'Could not locate dialogContinue button in Song Metadata dialog';
     dialogContinue.onClick = (_event) -> {
+      state.songMetadata.clear();
       state.songMetadata.set(targetVariation, newSongMetadata);
 
       Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges);
@@ -1352,7 +1438,9 @@ class ChartEditorDialogHandler
     var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
     if (buttonCancel == null) throw 'Could not locate dialogCancel button in Import Chart dialog';
 
+    state.isHaxeUIDialogOpen = true;
     buttonCancel.onClick = function(_event) {
+      state.isHaxeUIDialogOpen = false;
       dialog.hideDialog(DialogButton.CANCEL);
     }
 
@@ -1513,7 +1601,9 @@ class ChartEditorDialogHandler
 
     // If all validators succeeded, this callback is called.
 
+    state.isHaxeUIDialogOpen = true;
     variationForm.onSubmit = function(_event) {
+      state.isHaxeUIDialogOpen = false;
       trace('Add Variation dialog submitted, validation succeeded!');
 
       var dialogVariationName:Null<TextField> = dialog.findComponent('dialogVariationName', TextField);
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
index be850e93e..ce04084c6 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
@@ -7,7 +7,7 @@ import haxe.io.Path;
 import funkin.util.SerializerUtil;
 import haxe.ui.notifications.NotificationManager;
 import funkin.util.FileUtil;
-import funkin.util.FileUtil;
+import funkin.util.FileUtil.FileWriteMode;
 import haxe.io.Bytes;
 import funkin.play.song.Song;
 import funkin.data.song.SongData.SongChartData;
@@ -53,6 +53,8 @@ class ChartEditorImportExportHandler
 
     state.sortChartData();
 
+    ChartEditorAudioHandler.wipeInstrumentalData(state);
+    ChartEditorAudioHandler.wipeVocalData(state);
     state.stopExistingVocals();
 
     var variations:Array<String> = state.availableVariations;
@@ -91,7 +93,10 @@ class ChartEditorImportExportHandler
       }
     }
 
+    state.isHaxeUIDialogOpen = false;
+    state.currentWorkingFilePath = null; // New file, so no path.
     state.switchToCurrentInstrumental();
+    state.postLoadInstrumental();
 
     state.refreshMetadataToolbox();
 
@@ -138,31 +143,40 @@ class ChartEditorImportExportHandler
     }
   }
 
-  public static function loadFromFNFCPath(state:ChartEditorState, path:String):Bool
+  /**
+   * Load a chart's metadata, chart data, and audio from an FNFC file path.
+   * @param state
+   * @param path
+   * @return `null` on failure, `[]` on success, `[warnings]` on success with warnings.
+   */
+  public static function loadFromFNFCPath(state:ChartEditorState, path:String):Null<Array<String>>
   {
     var bytes:Null<Bytes> = FileUtil.readBytesFromPath(path);
-    if (bytes == null) return false;
+    if (bytes == null) return null;
 
     trace('Loaded ${bytes.length} bytes from $path');
 
-    var result:Bool = loadFromFNFC(state, bytes);
-    if (result)
+    var result:Null<Array<String>> = loadFromFNFC(state, bytes);
+    if (result != null)
     {
       state.currentWorkingFilePath = path;
+      state.saveDataDirty = false; // Just loaded file!
     }
 
     return result;
   }
 
   /**
-   * Load a chart's metadata, chart data, and audio from an FNFC archive..
+   * Load a chart's metadata, chart data, and audio from an FNFC archive.
    * @param state
    * @param bytes
    * @param instId
-   * @return Bool
+   * @return `null` on failure, `[]` on success, `[warnings]` on success with warnings.
    */
-  public static function loadFromFNFC(state:ChartEditorState, bytes:Bytes):Bool
+  public static function loadFromFNFC(state:ChartEditorState, bytes:Bytes):Null<Array<String>>
   {
+    var warnings:Array<String> = [];
+
     var songMetadatas:Map<String, SongMetadata> = [];
     var songChartDatas:Map<String, SongChartData> = [];
 
@@ -231,8 +245,8 @@ class ChartEditorImportExportHandler
       songChartDatas.set(variation, variChartData);
     }
 
-    ChartEditorAudioHandler.stopExistingInstrumental(state);
-    ChartEditorAudioHandler.stopExistingVocals(state);
+    ChartEditorAudioHandler.wipeInstrumentalData(state);
+    ChartEditorAudioHandler.wipeVocalData(state);
 
     // Load instrumentals
     for (variation in [Constants.DEFAULT_VARIATION].concat(variationList))
@@ -264,12 +278,14 @@ class ChartEditorImportExportHandler
       {
         if (!ChartEditorAudioHandler.loadVocalsFromBytes(state, playerVocalsFileBytes, playerCharId, instId))
         {
-          throw 'Could not load vocals ($playerCharId).';
+          warnings.push('Could not parse vocals ($playerCharId).');
+          // throw 'Could not parse vocals ($playerCharId).';
         }
       }
       else
       {
-        throw 'Could not find vocals ($playerVocalsFileName).';
+        warnings.push('Could not find vocals ($playerVocalsFileName).');
+        // throw 'Could not find vocals ($playerVocalsFileName).';
       }
 
       if (opponentCharId != null)
@@ -280,12 +296,14 @@ class ChartEditorImportExportHandler
         {
           if (!ChartEditorAudioHandler.loadVocalsFromBytes(state, opponentVocalsFileBytes, opponentCharId, instId))
           {
-            throw 'Could not load vocals ($opponentCharId).';
+            warnings.push('Could not parse vocals ($opponentCharId).');
+            // throw 'Could not parse vocals ($opponentCharId).';
           }
         }
         else
         {
-          throw 'Could not load vocals ($opponentCharId).';
+          warnings.push('Could not find vocals ($opponentVocalsFileName).');
+          // throw 'Could not find vocals ($opponentVocalsFileName).';
         }
       }
     }
@@ -297,7 +315,7 @@ class ChartEditorImportExportHandler
 
     state.switchToCurrentInstrumental();
 
-    return true;
+    return warnings;
   }
 
   /**
@@ -345,8 +363,10 @@ class ChartEditorImportExportHandler
 
     if (force)
     {
+      var targetMode:FileWriteMode = Force;
       if (targetPath == null)
       {
+        targetMode = Skip;
         targetPath = Path.join([
           './backups/',
           'chart-editor-${DateUtil.generateTimestamp()}.${Constants.EXT_CHART}'
@@ -355,13 +375,24 @@ class ChartEditorImportExportHandler
 
       // We have to force write because the program will die before the save dialog is closed.
       trace('Force exporting to $targetPath...');
-      FileUtil.saveFilesAsZIPToPath(zipEntries, targetPath);
+      FileUtil.saveFilesAsZIPToPath(zipEntries, targetPath, targetMode);
+      state.saveDataDirty = false;
     }
     else
     {
       // Prompt and save.
       var onSave:Array<String>->Void = function(paths:Array<String>) {
-        trace('Successfully exported files.');
+        if (paths.length != 1)
+        {
+          trace('[WARN] Could not get save path.');
+          state.applyWindowTitle();
+        }
+        else
+        {
+          trace('Saved to "${paths[0]}"');
+          state.currentWorkingFilePath = paths[0];
+          state.applyWindowTitle();
+        }
       };
 
       var onCancel:Void->Void = function() {
@@ -372,6 +403,7 @@ class ChartEditorImportExportHandler
       try
       {
         FileUtil.saveChartAsFNFC(zipEntries, onSave, onCancel, '${state.currentSongId}.${Constants.EXT_CHART}');
+        state.saveDataDirty = false;
       }
       catch (e) {}
     }
diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index ad3b59f6f..f8749567b 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -402,8 +402,12 @@ class Constants
   public static final GHOST_TAPPING:Bool = false;
 
   /**
-    * The separator between an asset library and the asset path.
+   * The maximum number of previous file paths for the Chart Editor to remember.
+   */
+  public static final MAX_PREVIOUS_WORKING_FILES:Int = 10;
 
+  /**
+   * The separator between an asset library and the asset path.
    */
   public static final LIBRARY_SEPARATOR:String = ':';
 
diff --git a/source/funkin/util/FileUtil.hx b/source/funkin/util/FileUtil.hx
index 72c9c43f1..8c41cc363 100644
--- a/source/funkin/util/FileUtil.hx
+++ b/source/funkin/util/FileUtil.hx
@@ -101,7 +101,7 @@ class FileUtil
   }
 
   /**
-   * Browses for a file location to save to, then calls `onSelect(path)` when a path chosen.
+   * Browses for a file location to save to, then calls `onSave(path)` when a path chosen.
    * Note that on HTML5 you can't do much with this, you should call `saveFile(resource:haxe.io.Bytes)` instead.
    *
    * @param typeFilter TODO What does this do?
@@ -183,7 +183,7 @@ class FileUtil
     var filter:String = convertTypeFilter(typeFilter);
 
     var fileDialog:FileDialog = new FileDialog();
-    if (onSave != null) fileDialog.onSelect.add(onSave);
+    if (onSave != null) fileDialog.onSave.add(onSave);
     if (onCancel != null) fileDialog.onCancel.add(onCancel);
 
     fileDialog.save(data, filter, defaultFileName, dialogTitle);
@@ -268,7 +268,8 @@ class FileUtil
     var zipBytes:Bytes = createZIPFromEntries(resources);
 
     var onSave:String->Void = function(path:String) {
-      onSave([path]);
+      trace('Saved ${resources.length} files to ZIP at "$path".');
+      if (onSave != null) onSave([path]);
     };
 
     // Prompt the user to save the ZIP file.
@@ -287,7 +288,8 @@ class FileUtil
     var zipBytes:Bytes = createZIPFromEntries(resources);
 
     var onSave:String->Void = function(path:String) {
-      onSave([path]);
+      trace('Saved FNF file to "$path"');
+      if (onSave != null) onSave([path]);
     };
 
     // Prompt the user to save the ZIP file.
@@ -302,14 +304,14 @@ class FileUtil
    * Use `saveFilesAsZIP` instead.
    * @param force Whether to force overwrite an existing file.
    */
-  public static function saveFilesAsZIPToPath(resources:Array<Entry>, path:String, force:Bool = false):Bool
+  public static function saveFilesAsZIPToPath(resources:Array<Entry>, path:String, mode:FileWriteMode = Skip):Bool
   {
     #if desktop
     // Create a ZIP file.
     var zipBytes:Bytes = createZIPFromEntries(resources);
 
     // Write the ZIP.
-    writeBytesToPath(path, zipBytes, force ? Force : Skip);
+    writeBytesToPath(path, zipBytes, mode);
 
     return true;
     #else
@@ -344,13 +346,22 @@ class FileUtil
   public static function readBytesFromPath(path:String):Bytes
   {
     #if sys
-    if (!sys.FileSystem.exists(path)) return null;
+    if (!doesFileExist(path)) return null;
     return sys.io.File.getBytes(path);
     #else
     return null;
     #end
   }
 
+  public static function doesFileExist(path:String):Bool
+  {
+    #if sys
+    return sys.FileSystem.exists(path);
+    #else
+    return false;
+    #end
+  }
+
   /**
    * Browse for a file to read and execute a callback once we have a file reference.
    * Works great on HTML5 or desktop.
@@ -434,18 +445,20 @@ class FileUtil
       case Force:
         sys.io.File.saveContent(path, data);
       case Skip:
-        if (!sys.FileSystem.exists(path))
+        if (!doesFileExist(path))
         {
           sys.io.File.saveContent(path, data);
         }
         else
         {
-          throw 'File already exists: $path';
+          // Do nothing.
+          // throw 'File already exists: $path';
         }
       case Ask:
-        if (sys.FileSystem.exists(path))
+        if (doesFileExist(path))
         {
           // TODO: We don't have the technology to use native popups yet.
+          throw 'File already exists: $path';
         }
         else
         {
@@ -475,18 +488,20 @@ class FileUtil
       case Force:
         sys.io.File.saveBytes(path, data);
       case Skip:
-        if (!sys.FileSystem.exists(path))
+        if (!doesFileExist(path))
         {
           sys.io.File.saveBytes(path, data);
         }
         else
         {
-          throw 'File already exists: $path';
+          // Do nothing.
+          // throw 'File already exists: $path';
         }
       case Ask:
-        if (sys.FileSystem.exists(path))
+        if (doesFileExist(path))
         {
           // TODO: We don't have the technology to use native popups yet.
+          throw 'File already exists: $path';
         }
         else
         {
@@ -523,7 +538,7 @@ class FileUtil
   public static function createDirIfNotExists(dir:String):Void
   {
     #if sys
-    if (!sys.FileSystem.exists(dir))
+    if (!doesFileExist(dir))
     {
       sys.FileSystem.createDirectory(dir);
     }