From 8664aed4cc11b2059521039151b96fdc646dbc3e Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sun, 22 Oct 2023 15:43:39 -0400
Subject: [PATCH 1/8] Implement "Open Recent" menu

---
 assets                                        |   2 +-
 source/funkin/save/Save.hx                    | 308 +++++++++++++++++-
 .../charting/ChartEditorDialogHandler.hx      |  48 +++
 .../ui/debug/charting/ChartEditorState.hx     | 205 +++++++++---
 source/funkin/util/Constants.hx               |   5 +
 source/funkin/util/FileUtil.hx                |  21 +-
 6 files changed, 527 insertions(+), 62 deletions(-)

diff --git a/assets b/assets
index 118b62295..c1cea2051 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 118b622953171aaf127cb160538e21bc468620e2
+Subproject commit c1cea20513dfa93e3e74a0db98498b2fd8da50fc
diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx
index 54b66605c..dcf7f9f0d 100644
--- a/source/funkin/save/Save.hx
+++ b/source/funkin/save/Save.hx
@@ -1,16 +1,19 @@
 package funkin.save;
 
 import flixel.util.FlxSave;
-import funkin.save.migrator.SaveDataMigrator;
-import thx.semver.Version;
 import funkin.Controls.Device;
 import funkin.save.migrator.RawSaveData_v1_0_0;
+import funkin.save.migrator.SaveDataMigrator;
+import funkin.ui.debug.charting.ChartEditorState.LiveInputStyle;
+import funkin.ui.debug.charting.ChartEditorThemeHandler.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 +97,18 @@ abstract Save(RawSaveData)
         optionsChartEditor:
           {
             // Reasonable defaults.
+            previousFiles: [],
+            noteQuant: 3,
+            liveInputStyle: LiveInputStyle.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 +139,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 +153,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):LiveInputStyle;
+
+  function get_chartEditorLiveInputStyle():LiveInputStyle
+  {
+    if (this.optionsChartEditor.liveInputStyle == null) this.optionsChartEditor.liveInputStyle = LiveInputStyle.None;
+
+    return this.optionsChartEditor.liveInputStyle;
+  }
+
+  function set_chartEditorLiveInputStyle(value:LiveInputStyle):LiveInputStyle
+  {
+    // Set and apply.
+    this.optionsChartEditor.liveInputStyle = value;
+    flush();
+    return this.optionsChartEditor.liveInputStyle;
+  }
+
+  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 +922,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 `LiveInputStyle.None`
+   */
+  var ?liveInputStyle:LiveInputStyle;
+
+  /**
+   * 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/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
index dd5ddb06c..dd874577a 100644
--- a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
@@ -84,11 +84,47 @@ 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)
+    {
+      var linkRecentChart:Link = new FunkinLink();
+      linkRecentChart.text = chartPath;
+      linkRecentChart.onClick = function(_event) {
+        dialog.hideDialog(DialogButton.CANCEL);
+        state.stopWelcomeMusic();
+
+        // Load chart from file
+        ChartEditorImportExportHandler.loadFromFNFCPath(state, chartPath);
+      }
+
+      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';
@@ -180,6 +216,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.
@@ -194,6 +231,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);
     }
@@ -232,6 +270,10 @@ class ChartEditorDialogHandler
                   });
                 #end
 
+                trace(selectedFile.name);
+                trace(selectedFile.text);
+                trace(selectedFile.isBinary);
+                trace(selectedFile.fullPath);
                 if (selectedFile.fullPath != null) state.currentWorkingFilePath = selectedFile.fullPath;
                 dialog.hideDialog(DialogButton.APPLY);
                 removeDropHandler(onDropFile);
@@ -689,7 +731,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);
     }
 
@@ -1411,7 +1455,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);
     }
 
@@ -1596,7 +1642,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/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index d392c2c06..831afe738 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -1,25 +1,18 @@
 package funkin.ui.debug.charting;
 
-import funkin.play.stage.StageData;
-import funkin.play.character.CharacterData.CharacterDataParser;
-import funkin.play.character.CharacterData;
-import flixel.system.FlxAssets.FlxSoundAsset;
-import flixel.math.FlxMath;
-import haxe.ui.components.TextField;
-import haxe.ui.components.DropDown;
-import haxe.ui.components.NumberStepper;
-import haxe.ui.containers.Frame;
 import flixel.addons.display.FlxSliceSprite;
 import flixel.addons.display.FlxTiledSprite;
+import flixel.addons.transition.FlxTransitionableState;
 import flixel.FlxCamera;
 import flixel.FlxSprite;
 import flixel.FlxSubState;
 import flixel.group.FlxSpriteGroup;
-import flixel.addons.transition.FlxTransitionableState;
 import flixel.input.keyboard.FlxKey;
+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;
@@ -29,28 +22,31 @@ import flixel.util.FlxTimer;
 import funkin.audio.visualize.PolygonSpectogram;
 import funkin.audio.VoicesGroup;
 import funkin.data.notestyle.NoteStyleRegistry;
-import funkin.data.notestyle.NoteStyleRegistry;
+import funkin.data.song.SongData.SongCharacterData;
+import funkin.data.song.SongData.SongChartData;
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongData.SongMetadata;
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongDataUtils;
+import funkin.data.song.SongRegistry;
 import funkin.input.Cursor;
 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.HealthIcon;
 import funkin.play.notes.NoteSprite;
 import funkin.play.notes.Strumline;
 import funkin.play.PlayState;
 import funkin.play.song.Song;
-import funkin.data.song.SongData.SongChartData;
-import funkin.data.song.SongRegistry;
-import funkin.data.song.SongData.SongEventData;
-import funkin.data.song.SongData.SongMetadata;
-import funkin.data.song.SongData.SongNoteData;
-import funkin.data.song.SongData.SongCharacterData;
-import funkin.data.song.SongDataUtils;
-import funkin.ui.debug.charting.ChartEditorCommand;
+import funkin.play.stage.StageData;
+import funkin.save.Save;
 import funkin.ui.debug.charting.ChartEditorCommand;
 import funkin.ui.debug.charting.ChartEditorThemeHandler.ChartEditorTheme;
 import funkin.ui.debug.charting.ChartEditorToolboxHandler.ChartEditorToolMode;
 import funkin.ui.haxeui.components.CharacterPlayer;
+import funkin.ui.haxeui.components.FunkinMenuItem;
 import funkin.ui.haxeui.HaxeUIState;
 import funkin.util.Constants;
 import funkin.util.DateUtil;
@@ -61,10 +57,14 @@ 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;
 import haxe.ui.components.Slider;
+import haxe.ui.components.TextField;
 import haxe.ui.containers.dialogs.CollapsibleDialog;
-import haxe.ui.containers.menus.MenuItem;
+import haxe.ui.containers.Frame;
+import haxe.ui.containers.menus.Menu;
 import haxe.ui.containers.TreeView;
 import haxe.ui.containers.TreeViewNode;
 import haxe.ui.core.Component;
@@ -594,27 +594,6 @@ class ChartEditorState extends HaxeUIState
     return selectedCharacter;
   }
 
-  /**
-   * Whether the user is currently in Pattern Mode.
-   * This overrides the chart editor's normal behavior.
-   */
-  var isInPatternMode(default, set):Bool = false;
-
-  function set_isInPatternMode(value:Bool):Bool
-  {
-    isInPatternMode = value;
-
-    // Make sure view is updated when we change modes.
-    noteDisplayDirty = true;
-    notePreviewDirty = true;
-    notePreviewViewportBoundsDirty = true;
-    this.scrollPositionInPixels = 0;
-
-    return isInPatternMode;
-  }
-
-  var currentPattern:String = '';
-
   /**
    * Whether the note display render group has been modified and needs to be updated.
    * This happens when we scroll or add/remove notes, and need to update what notes are displayed and where.
@@ -1183,6 +1162,11 @@ class ChartEditorState extends HaxeUIState
    */
   var playbarHeadLayout:Null<Component> = null;
 
+  /**
+   * The submenu in the menubar containing recently opened files.
+   */
+  var menubarOpenRecent:Null<Menu> = null;
+
   /**
    * The playbar head slider.
    */
@@ -1237,10 +1221,50 @@ class ChartEditorState extends HaxeUIState
    */
   var params:Null<ChartEditorParams>;
 
+  /**
+   * A list of previous working file paths.
+   * Also known as the "recent files" list.
+   */
+  public var previousWorkingFilePaths:Array<String> = [];
+
   /**
    * The current file path which the chart editor is working with.
    */
-  public var currentWorkingFilePath:Null<String>;
+  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 == null) return null;
+
+    if (value == previousWorkingFilePaths[0]) return value;
+
+    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 oldest path.
+      previousWorkingFilePaths.pop();
+    }
+
+    populateOpenRecentMenu();
+
+    return value;
+  }
 
   public function new(?params:ChartEditorParams)
   {
@@ -1260,6 +1284,8 @@ class ChartEditorState extends HaxeUIState
     // Show the mouse cursor.
     Cursor.show();
 
+    loadPreferences();
+
     fixCamera();
 
     // Get rid of any music from the previous state.
@@ -1280,6 +1306,7 @@ class ChartEditorState extends HaxeUIState
     buildSelectionBox();
 
     buildAdditionalUI();
+    populateOpenRecentMenu();
 
     // Setup the onClick listeners for the UI after it's been created.
     setupUIListeners();
@@ -1322,8 +1349,80 @@ class ChartEditorState extends HaxeUIState
   {
     this.welcomeMusic.loadEmbedded(Paths.music('chartEditorLoop/chartEditorLoop'));
     this.welcomeMusic.looped = true;
-    // this.welcomeMusic.play();
-    // fadeInWelcomeMusic();
+  }
+
+  public function loadPreferences():Void
+  {
+    var save:Save = Save.get();
+
+    previousWorkingFilePaths = 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();
+
+    save.chartEditorPreviousFiles = previousWorkingFilePaths;
+    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.clear();
+
+    for (chartPath in previousWorkingFilePaths)
+    {
+      var menuItemRecentChart:FunkinMenuItem = new FunkinMenuItem();
+      menuItemRecentChart.text = chartPath;
+      menuItemRecentChart.onClick = function(_event) {
+        stopWelcomeMusic();
+
+        // Load chart from file
+        ChartEditorImportExportHandler.loadFromFNFCPath(this, chartPath);
+      }
+
+      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
   }
 
   public function fadeInWelcomeMusic():Void
@@ -1540,7 +1639,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)
     {
@@ -1646,9 +1748,11 @@ class ChartEditorState extends HaxeUIState
 
     add(playbarHeadLayout);
 
+    menubarOpenRecent = findComponent('menubarOpenRecent', Menu);
+    if (menubarOpenRecent == null) throw "Could not find menubarOpenRecent!";
+
     // Setup notifications.
     @:privateAccess
-    // NotificationManager.GUTTER_SIZE = 56;
     NotificationManager.GUTTER_SIZE = 20;
   }
 
@@ -1886,10 +1990,13 @@ class ChartEditorState extends HaxeUIState
   {
     saveDataDirty = false;
 
-    // Auto-save the chart.
+    // 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);
@@ -3835,7 +3942,7 @@ class ChartEditorState extends HaxeUIState
       commandHistoryDirty = false;
 
       // Update the Undo and Redo buttons.
-      var undoButton:Null<MenuItem> = findComponent('menubarItemUndo', MenuItem);
+      var undoButton:Null<FunkinMenuItem> = findComponent('menubarItemUndo', FunkinMenuItem);
 
       if (undoButton != null)
       {
@@ -3857,7 +3964,7 @@ class ChartEditorState extends HaxeUIState
         trace('undoButton is null');
       }
 
-      var redoButton:Null<MenuItem> = findComponent('menubarItemRedo', MenuItem);
+      var redoButton:Null<FunkinMenuItem> = findComponent('menubarItemRedo', FunkinMenuItem);
 
       if (redoButton != null)
       {
diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index edd95f946..d37505b4c 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -391,6 +391,11 @@ class Constants
    */
   public static final GHOST_TAPPING:Bool = false;
 
+  /**
+   * 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.
    */
diff --git a/source/funkin/util/FileUtil.hx b/source/funkin/util/FileUtil.hx
index 72c9c43f1..5fae983d4 100644
--- a/source/funkin/util/FileUtil.hx
+++ b/source/funkin/util/FileUtil.hx
@@ -344,13 +344,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,7 +443,7 @@ 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);
         }
@@ -443,7 +452,7 @@ class FileUtil
           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.
         }
@@ -475,7 +484,7 @@ 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);
         }
@@ -484,7 +493,7 @@ class FileUtil
           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.
         }
@@ -523,7 +532,7 @@ class FileUtil
   public static function createDirIfNotExists(dir:String):Void
   {
     #if sys
-    if (!sys.FileSystem.exists(dir))
+    if (!doesFileExist(dir))
     {
       sys.FileSystem.createDirectory(dir);
     }

From a9e253a53078aa9d22dc6dce2f43dc920349770a Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Mon, 23 Oct 2023 12:22:29 -0400
Subject: [PATCH 2/8] Save handling (with window title!)

---
 .../charting/ChartEditorDialogHandler.hx      |  8 +--
 .../ChartEditorImportExportHandler.hx         | 10 ++-
 .../ui/debug/charting/ChartEditorState.hx     | 66 +++++++++++++++++--
 3 files changed, 74 insertions(+), 10 deletions(-)

diff --git a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
index dd874577a..bfb860c92 100644
--- a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
@@ -97,6 +97,8 @@ class ChartEditorDialogHandler
 
     for (chartPath in state.previousWorkingFilePaths)
     {
+      if (chartPath == null) continue;
+
       var linkRecentChart:Link = new FunkinLink();
       linkRecentChart.text = chartPath;
       linkRecentChart.onClick = function(_event) {
@@ -270,10 +272,6 @@ class ChartEditorDialogHandler
                   });
                 #end
 
-                trace(selectedFile.name);
-                trace(selectedFile.text);
-                trace(selectedFile.isBinary);
-                trace(selectedFile.fullPath);
                 if (selectedFile.fullPath != null) state.currentWorkingFilePath = selectedFile.fullPath;
                 dialog.hideDialog(DialogButton.APPLY);
                 removeDropHandler(onDropFile);
@@ -437,6 +435,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();
             }
@@ -495,6 +494,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();
                       }
diff --git a/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx b/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx
index c5cbdd5de..6e9022457 100644
--- a/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx
@@ -361,7 +361,15 @@ class ChartEditorImportExportHandler
     {
       // 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.');
+        }
+        else
+        {
+          state.currentWorkingFilePath = paths[0];
+          state.applyWindowTitle();
+        }
       };
 
       var onCancel:Void->Void = function() {
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 831afe738..fb91e763d 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -1167,6 +1167,11 @@ class ChartEditorState extends HaxeUIState
    */
   var menubarOpenRecent:Null<Menu> = null;
 
+  /**
+   * The item in the menubar to save the currently opened chart.
+   */
+  var menubarItemSave:Null<FunkinMenuItem> = null;
+
   /**
    * The playbar head slider.
    */
@@ -1225,10 +1230,21 @@ class ChartEditorState extends HaxeUIState
    * A list of previous working file paths.
    * Also known as the "recent files" list.
    */
-  public var previousWorkingFilePaths:Array<String> = [];
+  public var previousWorkingFilePaths(default, set):Array<String> = [];
+
+  function set_previousWorkingFilePaths(value:Array<String>):Array<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>;
 
@@ -1239,10 +1255,14 @@ class ChartEditorState extends HaxeUIState
 
   function set_currentWorkingFilePath(value:Null<String>):Null<String>
   {
-    if (value == null) return null;
-
     if (value == previousWorkingFilePaths[0]) return value;
 
+    if (previousWorkingFilePaths.contains(null))
+    {
+      // Filter all instances of `null` from the array.
+      previousWorkingFilePaths = previousWorkingFilePaths.filter((x) -> x != null);
+    }
+
     if (previousWorkingFilePaths.contains(value))
     {
       // Move the path to the front of the list.
@@ -1257,11 +1277,12 @@ class ChartEditorState extends HaxeUIState
 
     while (previousWorkingFilePaths.length > Constants.MAX_PREVIOUS_WORKING_FILES)
     {
-      // Remove the oldest path.
+      // Remove the last path in the list.
       previousWorkingFilePaths.pop();
     }
 
     populateOpenRecentMenu();
+    applyWindowTitle();
 
     return value;
   }
@@ -1395,10 +1416,12 @@ class ChartEditorState extends HaxeUIState
     if (menubarOpenRecent == null) return;
 
     #if sys
-    menubarOpenRecent.clear();
+    menubarOpenRecent.removeAllComponents();
 
     for (chartPath in previousWorkingFilePaths)
     {
+      if (chartPath == null) continue;
+
       var menuItemRecentChart:FunkinMenuItem = new FunkinMenuItem();
       menuItemRecentChart.text = chartPath;
       menuItemRecentChart.onClick = function(_event) {
@@ -1751,6 +1774,9 @@ class ChartEditorState extends HaxeUIState
     menubarOpenRecent = findComponent('menubarOpenRecent', Menu);
     if (menubarOpenRecent == null) throw "Could not find menubarOpenRecent!";
 
+    menubarItemSave = findComponent('menubarItemSave', FunkinMenuItem);
+    if (menubarItemSave == null) throw "Could not find menubarItemSave!";
+
     // Setup notifications.
     @:privateAccess
     NotificationManager.GUTTER_SIZE = 20;
@@ -1783,6 +1809,16 @@ class ChartEditorState extends HaxeUIState
 
     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));
@@ -4495,6 +4531,26 @@ class ChartEditorState extends HaxeUIState
   {
     NotificationManager.instance.clearNotifications();
   }
+
+  function applyCanQuickSave():Void
+  {
+    if (currentWorkingFilePath == null) {}
+    else {}
+  }
+
+  function applyWindowTitle():Void
+  {
+    var inner:String = (currentSongMetadata.songName != null) ? currentSongMetadata.songName : 'Untitled';
+    if (currentWorkingFilePath == null)
+    {
+      inner += '*';
+    }
+    else
+    {
+      inner += ' (${currentWorkingFilePath})';
+    }
+    WindowUtil.setWindowTitle('FNF Chart Editor - ${inner}');
+  }
 }
 
 enum LiveInputStyle

From 818ae6ddd9b6191df17d513c310fcc5fcb33dfcd Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Mon, 23 Oct 2023 12:23:06 -0400
Subject: [PATCH 3/8] Update assets

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index c1cea2051..fd745fcb1 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit c1cea20513dfa93e3e74a0db98498b2fd8da50fc
+Subproject commit fd745fcb16c6a0de73449ae833ce1d92f022d9d6

From 33a1b81737cf55aee328b5839eab99ac186182b3 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 24 Oct 2023 15:50:02 -0400
Subject: [PATCH 4/8] Fixes for quicksave

---
 assets                                        |   2 +-
 .../debug/charting/ChartEditorAudioHandler.hx |  34 ++++-
 .../charting/ChartEditorDialogHandler.hx      |  54 ++++++-
 .../ChartEditorImportExportHandler.hx         |  57 +++++---
 .../ui/debug/charting/ChartEditorState.hx     | 132 ++++++++++++++----
 source/funkin/util/FileUtil.hx                |  22 +--
 6 files changed, 238 insertions(+), 63 deletions(-)

diff --git a/assets b/assets
index fd745fcb1..8e8aeb064 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit fd745fcb16c6a0de73449ae833ce1d92f022d9d6
+Subproject commit 8e8aeb06472ca294c569818cbefb1bb3dfce7854
diff --git a/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx b/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx
index 6f390e604..1ceeadd5f 100644
--- a/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx
@@ -218,6 +218,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);
+  }
+
   /**
    * Convert byte data into a playable sound.
    *
@@ -238,18 +250,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));
       }
     }
@@ -261,10 +282,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/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
index bfb860c92..0dd916ba1 100644
--- a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
@@ -106,7 +106,31 @@ class ChartEditorDialogHandler
         state.stopWelcomeMusic();
 
         // Load chart from file
-        ChartEditorImportExportHandler.loadFromFNFCPath(state, chartPath);
+        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: ChartEditorState.NOTIFICATION_DISMISS_TIME
+            });
+          #end
+        }
+        else
+        {
+          #if !mac
+          NotificationManager.instance.addNotification(
+            {
+              title: 'Failure',
+              body: 'Failed to load chart (${chartPath.toString()})',
+              type: NotificationType.Error,
+              expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+            });
+          #end
+        }
       }
 
       if (!FileUtil.doesFileExist(chartPath))
@@ -260,7 +284,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(
@@ -299,21 +324,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: ChartEditorState.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: ChartEditorState.NOTIFICATION_DISMISS_TIME
+            });
+          #end
+        }
       }
       catch (err)
       {
@@ -359,6 +396,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();
             }
           }
@@ -398,6 +437,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();
             }
           }
@@ -848,6 +889,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);
diff --git a/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx b/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx
index 6e9022457..26dde114d 100644
--- a/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx
+++ b/source/funkin/ui/debug/charting/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,7 +53,8 @@ class ChartEditorImportExportHandler
 
     state.sortChartData();
 
-    state.clearVocals();
+    ChartEditorAudioHandler.wipeInstrumentalData(state);
+    ChartEditorAudioHandler.wipeVocalData(state);
 
     var variations:Array<String> = state.availableVariations;
     for (variation in variations)
@@ -91,7 +92,10 @@ class ChartEditorImportExportHandler
       }
     }
 
+    state.isHaxeUIDialogOpen = false;
+    state.currentWorkingFilePath = null; // New file, so no path.
     state.switchToCurrentInstrumental();
+    state.postLoadInstrumental();
 
     state.refreshMetadataToolbox();
 
@@ -138,31 +142,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 +244,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 +277,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 +295,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 ($playerCharId-$instId).';
+          warnings.push('Could not find vocals ($opponentVocalsFileName).');
+          // throw 'Could not find vocals ($opponentVocalsFileName).';
         }
       }
     }
@@ -297,7 +314,7 @@ class ChartEditorImportExportHandler
 
     state.switchToCurrentInstrumental();
 
-    return true;
+    return warnings;
   }
 
   /**
@@ -345,8 +362,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,7 +374,8 @@ 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
     {
@@ -364,9 +384,11 @@ class ChartEditorImportExportHandler
         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();
         }
@@ -380,6 +402,7 @@ class ChartEditorImportExportHandler
       try
       {
         FileUtil.saveChartAsFNFC(zipEntries, onSave, onCancel, '${state.currentSongId}.${Constants.EXT_CHART}');
+        state.saveDataDirty = false;
       }
       catch (e) {}
     }
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index fb91e763d..7babd6038 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -644,7 +644,9 @@ class ChartEditorState extends HaxeUIState
       }
     }
 
-    return saveDataDirty = value;
+    saveDataDirty = value;
+    applyWindowTitle();
+    return saveDataDirty;
   }
 
   /**
@@ -882,7 +884,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;
@@ -1170,7 +1172,7 @@ class ChartEditorState extends HaxeUIState
   /**
    * The item in the menubar to save the currently opened chart.
    */
-  var menubarItemSave:Null<FunkinMenuItem> = null;
+  var menubarItemSaveChart:Null<FunkinMenuItem> = null;
 
   /**
    * The playbar head slider.
@@ -1229,10 +1231,11 @@ class ChartEditorState extends HaxeUIState
   /**
    * 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 previousWorkingFilePaths(default, set):Array<String> = [];
+  public var previousWorkingFilePaths(default, set):Array<Null<String>> = [null];
 
-  function set_previousWorkingFilePaths(value:Array<String>):Array<String>
+  function set_previousWorkingFilePaths(value:Array<Null<String>>):Array<Null<String>>
   {
     // Called only when the WHOLE LIST is overridden.
     previousWorkingFilePaths = value;
@@ -1260,7 +1263,9 @@ class ChartEditorState extends HaxeUIState
     if (previousWorkingFilePaths.contains(null))
     {
       // Filter all instances of `null` from the array.
-      previousWorkingFilePaths = previousWorkingFilePaths.filter((x) -> x != null);
+      previousWorkingFilePaths = previousWorkingFilePaths.filter(function(x:Null<String>):Bool {
+        return x != null;
+      });
     }
 
     if (previousWorkingFilePaths.contains(value))
@@ -1340,22 +1345,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: ChartEditorState.NOTIFICATION_DISMISS_TIME
           });
         #end
       }
       else
       {
+        #if !mac
+        NotificationManager.instance.addNotification(
+          {
+            title: 'Failure',
+            body: 'Failed to load chart (${params.fnfcTargetPath})',
+            type: NotificationType.Error,
+            expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+          });
+        #end
+
         // Song failed to load, open the Welcome dialog so we aren't in a broken state.
         ChartEditorDialogHandler.openWelcomeDialog(this, false);
       }
@@ -1376,7 +1390,14 @@ class ChartEditorState extends HaxeUIState
   {
     var save:Save = Save.get();
 
-    previousWorkingFilePaths = save.chartEditorPreviousFiles;
+    if (previousWorkingFilePaths[0] == null)
+    {
+      previousWorkingFilePaths = [null].concat(save.chartEditorPreviousFiles);
+    }
+    else
+    {
+      previousWorkingFilePaths = [currentWorkingFilePath].concat(save.chartEditorPreviousFiles);
+    }
     noteSnapQuantIndex = save.chartEditorNoteQuant;
     currentLiveInputStyle = save.chartEditorLiveInputStyle;
     isViewDownscroll = save.chartEditorDownscroll;
@@ -1396,7 +1417,12 @@ class ChartEditorState extends HaxeUIState
   {
     var save:Save = Save.get();
 
-    save.chartEditorPreviousFiles = previousWorkingFilePaths;
+    // 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;
@@ -1428,7 +1454,31 @@ class ChartEditorState extends HaxeUIState
         stopWelcomeMusic();
 
         // Load chart from file
-        ChartEditorImportExportHandler.loadFromFNFCPath(this, chartPath);
+        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: ChartEditorState.NOTIFICATION_DISMISS_TIME
+            });
+          #end
+        }
+        else
+        {
+          #if !mac
+          NotificationManager.instance.addNotification(
+            {
+              title: 'Failure',
+              body: 'Failed to load chart (${chartPath.toString()})',
+              type: NotificationType.Error,
+              expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+            });
+          #end
+        }
       }
 
       if (!FileUtil.doesFileExist(chartPath))
@@ -1774,8 +1824,8 @@ class ChartEditorState extends HaxeUIState
     menubarOpenRecent = findComponent('menubarOpenRecent', Menu);
     if (menubarOpenRecent == null) throw "Could not find menubarOpenRecent!";
 
-    menubarItemSave = findComponent('menubarItemSave', FunkinMenuItem);
-    if (menubarItemSave == null) throw "Could not find menubarItemSave!";
+    menubarItemSaveChart = findComponent('menubarItemSaveChart', FunkinMenuItem);
+    if (menubarItemSaveChart == null) throw "Could not find menubarItemSaveChart!";
 
     // Setup notifications.
     @:privateAccess
@@ -3340,12 +3390,24 @@ class ChartEditorState extends HaxeUIState
       ChartEditorDialogHandler.openBrowseFNFC(this, 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)
     {
       ChartEditorImportExportHandler.exportAllSongData(this, false);
     }
-
     // CTRL + Q = Quit to Menu
     if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Q)
     {
@@ -3361,6 +3423,8 @@ class ChartEditorState extends HaxeUIState
     // TODO: PR Flixel to make onComplete nullable.
     if (audioInstTrack != null) audioInstTrack.onComplete = null;
     FlxG.switchState(new MainMenuState());
+
+    resetWindowTitle();
   }
 
   /**
@@ -4534,22 +4598,36 @@ class ChartEditorState extends HaxeUIState
 
   function applyCanQuickSave():Void
   {
-    if (currentWorkingFilePath == null) {}
-    else {}
+    if (menubarItemSaveChart == null) return;
+
+    if (currentWorkingFilePath == null)
+    {
+      menubarItemSaveChart.disabled = true;
+    }
+    else
+    {
+      menubarItemSaveChart.disabled = false;
+    }
   }
 
   function applyWindowTitle():Void
   {
-    var inner:String = (currentSongMetadata.songName != null) ? currentSongMetadata.songName : 'Untitled';
-    if (currentWorkingFilePath == null)
+    var inner:String = 'New Chart';
+    var cwfp:Null<String> = currentWorkingFilePath;
+    if (cwfp != null)
+    {
+      inner = cwfp;
+    }
+    if (currentWorkingFilePath == null || saveDataDirty)
     {
       inner += '*';
     }
-    else
-    {
-      inner += ' (${currentWorkingFilePath})';
-    }
-    WindowUtil.setWindowTitle('FNF Chart Editor - ${inner}');
+    WindowUtil.setWindowTitle('Friday Night Funkin\' Chart Editor - ${inner}');
+  }
+
+  function resetWindowTitle():Void
+  {
+    WindowUtil.setWindowTitle('Friday Night Funkin\'');
   }
 }
 
diff --git a/source/funkin/util/FileUtil.hx b/source/funkin/util/FileUtil.hx
index 5fae983d4..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
@@ -449,12 +451,14 @@ class FileUtil
         }
         else
         {
-          throw 'File already exists: $path';
+          // Do nothing.
+          // throw 'File already exists: $path';
         }
       case Ask:
         if (doesFileExist(path))
         {
           // TODO: We don't have the technology to use native popups yet.
+          throw 'File already exists: $path';
         }
         else
         {
@@ -490,12 +494,14 @@ class FileUtil
         }
         else
         {
-          throw 'File already exists: $path';
+          // Do nothing.
+          // throw 'File already exists: $path';
         }
       case Ask:
         if (doesFileExist(path))
         {
           // TODO: We don't have the technology to use native popups yet.
+          throw 'File already exists: $path';
         }
         else
         {

From 3e1146aadf90cc84ddd38bb3f02da8133b6f9862 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 7 Nov 2023 18:32:00 -0500
Subject: [PATCH 5/8] Fix bug where getDirection didn't work in scripts.

---
 assets                              |  2 +-
 source/funkin/data/song/SongData.hx | 32 ++++++++++++++---------------
 2 files changed, 17 insertions(+), 17 deletions(-)

diff --git a/assets b/assets
index e634c8f50..5d5a860af 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit e634c8f50c34845097283e0f411e1f89409e1498
+Subproject commit 5d5a860af517ef0cf2aa39c537eb228c7ae803d0
diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
index 783f52a64..29ca28036 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -658,6 +658,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;
 
@@ -714,22 +730,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)

From 2c9b8a115fde80f9ad75494164b791fa5fe55f1e Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 15 Nov 2023 00:49:23 -0500
Subject: [PATCH 6/8] Update assets commit

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index 5d5a860af..fb0a5fb09 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 5d5a860af517ef0cf2aa39c537eb228c7ae803d0
+Subproject commit fb0a5fb09fb966e95433d9cfb45826a89a1c5870

From 398b2e386e840c07c9522eb922b060253953dca3 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 15 Nov 2023 11:30:38 -0500
Subject: [PATCH 7/8] Remove wonky Spectrogram code

---
 .../ui/debug/charting/ChartEditorState.hx      | 18 ------------------
 1 file changed, 18 deletions(-)

diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index aa5372327..67c4e1fc4 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -1237,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.
@@ -1480,7 +1475,6 @@ class ChartEditorState extends HaxeUIState
     this.updateTheme();
 
     buildGrid();
-    // buildSpectrogram(audioInstTrack);
     buildNotePreview();
     buildSelectionBox();
 
@@ -1881,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.
    */
@@ -4857,8 +4841,6 @@ class ChartEditorState extends HaxeUIState
         gridPlayheadScrollArea.setGraphicSize(Std.int(gridPlayheadScrollArea.width), songLengthInPixels);
         gridPlayheadScrollArea.updateHitbox();
       }
-
-      buildSpectrogram(audioInstTrack);
     }
     else
     {

From add4036911a63f14a61494cd292d6c5b47514725 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Wed, 15 Nov 2023 19:51:20 -0500
Subject: [PATCH 8/8] assets update

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index fb0a5fb09..3b05b0fdd 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit fb0a5fb09fb966e95433d9cfb45826a89a1c5870
+Subproject commit 3b05b0fdd8e3b2cd09b9e4e415c186bae8e3b7d3