diff --git a/Project.xml b/Project.xml
index a83db1677..ccf6c83a3 100644
--- a/Project.xml
+++ b/Project.xml
@@ -200,6 +200,12 @@
 	<postbuild haxe="source/Prebuild.hx"/> -->
 	<postbuild haxe="source/Postbuild.hx"/> -->
 
+	<!-- Enable this on platforms which do not support dropping files onto the window. -->
+	<set name="FILE_DROP_UNSUPPORTED" if="mac" />
+	<section unless="FILE_DROP_UNSUPPORTED">
+		<set name="FILE_DROP_SUPPORTED" />
+	</section>
+
 	<!-- Options for Polymod -->
 	<section if="polymod">
 		<!-- Turns on additional debug logging. -->
diff --git a/hmm.json b/hmm.json
index e2670420a..f06b295e4 100644
--- a/hmm.json
+++ b/hmm.json
@@ -49,8 +49,8 @@
       "name": "haxeui-core",
       "type": "git",
       "dir": null,
-      "ref": "f5daafe93bdfa957538f199294a54e0476c805b7",
-      "url": "https://github.com/haxeui/haxeui-core/"
+      "ref": "e92d5cfac847943fac84696b103670d55c2c774f",
+      "url": "https://github.com/haxeui/haxeui-core"
     },
     {
       "name": "haxeui-flixel",
@@ -137,7 +137,7 @@
       "name": "openfl",
       "type": "git",
       "dir": null,
-      "ref": "ef43deb2c68d8a4bcd73abfbd77324fc8220d0c1",
+      "ref": "d33d489a137ff8fdece4994cf1302f0b6334ed08",
       "url": "https://github.com/EliteMasterEric/openfl"
     },
     {
diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx
index 9bd668b69..b0ad6c221 100644
--- a/source/funkin/Conductor.hx
+++ b/source/funkin/Conductor.hx
@@ -47,7 +47,7 @@ class Conductor
   /**
    * Beats per minute of the current song at the current time.
    */
-  public static var bpm(get, null):Float;
+  public static var bpm(get, never):Float;
 
   static function get_bpm():Float
   {
@@ -67,7 +67,7 @@ class Conductor
   /**
    * Duration of a measure in milliseconds. Calculated based on bpm.
    */
-  public static var measureLengthMs(get, null):Float;
+  public static var measureLengthMs(get, never):Float;
 
   static function get_measureLengthMs():Float
   {
@@ -77,7 +77,7 @@ class Conductor
   /**
    * Duration of a beat (quarter note) in milliseconds. Calculated based on bpm.
    */
-  public static var beatLengthMs(get, null):Float;
+  public static var beatLengthMs(get, never):Float;
 
   static function get_beatLengthMs():Float
   {
@@ -88,14 +88,14 @@ class Conductor
   /**
    * Duration of a step (sixtennth note) in milliseconds. Calculated based on bpm.
    */
-  public static var stepLengthMs(get, null):Float;
+  public static var stepLengthMs(get, never):Float;
 
   static function get_stepLengthMs():Float
   {
     return beatLengthMs / timeSignatureNumerator;
   }
 
-  public static var timeSignatureNumerator(get, null):Int;
+  public static var timeSignatureNumerator(get, never):Int;
 
   static function get_timeSignatureNumerator():Int
   {
@@ -104,7 +104,7 @@ class Conductor
     return currentTimeChange.timeSignatureNum;
   }
 
-  public static var timeSignatureDenominator(get, null):Int;
+  public static var timeSignatureDenominator(get, never):Int;
 
   static function get_timeSignatureDenominator():Int
   {
@@ -151,7 +151,7 @@ class Conductor
   public static var audioOffset:Float = 0;
   public static var offset:Float = 0;
 
-  public static var beatsPerMeasure(get, null):Float;
+  public static var beatsPerMeasure(get, never):Float;
 
   static function get_beatsPerMeasure():Float
   {
@@ -159,7 +159,7 @@ class Conductor
     return stepsPerMeasure / Constants.STEPS_PER_BEAT;
   }
 
-  public static var stepsPerMeasure(get, null):Int;
+  public static var stepsPerMeasure(get, never):Int;
 
   static function get_stepsPerMeasure():Int
   {
diff --git a/source/funkin/input/Cursor.hx b/source/funkin/input/Cursor.hx
index 37e819469..edd9e70f3 100644
--- a/source/funkin/input/Cursor.hx
+++ b/source/funkin/input/Cursor.hx
@@ -4,9 +4,34 @@ import openfl.utils.Assets;
 import lime.app.Future;
 import openfl.display.BitmapData;
 
+@:nullSafety
 class Cursor
 {
-  public static var cursorMode(default, set):CursorMode;
+  /**
+   * The current cursor mode.
+   * Set this value to change the cursor graphic.
+   */
+  public static var cursorMode(default, set):Null<CursorMode> = null;
+
+  /**
+   * Show the cursor.
+   */
+  public static inline function show():Void
+  {
+    FlxG.mouse.visible = true;
+    // Reset the cursor mode.
+    Cursor.cursorMode = Default;
+  }
+
+  /**
+   * Hide the cursor.
+   */
+  public static inline function hide():Void
+  {
+    FlxG.mouse.visible = false;
+    // Reset the cursor mode.
+    Cursor.cursorMode = null;
+  }
 
   static final CURSOR_DEFAULT_PARAMS:CursorParams =
     {
@@ -15,7 +40,7 @@ class Cursor
       offsetX: 0,
       offsetY: 0,
     };
-  static var assetCursorDefault:BitmapData = null;
+  static var assetCursorDefault:Null<BitmapData> = null;
 
   static final CURSOR_CROSS_PARAMS:CursorParams =
     {
@@ -24,7 +49,7 @@ class Cursor
       offsetX: 0,
       offsetY: 0,
     };
-  static var assetCursorCross:BitmapData = null;
+  static var assetCursorCross:Null<BitmapData> = null;
 
   static final CURSOR_ERASER_PARAMS:CursorParams =
     {
@@ -33,16 +58,16 @@ class Cursor
       offsetX: 0,
       offsetY: 0,
     };
-  static var assetCursorEraser:BitmapData = null;
+  static var assetCursorEraser:Null<BitmapData> = null;
 
   static final CURSOR_GRABBING_PARAMS:CursorParams =
     {
       graphic: "assets/images/cursor/cursor-grabbing.png",
       scale: 1.0,
-      offsetX: 32,
+      offsetX: -8,
       offsetY: 0,
     };
-  static var assetCursorGrabbing:BitmapData = null;
+  static var assetCursorGrabbing:Null<BitmapData> = null;
 
   static final CURSOR_HOURGLASS_PARAMS:CursorParams =
     {
@@ -51,25 +76,34 @@ class Cursor
       offsetX: 0,
       offsetY: 0,
     };
-  static var assetCursorHourglass:BitmapData = null;
+  static var assetCursorHourglass:Null<BitmapData> = null;
 
   static final CURSOR_POINTER_PARAMS:CursorParams =
     {
       graphic: "assets/images/cursor/cursor-pointer.png",
       scale: 1.0,
-      offsetX: 8,
+      offsetX: -8,
       offsetY: 0,
     };
-  static var assetCursorPointer:BitmapData = null;
+  static var assetCursorPointer:Null<BitmapData> = null;
 
   static final CURSOR_TEXT_PARAMS:CursorParams =
     {
       graphic: "assets/images/cursor/cursor-text.png",
-      scale: 1.0,
+      scale: 0.2,
       offsetX: 0,
       offsetY: 0,
     };
-  static var assetCursorText:BitmapData = null;
+  static var assetCursorText:Null<BitmapData> = null;
+
+  static final CURSOR_TEXT_VERTICAL_PARAMS:CursorParams =
+    {
+      graphic: "assets/images/cursor/cursor-text-vertical.png",
+      scale: 0.2,
+      offsetX: 0,
+      offsetY: 0,
+    };
+  static var assetCursorTextVertical:Null<BitmapData> = null;
 
   static final CURSOR_ZOOM_IN_PARAMS:CursorParams =
     {
@@ -78,7 +112,7 @@ class Cursor
       offsetX: 0,
       offsetY: 0,
     };
-  static var assetCursorZoomIn:BitmapData = null;
+  static var assetCursorZoomIn:Null<BitmapData> = null;
 
   static final CURSOR_ZOOM_OUT_PARAMS:CursorParams =
     {
@@ -87,11 +121,36 @@ class Cursor
       offsetX: 0,
       offsetY: 0,
     };
-  static var assetCursorZoomOut:BitmapData = null;
+  static var assetCursorZoomOut:Null<BitmapData> = null;
 
-  static function set_cursorMode(value:CursorMode):CursorMode
+  static final CURSOR_CROSSHAIR_PARAMS:CursorParams =
+    {
+      graphic: "assets/images/cursor/cursor-crosshair.png",
+      scale: 1.0,
+      offsetX: -16,
+      offsetY: -16,
+    };
+  static var assetCursorCrosshair:Null<BitmapData> = null;
+
+  static final CURSOR_CELL_PARAMS:CursorParams =
+    {
+      graphic: "assets/images/cursor/cursor-cell.png",
+      scale: 1.0,
+      offsetX: -16,
+      offsetY: -16,
+    };
+  static var assetCursorCell:Null<BitmapData> = null;
+
+  // DESIRED CURSOR: Resize NS (vertical)
+  // DESIRED CURSOR: Resize EW (horizontal)
+  // DESIRED CURSOR: Resize NESW (diagonal)
+  // DESIRED CURSOR: Resize NWSE (diagonal)
+  // DESIRED CURSOR: Help (Cursor with question mark)
+  // DESIRED CURSOR: Menu (Cursor with menu icon)
+
+  static function set_cursorMode(value:Null<CursorMode>):Null<CursorMode>
   {
-    if (cursorMode != value)
+    if (value != null && cursorMode != value)
     {
       cursorMode = value;
       setCursorGraphic(cursorMode);
@@ -99,16 +158,9 @@ class Cursor
     return cursorMode;
   }
 
-  public static inline function show():Void
-  {
-    FlxG.mouse.visible = true;
-  }
-
-  public static inline function hide():Void
-  {
-    FlxG.mouse.visible = false;
-  }
-
+  /**
+   * Synchronous.
+   */
   static function setCursorGraphic(?value:CursorMode = null):Void
   {
     if (value == null)
@@ -117,6 +169,156 @@ class Cursor
       return;
     }
 
+    switch (value)
+    {
+      case Default:
+        if (assetCursorDefault == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_DEFAULT_PARAMS.graphic);
+          assetCursorDefault = bitmapData;
+          applyCursorParams(assetCursorDefault, CURSOR_DEFAULT_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorDefault, CURSOR_DEFAULT_PARAMS);
+        }
+
+      case Cross:
+        if (assetCursorCross == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_CROSS_PARAMS.graphic);
+          assetCursorCross = bitmapData;
+          applyCursorParams(assetCursorCross, CURSOR_CROSS_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorCross, CURSOR_CROSS_PARAMS);
+        }
+
+      case Eraser:
+        if (assetCursorEraser == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_ERASER_PARAMS.graphic);
+          assetCursorEraser = bitmapData;
+          applyCursorParams(assetCursorEraser, CURSOR_ERASER_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorEraser, CURSOR_ERASER_PARAMS);
+        }
+
+      case Grabbing:
+        if (assetCursorGrabbing == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_GRABBING_PARAMS.graphic);
+          assetCursorGrabbing = bitmapData;
+          applyCursorParams(assetCursorGrabbing, CURSOR_GRABBING_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorGrabbing, CURSOR_GRABBING_PARAMS);
+        }
+
+      case Hourglass:
+        if (assetCursorHourglass == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_HOURGLASS_PARAMS.graphic);
+          assetCursorHourglass = bitmapData;
+          applyCursorParams(assetCursorHourglass, CURSOR_HOURGLASS_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorHourglass, CURSOR_HOURGLASS_PARAMS);
+        }
+
+      case Pointer:
+        if (assetCursorPointer == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_POINTER_PARAMS.graphic);
+          assetCursorPointer = bitmapData;
+          applyCursorParams(assetCursorPointer, CURSOR_POINTER_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorPointer, CURSOR_POINTER_PARAMS);
+        }
+
+      case Text:
+        if (assetCursorText == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_TEXT_PARAMS.graphic);
+          assetCursorText = bitmapData;
+          applyCursorParams(assetCursorText, CURSOR_TEXT_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorText, CURSOR_TEXT_PARAMS);
+        }
+
+      case ZoomIn:
+        if (assetCursorZoomIn == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_ZOOM_IN_PARAMS.graphic);
+          assetCursorZoomIn = bitmapData;
+          applyCursorParams(assetCursorZoomIn, CURSOR_ZOOM_IN_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorZoomIn, CURSOR_ZOOM_IN_PARAMS);
+        }
+
+      case ZoomOut:
+        if (assetCursorZoomOut == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_ZOOM_OUT_PARAMS.graphic);
+          assetCursorZoomOut = bitmapData;
+          applyCursorParams(assetCursorZoomOut, CURSOR_ZOOM_OUT_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorZoomOut, CURSOR_ZOOM_OUT_PARAMS);
+        }
+
+      case Crosshair:
+        if (assetCursorCrosshair == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_CROSSHAIR_PARAMS.graphic);
+          assetCursorCrosshair = bitmapData;
+          applyCursorParams(assetCursorCrosshair, CURSOR_CROSSHAIR_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorCrosshair, CURSOR_CROSSHAIR_PARAMS);
+        }
+
+      case Cell:
+        if (assetCursorCell == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_CELL_PARAMS.graphic);
+          assetCursorCell = bitmapData;
+          applyCursorParams(assetCursorCell, CURSOR_CELL_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorCell, CURSOR_CELL_PARAMS);
+        }
+
+      default:
+        setCursorGraphic(null);
+    }
+  }
+
+  /**
+   * Asynchronous.
+   */
+  static function loadCursorGraphic(?value:CursorMode = null):Void
+  {
+    if (value == null)
+    {
+      FlxG.mouse.unload();
+      return;
+    }
+
     switch (value)
     {
       case Default:
@@ -127,6 +329,7 @@ class Cursor
             assetCursorDefault = bitmapData;
             applyCursorParams(assetCursorDefault, CURSOR_DEFAULT_PARAMS);
           });
+          future.onError(onCursorError.bind(Default));
         }
         else
         {
@@ -141,6 +344,7 @@ class Cursor
             assetCursorCross = bitmapData;
             applyCursorParams(assetCursorCross, CURSOR_CROSS_PARAMS);
           });
+          future.onError(onCursorError.bind(Cross));
         }
         else
         {
@@ -155,6 +359,7 @@ class Cursor
             assetCursorEraser = bitmapData;
             applyCursorParams(assetCursorEraser, CURSOR_ERASER_PARAMS);
           });
+          future.onError(onCursorError.bind(Eraser));
         }
         else
         {
@@ -169,6 +374,7 @@ class Cursor
             assetCursorGrabbing = bitmapData;
             applyCursorParams(assetCursorGrabbing, CURSOR_GRABBING_PARAMS);
           });
+          future.onError(onCursorError.bind(Grabbing));
         }
         else
         {
@@ -183,6 +389,7 @@ class Cursor
             assetCursorHourglass = bitmapData;
             applyCursorParams(assetCursorHourglass, CURSOR_HOURGLASS_PARAMS);
           });
+          future.onError(onCursorError.bind(Hourglass));
         }
         else
         {
@@ -197,6 +404,7 @@ class Cursor
             assetCursorPointer = bitmapData;
             applyCursorParams(assetCursorPointer, CURSOR_POINTER_PARAMS);
           });
+          future.onError(onCursorError.bind(Pointer));
         }
         else
         {
@@ -211,6 +419,7 @@ class Cursor
             assetCursorText = bitmapData;
             applyCursorParams(assetCursorText, CURSOR_TEXT_PARAMS);
           });
+          future.onError(onCursorError.bind(Text));
         }
         else
         {
@@ -225,6 +434,7 @@ class Cursor
             assetCursorZoomIn = bitmapData;
             applyCursorParams(assetCursorZoomIn, CURSOR_ZOOM_IN_PARAMS);
           });
+          future.onError(onCursorError.bind(ZoomIn));
         }
         else
         {
@@ -239,14 +449,45 @@ class Cursor
             assetCursorZoomOut = bitmapData;
             applyCursorParams(assetCursorZoomOut, CURSOR_ZOOM_OUT_PARAMS);
           });
+          future.onError(onCursorError.bind(ZoomOut));
         }
         else
         {
           applyCursorParams(assetCursorZoomOut, CURSOR_ZOOM_OUT_PARAMS);
         }
 
+      case Crosshair:
+        if (assetCursorCrosshair == null)
+        {
+          var future:Future<BitmapData> = Assets.loadBitmapData(CURSOR_CROSSHAIR_PARAMS.graphic);
+          future.onComplete(function(bitmapData:BitmapData) {
+            assetCursorCrosshair = bitmapData;
+            applyCursorParams(assetCursorCrosshair, CURSOR_CROSSHAIR_PARAMS);
+          });
+          future.onError(onCursorError.bind(Crosshair));
+        }
+        else
+        {
+          applyCursorParams(assetCursorCrosshair, CURSOR_CROSSHAIR_PARAMS);
+        }
+
+      case Cell:
+        if (assetCursorCell == null)
+        {
+          var future:Future<BitmapData> = Assets.loadBitmapData(CURSOR_CELL_PARAMS.graphic);
+          future.onComplete(function(bitmapData:BitmapData) {
+            assetCursorCell = bitmapData;
+            applyCursorParams(assetCursorCell, CURSOR_CELL_PARAMS);
+          });
+          future.onError(onCursorError.bind(Cell));
+        }
+        else
+        {
+          applyCursorParams(assetCursorCell, CURSOR_CELL_PARAMS);
+        }
+
       default:
-        setCursorGraphic(null);
+        loadCursorGraphic(null);
     }
   }
 
@@ -254,6 +495,11 @@ class Cursor
   {
     FlxG.mouse.load(graphic, params.scale, params.offsetX, params.offsetY);
   }
+
+  static function onCursorError(cursorMode:CursorMode, error:String):Void
+  {
+    trace("Failed to load cursor graphic for cursor mode " + cursorMode + ": " + error);
+  }
 }
 
 // https://developer.mozilla.org/en-US/docs/Web/CSS/cursor
@@ -268,6 +514,8 @@ enum CursorMode
   Text;
   ZoomIn;
   ZoomOut;
+  Crosshair;
+  Cell;
 }
 
 /**
diff --git a/source/funkin/input/TurboKeyHandler.hx b/source/funkin/input/TurboKeyHandler.hx
index 3719ff7cc..099d373b4 100644
--- a/source/funkin/input/TurboKeyHandler.hx
+++ b/source/funkin/input/TurboKeyHandler.hx
@@ -26,7 +26,7 @@ class TurboKeyHandler extends FlxBasic
   /**
    * Whether all of the keys for this handler are pressed.
    */
-  public var allPressed(get, null):Bool;
+  public var allPressed(get, never):Bool;
 
   /**
    * Whether all of the keys for this handler are activated,
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index ed82d6e99..068f32f97 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -251,14 +251,14 @@ class PlayState extends MusicBeatSubState
 
   var overrideMusic:Bool = false;
 
-  public var isSubState(get, null):Bool;
+  public var isSubState(get, never):Bool;
 
   function get_isSubState():Bool
   {
     return this._parentState != null;
   }
 
-  public var isChartingMode(get, null):Bool;
+  public var isChartingMode(get, never):Bool;
 
   function get_isChartingMode():Bool
   {
@@ -427,7 +427,7 @@ class PlayState extends MusicBeatSubState
    * Data for the current difficulty for the current song.
    * Includes chart data, scroll speed, and other information.
    */
-  public var currentChart(get, null):SongDifficulty;
+  public var currentChart(get, never):SongDifficulty;
 
   function get_currentChart():SongDifficulty
   {
@@ -439,7 +439,7 @@ class PlayState extends MusicBeatSubState
    * The internal ID of the currently active Stage.
    * Used to retrieve the data required to build the `currentStage`.
    */
-  public var currentStageId(get, null):String;
+  public var currentStageId(get, never):String;
 
   function get_currentStageId():String
   {
diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx
index c7b58c393..30b549fd3 100644
--- a/source/funkin/play/character/BaseCharacter.hx
+++ b/source/funkin/play/character/BaseCharacter.hx
@@ -66,7 +66,7 @@ class BaseCharacter extends Bopper
    * The offset between the corner of the sprite and the origin of the sprite (at the character's feet).
    * cornerPosition = stageData - characterOrigin
    */
-  public var characterOrigin(get, null):FlxPoint;
+  public var characterOrigin(get, never):FlxPoint;
 
   function get_characterOrigin():FlxPoint
   {
@@ -103,7 +103,7 @@ class BaseCharacter extends Bopper
   /**
    * The absolute position of the character's feet, at the bottom-center of the sprite.
    */
-  public var feetPosition(get, null):FlxPoint;
+  public var feetPosition(get, never):FlxPoint;
 
   function get_feetPosition():FlxPoint
   {
@@ -264,7 +264,7 @@ class BaseCharacter extends Bopper
   /**
    * The per-character camera offset.
    */
-  var characterCameraOffsets(get, null):Array<Float>;
+  var characterCameraOffsets(get, never):Array<Float>;
 
   function get_characterCameraOffsets():Array<Float>
   {
diff --git a/source/funkin/play/cutscene/dialogue/Conversation.hx b/source/funkin/play/cutscene/dialogue/Conversation.hx
index a6851f0f9..2b7db381c 100644
--- a/source/funkin/play/cutscene/dialogue/Conversation.hx
+++ b/source/funkin/play/cutscene/dialogue/Conversation.hx
@@ -50,7 +50,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
    */
   var currentDialogueEntry:Int = 0;
 
-  var currentDialogueEntryCount(get, null):Int;
+  var currentDialogueEntryCount(get, never):Int;
 
   function get_currentDialogueEntryCount():Int
   {
@@ -62,14 +62,14 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
   * **/
   var currentDialogueLine:Int = 0;
 
-  var currentDialogueLineCount(get, null):Int;
+  var currentDialogueLineCount(get, never):Int;
 
   function get_currentDialogueLineCount():Int
   {
     return currentDialogueEntryData.text.length;
   }
 
-  var currentDialogueEntryData(get, null):DialogueEntryData;
+  var currentDialogueEntryData(get, never):DialogueEntryData;
 
   function get_currentDialogueEntryData():DialogueEntryData
   {
@@ -79,7 +79,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
     return conversationData.dialogue[currentDialogueEntry];
   }
 
-  var currentDialogueLineString(get, null):String;
+  var currentDialogueLineString(get, never):String;
 
   function get_currentDialogueLineString():String
   {
diff --git a/source/funkin/play/cutscene/dialogue/DialogueBox.hx b/source/funkin/play/cutscene/dialogue/DialogueBox.hx
index bfc0e9233..cdac3c233 100644
--- a/source/funkin/play/cutscene/dialogue/DialogueBox.hx
+++ b/source/funkin/play/cutscene/dialogue/DialogueBox.hx
@@ -13,7 +13,7 @@ import flixel.util.FlxColor;
 class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
 {
   public final dialogueBoxId:String;
-  public var dialogueBoxName(get, null):String;
+  public var dialogueBoxName(get, never):String;
 
   function get_dialogueBoxName():String
   {
diff --git a/source/funkin/play/cutscene/dialogue/Speaker.hx b/source/funkin/play/cutscene/dialogue/Speaker.hx
index 1fb341009..d7ed004f1 100644
--- a/source/funkin/play/cutscene/dialogue/Speaker.hx
+++ b/source/funkin/play/cutscene/dialogue/Speaker.hx
@@ -8,7 +8,7 @@ import funkin.modding.IScriptedClass.IDialogueScriptedClass;
 
 /**
  * The character sprite which displays during dialogue.
- * 
+ *
  * Most conversations have two speakers, with one being flipped.
  */
 class Speaker extends FlxSprite implements IDialogueScriptedClass
@@ -26,7 +26,7 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
   /**
    * A readable name for this speaker.
    */
-  public var speakerName(get, null):String;
+  public var speakerName(get, never):String;
 
   function get_speakerName():String
   {
@@ -129,7 +129,7 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
 
   /**
    * Set the sprite scale to the appropriate value.
-   * @param scale 
+   * @param scale
    */
   public function setScale(scale:Null<Float>):Void
   {
@@ -184,7 +184,7 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
   /**
    * Ensure that a given animation exists before playing it.
    * Will gracefully check for name, then name with stripped suffixes, then 'idle', then fail to play.
-   * @param name 
+   * @param name
    */
   function correctAnimationName(name:String):String
   {
diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index 8847636bd..2b21e6b7e 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -31,7 +31,7 @@ class Strumline extends FlxSpriteGroup
   static final KEY_COUNT:Int = 4;
   static final NOTE_SPLASH_CAP:Int = 6;
 
-  static var RENDER_DISTANCE_MS(get, null):Float;
+  static var RENDER_DISTANCE_MS(get, never):Float;
 
   static function get_RENDER_DISTANCE_MS():Float
   {
diff --git a/source/funkin/play/notes/SustainTrail.hx b/source/funkin/play/notes/SustainTrail.hx
index 72d22191b..4bcbe0528 100644
--- a/source/funkin/play/notes/SustainTrail.hx
+++ b/source/funkin/play/notes/SustainTrail.hx
@@ -31,7 +31,7 @@ class SustainTrail extends FlxSprite
   public var noteDirection:NoteDirection = 0;
   public var sustainLength(default, set):Float = 0; // millis
   public var fullSustainLength:Float = 0;
-  public var noteData:SongNoteData;
+  public var noteData:Null<SongNoteData>;
 
   public var cover:NoteHoldCover = null;
 
diff --git a/source/funkin/play/song/SongMigrator.hx b/source/funkin/play/song/SongMigrator.hx
index bb8718bb7..f33d9bbe9 100644
--- a/source/funkin/play/song/SongMigrator.hx
+++ b/source/funkin/play/song/SongMigrator.hx
@@ -179,7 +179,7 @@ class SongMigrator
     songMetadata.playData.playableChars = {};
     try
     {
-      Reflect.setField(songMetadata.playData.playableChars, songData.song.player1, new SongPlayableChar('', songData.song.player2));
+      songMetadata.playData.playableChars.set(songData.song.player1, new SongPlayableChar('', songData.song.player2));
     }
     catch (e)
     {
diff --git a/source/funkin/play/song/SongValidator.hx b/source/funkin/play/song/SongValidator.hx
index 16ea88664..11cc758b9 100644
--- a/source/funkin/play/song/SongValidator.hx
+++ b/source/funkin/play/song/SongValidator.hx
@@ -20,7 +20,7 @@ class SongValidator
   public static final DEFAULT_STAGE:String = "mainStage";
   public static final DEFAULT_SCROLLSPEED:Float = 1.0;
 
-  public static var DEFAULT_GENERATEDBY(get, null):String;
+  public static var DEFAULT_GENERATEDBY(get, never):String;
 
   static function get_DEFAULT_GENERATEDBY():String
   {
diff --git a/source/funkin/ui/debug/charting/ChartEditorCommand.hx b/source/funkin/ui/debug/charting/ChartEditorCommand.hx
index fd179c481..f0ecb573b 100644
--- a/source/funkin/ui/debug/charting/ChartEditorCommand.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorCommand.hx
@@ -35,6 +35,7 @@ interface ChartEditorCommand
   public function toString():String;
 }
 
+@:nullSafety
 class AddNotesCommand implements ChartEditorCommand
 {
   var notes:Array<SongNoteData>;
@@ -98,6 +99,7 @@ class AddNotesCommand implements ChartEditorCommand
   }
 }
 
+@:nullSafety
 class RemoveNotesCommand implements ChartEditorCommand
 {
   var notes:Array<SongNoteData>;
@@ -153,6 +155,7 @@ class RemoveNotesCommand implements ChartEditorCommand
 /**
  * Appends one or more items to the selection.
  */
+@:nullSafety
 class SelectItemsCommand implements ChartEditorCommand
 {
   var notes:Array<SongNoteData>;
@@ -220,6 +223,7 @@ class SelectItemsCommand implements ChartEditorCommand
   }
 }
 
+@:nullSafety
 class AddEventsCommand implements ChartEditorCommand
 {
   var events:Array<SongEventData>;
@@ -278,6 +282,7 @@ class AddEventsCommand implements ChartEditorCommand
   }
 }
 
+@:nullSafety
 class RemoveEventsCommand implements ChartEditorCommand
 {
   var events:Array<SongEventData>;
@@ -327,6 +332,7 @@ class RemoveEventsCommand implements ChartEditorCommand
   }
 }
 
+@:nullSafety
 class RemoveItemsCommand implements ChartEditorCommand
 {
   var notes:Array<SongNoteData>;
@@ -385,6 +391,7 @@ class RemoveItemsCommand implements ChartEditorCommand
   }
 }
 
+@:nullSafety
 class SwitchDifficultyCommand implements ChartEditorCommand
 {
   var prevDifficulty:String;
@@ -424,6 +431,7 @@ class SwitchDifficultyCommand implements ChartEditorCommand
   }
 }
 
+@:nullSafety
 class DeselectItemsCommand implements ChartEditorCommand
 {
   var notes:Array<SongNoteData>;
@@ -478,6 +486,7 @@ class DeselectItemsCommand implements ChartEditorCommand
  * Sets the selection rather than appends it.
  * Deselects any notes that are not in the new selection.
  */
+@:nullSafety
 class SetItemSelectionCommand implements ChartEditorCommand
 {
   var notes:Array<SongNoteData>;
@@ -518,6 +527,7 @@ class SetItemSelectionCommand implements ChartEditorCommand
   }
 }
 
+@:nullSafety
 class SelectAllItemsCommand implements ChartEditorCommand
 {
   var previousNoteSelection:Array<SongNoteData>;
@@ -553,6 +563,7 @@ class SelectAllItemsCommand implements ChartEditorCommand
   }
 }
 
+@:nullSafety
 class InvertSelectedItemsCommand implements ChartEditorCommand
 {
   var previousNoteSelection:Array<SongNoteData>;
@@ -587,6 +598,7 @@ class InvertSelectedItemsCommand implements ChartEditorCommand
   }
 }
 
+@:nullSafety
 class DeselectAllItemsCommand implements ChartEditorCommand
 {
   var previousNoteSelection:Array<SongNoteData>;
@@ -622,6 +634,7 @@ class DeselectAllItemsCommand implements ChartEditorCommand
   }
 }
 
+@:nullSafety
 class CutItemsCommand implements ChartEditorCommand
 {
   var notes:Array<SongNoteData>;
@@ -679,14 +692,16 @@ class CutItemsCommand implements ChartEditorCommand
   }
 }
 
+@:nullSafety
 class FlipNotesCommand implements ChartEditorCommand
 {
-  var notes:Array<SongNoteData>;
-  var flippedNotes:Array<SongNoteData>;
+  var notes:Array<SongNoteData> = [];
+  var flippedNotes:Array<SongNoteData> = [];
 
   public function new(notes:Array<SongNoteData>)
   {
     this.notes = notes;
+    this.flippedNotes = SongDataUtils.flipNotes(notes);
   }
 
   public function execute(state:ChartEditorState):Void
@@ -695,7 +710,6 @@ class FlipNotesCommand implements ChartEditorCommand
     state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
 
     // Add the flipped notes.
-    flippedNotes = SongDataUtils.flipNotes(notes);
     state.currentSongChartNoteData = state.currentSongChartNoteData.concat(flippedNotes);
 
     state.currentNoteSelection = flippedNotes;
@@ -729,12 +743,13 @@ class FlipNotesCommand implements ChartEditorCommand
   }
 }
 
+@:nullSafety
 class PasteItemsCommand implements ChartEditorCommand
 {
   var targetTimestamp:Float;
   // Notes we added with this command, for undo.
-  var addedNotes:Array<SongNoteData>;
-  var addedEvents:Array<SongEventData>;
+  var addedNotes:Array<SongNoteData> = [];
+  var addedEvents:Array<SongEventData> = [];
 
   public function new(targetTimestamp:Float)
   {
@@ -787,6 +802,7 @@ class PasteItemsCommand implements ChartEditorCommand
   }
 }
 
+@:nullSafety
 class ExtendNoteLengthCommand implements ChartEditorCommand
 {
   var note:SongNoteData;
diff --git a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
index e5b2d332c..59bee0d74 100644
--- a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
@@ -6,6 +6,7 @@ import funkin.util.SerializerUtil;
 import funkin.play.song.SongData.SongChartData;
 import funkin.play.song.SongData.SongMetadata;
 import flixel.util.FlxTimer;
+import funkin.ui.haxeui.components.FunkinLink;
 import funkin.util.SortUtil;
 import funkin.input.Cursor;
 import funkin.play.character.BaseCharacter;
@@ -40,6 +41,7 @@ using Lambda;
 /**
  * Handles dialogs for the new Chart Editor.
  */
+@:nullSafety
 class ChartEditorDialogHandler
 {
   static final CHART_EDITOR_DIALOG_ABOUT_LAYOUT:String = Paths.ui('chart-editor/dialogs/about');
@@ -59,7 +61,7 @@ class ChartEditorDialogHandler
    * @param state The current chart editor state.
    * @return The dialog that was opened.
    */
-  public static inline function openAboutDialog(state:ChartEditorState):Dialog
+  public static inline function openAboutDialog(state:ChartEditorState):Null<Dialog>
   {
     return openDialog(state, CHART_EDITOR_DIALOG_ABOUT_LAYOUT, true, true);
   }
@@ -70,12 +72,14 @@ class ChartEditorDialogHandler
    * @param closable Whether the dialog can be closed by the user.
    * @return The dialog that was opened.
    */
-  public static function openWelcomeDialog(state:ChartEditorState, closable:Bool = true):Dialog
+  public static function openWelcomeDialog(state:ChartEditorState, closable:Bool = true):Null<Dialog>
   {
-    var dialog:Dialog = openDialog(state, CHART_EDITOR_DIALOG_WELCOME_LAYOUT, true, closable);
+    var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_WELCOME_LAYOUT, true, closable);
+    if (dialog == null) throw 'Could not locate Welcome dialog';
 
     // Add handlers to the "Create From Song" section.
-    var linkCreateBasic:Link = dialog.findComponent('splashCreateFromSongBasic', Link);
+    var linkCreateBasic:Null<Link> = dialog.findComponent('splashCreateFromSongBasic', Link);
+    if (linkCreateBasic == null) throw 'Could not locate splashCreateFromSongBasic link in Welcome dialog';
     linkCreateBasic.onClick = function(_event) {
       // Hide the welcome dialog
       dialog.hideDialog(DialogButton.CANCEL);
@@ -86,7 +90,8 @@ class ChartEditorDialogHandler
       openCreateSongWizard(state, false);
     }
 
-    var linkImportChartLegacy:Link = dialog.findComponent('splashImportChartLegacy', Link);
+    var linkImportChartLegacy:Null<Link> = dialog.findComponent('splashImportChartLegacy', Link);
+    if (linkImportChartLegacy == null) throw 'Could not locate splashImportChartLegacy link in Welcome dialog';
     linkImportChartLegacy.onClick = function(_event) {
       // Hide the welcome dialog
       dialog.hideDialog(DialogButton.CANCEL);
@@ -95,7 +100,8 @@ class ChartEditorDialogHandler
       openImportChartWizard(state, 'legacy', false);
     };
 
-    var buttonBrowse:Button = dialog.findComponent('splashBrowse', Button);
+    var buttonBrowse:Null<Button> = dialog.findComponent('splashBrowse', Button);
+    if (buttonBrowse == null) throw 'Could not locate splashBrowse button in Welcome dialog';
     buttonBrowse.onClick = function(_event) {
       // Hide the welcome dialog
       dialog.hideDialog(DialogButton.CANCEL);
@@ -104,25 +110,32 @@ class ChartEditorDialogHandler
       openBrowseWizard(state, false);
     }
 
-    var splashTemplateContainer:VBox = dialog.findComponent('splashTemplateContainer', VBox);
+    var splashTemplateContainer:Null<VBox> = dialog.findComponent('splashTemplateContainer', VBox);
+    if (splashTemplateContainer == null) throw 'Could not locate splashTemplateContainer in Welcome dialog';
 
     var songList:Array<String> = SongDataParser.listSongIds();
     songList.sort(SortUtil.alphabetically);
 
     for (targetSongId in songList)
     {
-      var songData:Song = SongDataParser.fetchSong(targetSongId);
+      var songData:Null<Song> = SongDataParser.fetchSong(targetSongId);
 
       if (songData == null) continue;
 
-      var songName:Null<String> = songData.getDifficulty('normal') ?.songName;
-      if (songName == null) songName = songData.getDifficulty() ?.songName;
+      var diffNormal:Null<SongDifficulty> = songData.getDifficulty('normal');
+      var songName:Null<String> = diffNormal?.songName;
+      if (songName == null)
+      {
+        var diffDefault:Null<SongDifficulty> = songData.getDifficulty();
+        songName = diffDefault?.songName;
+      }
       if (songName == null)
       {
         trace('[WARN] Could not fetch song name for ${targetSongId}');
+        continue;
       }
 
-      var linkTemplateSong:Link = new Link();
+      var linkTemplateSong:Link = new FunkinLink();
       linkTemplateSong.text = songName;
       linkTemplateSong.onClick = function(_event) {
         dialog.hideDialog(DialogButton.CANCEL);
@@ -184,7 +197,8 @@ class ChartEditorDialogHandler
   {
     // Open the "Open Chart" wizard
     // Step 1. Open Chart
-    var openChartDialog:Dialog = openImportChartDialog(state, format);
+    var openChartDialog:Null<Dialog> = openImportChartDialog(state, format);
+    if (openChartDialog == null) throw 'Could not locate Import Chart dialog';
     openChartDialog.onDialogClosed = function(_event) {
       state.isHaxeUIDialogOpen = false;
       if (_event.button == DialogButton.APPLY)
@@ -260,15 +274,18 @@ class ChartEditorDialogHandler
   @:haxe.warning("-WVarInit") // Hide the warning about the onDropFile handler.
   public static function openUploadInstDialog(state:ChartEditorState, closable:Bool = true):Dialog
   {
-    var dialog:Dialog = openDialog(state, CHART_EDITOR_DIALOG_UPLOAD_INST_LAYOUT, true, closable);
+    var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_UPLOAD_INST_LAYOUT, true, closable);
+    if (dialog == null) throw 'Could not locate Upload Instrumental dialog';
 
-    var buttonCancel:Button = dialog.findComponent('dialogCancel', Button);
+    var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
+    if (buttonCancel == null) throw 'Could not locate dialogCancel button in Upload Instrumental dialog';
 
     buttonCancel.onClick = function(_event) {
       dialog.hideDialog(DialogButton.CANCEL);
     }
 
-    var instrumentalBox:Box = dialog.findComponent('instrumentalBox', Box);
+    var instrumentalBox:Null<Box> = dialog.findComponent('instrumentalBox', Box);
+    if (instrumentalBox == null) throw 'Could not locate instrumentalBox in Upload Instrumental dialog';
 
     instrumentalBox.onMouseOver = function(_event) {
       instrumentalBox.swapClass('upload-bg', 'upload-bg-hover');
@@ -285,11 +302,12 @@ class ChartEditorDialogHandler
     instrumentalBox.onClick = function(_event) {
       Dialogs.openBinaryFile('Open Instrumental', [
         {label: 'Audio File (.ogg)', extension: 'ogg'}], function(selectedFile:SelectedFileInfo) {
-          if (selectedFile != null)
+          if (selectedFile != null && selectedFile.bytes != null)
           {
             if (state.loadInstrumentalFromBytes(selectedFile.bytes))
             {
               trace('Selected file: ' + selectedFile.fullPath);
+              #if !mac
               NotificationManager.instance.addNotification(
                 {
                   title: 'Success',
@@ -297,6 +315,7 @@ class ChartEditorDialogHandler
                   type: NotificationType.Success,
                   expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
                 });
+              #end
 
               dialog.hideDialog(DialogButton.APPLY);
               removeDropHandler(onDropFile);
@@ -305,6 +324,7 @@ class ChartEditorDialogHandler
             {
               trace('Failed to load instrumental (${selectedFile.fullPath})');
 
+              #if !mac
               NotificationManager.instance.addNotification(
                 {
                   title: 'Failure',
@@ -312,6 +332,7 @@ class ChartEditorDialogHandler
                   type: NotificationType.Error,
                   expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
                 });
+              #end
             }
           }
       });
@@ -323,6 +344,7 @@ class ChartEditorDialogHandler
       if (state.loadInstrumentalFromPath(path))
       {
         // Tell the user the load was successful.
+        #if !mac
         NotificationManager.instance.addNotification(
           {
             title: 'Success',
@@ -330,13 +352,14 @@ class ChartEditorDialogHandler
             type: NotificationType.Success,
             expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
           });
+        #end
 
         dialog.hideDialog(DialogButton.APPLY);
         removeDropHandler(onDropFile);
       }
       else
       {
-        var message:String = if (!ChartEditorState.SUPPORTED_MUSIC_FORMATS.contains(path.ext))
+        var message:String = if (!ChartEditorState.SUPPORTED_MUSIC_FORMATS.contains(path.ext ?? ''))
         {
           'File format (${path.ext}) not supported for instrumental track (${path.file}.${path.ext})';
         }
@@ -346,6 +369,7 @@ class ChartEditorDialogHandler
         }
 
         // Tell the user the load was successful.
+        #if !mac
         NotificationManager.instance.addNotification(
           {
             title: 'Failure',
@@ -353,6 +377,7 @@ class ChartEditorDialogHandler
             type: NotificationType.Error,
             expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
           });
+        #end
       }
     };
 
@@ -367,6 +392,15 @@ class ChartEditorDialogHandler
       handler:(String->Void)
     }> = [];
 
+  /**
+   * Add a callback for when a file is dropped on a component.
+   *
+   * On OS X you can’t drop on the application window, but rather only the app icon
+   * (either in the dock while running or the icon on the hard drive) so this must be disabled
+   * and UI updated appropriately.
+   * @param component
+   * @param handler
+   */
   static function addDropHandler(component:Component, handler:String->Void):Void
   {
     #if desktop
@@ -420,15 +454,17 @@ class ChartEditorDialogHandler
   @:haxe.warning("-WVarInit")
   public static function openSongMetadataDialog(state:ChartEditorState):Dialog
   {
-    var dialog:Dialog = openDialog(state, CHART_EDITOR_DIALOG_SONG_METADATA_LAYOUT, true, false);
-
-    var buttonCancel:Button = dialog.findComponent('dialogCancel', Button);
+    var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_SONG_METADATA_LAYOUT, true, false);
+    if (dialog == null) throw 'Could not locate Song Metadata dialog';
 
+    var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
+    if (buttonCancel == null) throw 'Could not locate dialogCancel button in Song Metadata dialog';
     buttonCancel.onClick = function(_event) {
       dialog.hideDialog(DialogButton.CANCEL);
     }
 
-    var dialogSongName:TextField = dialog.findComponent('dialogSongName', TextField);
+    var dialogSongName:Null<TextField> = dialog.findComponent('dialogSongName', TextField);
+    if (dialogSongName == null) throw 'Could not locate dialogSongName TextField in Song Metadata dialog';
     dialogSongName.onChange = function(event:UIEvent) {
       var valid:Bool = event.target.text != null && event.target.text != '';
 
@@ -439,12 +475,13 @@ class ChartEditorDialogHandler
       }
       else
       {
-        state.currentSongMetadata.songName = null;
+        state.currentSongMetadata.songName = "";
       }
     };
-    state.currentSongMetadata.songName = null;
+    state.currentSongMetadata.songName = "";
 
-    var dialogSongArtist:TextField = dialog.findComponent('dialogSongArtist', TextField);
+    var dialogSongArtist:Null<TextField> = dialog.findComponent('dialogSongArtist', TextField);
+    if (dialogSongArtist == null) throw 'Could not locate dialogSongArtist TextField in Song Metadata dialog';
     dialogSongArtist.onChange = function(event:UIEvent) {
       var valid:Bool = event.target.text != null && event.target.text != '';
 
@@ -455,26 +492,29 @@ class ChartEditorDialogHandler
       }
       else
       {
-        state.currentSongMetadata.artist = null;
+        state.currentSongMetadata.artist = "";
       }
     };
-    state.currentSongMetadata.artist = null;
+    state.currentSongMetadata.artist = "";
 
-    var dialogStage:DropDown = dialog.findComponent('dialogStage', DropDown);
+    var dialogStage:Null<DropDown> = dialog.findComponent('dialogStage', DropDown);
+    if (dialogStage == null) throw 'Could not locate dialogStage DropDown in Song Metadata dialog';
     dialogStage.onChange = function(event:UIEvent) {
       if (event.data == null && event.data.id == null) return;
       state.currentSongMetadata.playData.stage = event.data.id;
     };
-    state.currentSongMetadata.playData.stage = null;
+    state.currentSongMetadata.playData.stage = 'mainStage';
 
-    var dialogNoteSkin:DropDown = dialog.findComponent('dialogNoteSkin', DropDown);
+    var dialogNoteSkin:Null<DropDown> = dialog.findComponent('dialogNoteSkin', DropDown);
+    if (dialogNoteSkin == null) throw 'Could not locate dialogNoteSkin DropDown in Song Metadata dialog';
     dialogNoteSkin.onChange = function(event:UIEvent) {
       if (event.data.id == null) return;
       state.currentSongMetadata.playData.noteSkin = event.data.id;
     };
-    state.currentSongMetadata.playData.noteSkin = null;
+    state.currentSongMetadata.playData.noteSkin = 'funkin';
 
-    var dialogBPM:NumberStepper = dialog.findComponent('dialogBPM', NumberStepper);
+    var dialogBPM:Null<NumberStepper> = dialog.findComponent('dialogBPM', NumberStepper);
+    if (dialogBPM == null) throw 'Could not locate dialogBPM NumberStepper in Song Metadata dialog';
     dialogBPM.onChange = function(event:UIEvent) {
       if (event.value == null || event.value <= 0) return;
 
@@ -493,8 +533,10 @@ class ChartEditorDialogHandler
       state.currentSongMetadata.timeChanges = timeChanges;
     };
 
-    var dialogCharGrid:PropertyGrid = dialog.findComponent('dialogCharGrid', PropertyGrid);
-    var dialogCharAdd:Button = dialog.findComponent('dialogCharAdd', Button);
+    var dialogCharGrid:Null<PropertyGrid> = dialog.findComponent('dialogCharGrid', PropertyGrid);
+    if (dialogCharGrid == null) throw 'Could not locate dialogCharGrid PropertyGrid in Song Metadata dialog';
+    var dialogCharAdd:Null<Button> = dialog.findComponent('dialogCharAdd', Button);
+    if (dialogCharAdd == null) throw 'Could not locate dialogCharAdd Button in Song Metadata dialog';
     dialogCharAdd.onClick = function(event:UIEvent) {
       var charGroup:PropertyGroup;
       charGroup = buildCharGroup(state, null, () -> dialogCharGrid.removeComponent(charGroup));
@@ -504,15 +546,16 @@ class ChartEditorDialogHandler
     // Empty the character list.
     state.currentSongMetadata.playData.playableChars = {};
     // Add at least one character group with no Remove button.
-    dialogCharGrid.addComponent(buildCharGroup(state, 'bf', null));
+    dialogCharGrid.addComponent(buildCharGroup(state, 'bf'));
 
-    var dialogContinue:Button = dialog.findComponent('dialogContinue', Button);
+    var dialogContinue:Null<Button> = dialog.findComponent('dialogContinue', Button);
+    if (dialogContinue == null) throw 'Could not locate dialogContinue button in Song Metadata dialog';
     dialogContinue.onClick = (_event) -> dialog.hideDialog(DialogButton.APPLY);
 
     return dialog;
   }
 
-  static function buildCharGroup(state:ChartEditorState, key:String = null, removeFunc:Void->Void):PropertyGroup
+  static function buildCharGroup(state:ChartEditorState, key:String = '', removeFunc:Void->Void = null):PropertyGroup
   {
     var groupKey:String = key;
 
@@ -537,32 +580,36 @@ class ChartEditorDialogHandler
 
     var removeGroup:Void->Void = function() {
       state.currentSongMetadata.playData.playableChars.remove(groupKey);
-      removeFunc();
+      if (removeFunc != null) removeFunc();
     }
 
     var charData:SongPlayableChar = getCharData();
 
     var charGroup:PropertyGroup = cast state.buildComponent(CHART_EDITOR_DIALOG_SONG_METADATA_CHARGROUP_LAYOUT);
 
-    var charGroupPlayer:DropDown = charGroup.findComponent('charGroupPlayer', DropDown);
+    var charGroupPlayer:Null<DropDown> = charGroup.findComponent('charGroupPlayer', DropDown);
+    if (charGroupPlayer == null) throw 'Could not locate charGroupPlayer DropDown in Song Metadata dialog';
     charGroupPlayer.onChange = function(event:UIEvent) {
       charGroup.text = event.data.text;
       moveCharGroup(event.data.id);
     };
 
-    var charGroupOpponent:DropDown = charGroup.findComponent('charGroupOpponent', DropDown);
+    var charGroupOpponent:Null<DropDown> = charGroup.findComponent('charGroupOpponent', DropDown);
+    if (charGroupOpponent == null) throw 'Could not locate charGroupOpponent DropDown in Song Metadata dialog';
     charGroupOpponent.onChange = function(event:UIEvent) {
       charData.opponent = event.data.id;
     };
     charGroupOpponent.value = getCharData().opponent;
 
-    var charGroupGirlfriend:DropDown = charGroup.findComponent('charGroupGirlfriend', DropDown);
+    var charGroupGirlfriend:Null<DropDown> = charGroup.findComponent('charGroupGirlfriend', DropDown);
+    if (charGroupGirlfriend == null) throw 'Could not locate charGroupGirlfriend DropDown in Song Metadata dialog';
     charGroupGirlfriend.onChange = function(event:UIEvent) {
       charData.girlfriend = event.data.id;
     };
     charGroupGirlfriend.value = getCharData().girlfriend;
 
-    var charGroupRemove:Button = charGroup.findComponent('charGroupRemove', Button);
+    var charGroupRemove:Null<Button> = charGroup.findComponent('charGroupRemove', Button);
+    if (charGroupRemove == null) throw 'Could not locate charGroupRemove Button in Song Metadata dialog';
     charGroupRemove.onClick = function(event:UIEvent) {
       removeGroup();
     };
@@ -584,21 +631,26 @@ class ChartEditorDialogHandler
 
     for (charKey in state.currentSongMetadata.playData.playableChars.keys())
     {
-      var charData:SongPlayableChar = state.currentSongMetadata.playData.playableChars.get(charKey);
+      var charData:Null<SongPlayableChar> = state.currentSongMetadata.playData.playableChars.get(charKey);
+      if (charData == null) continue;
       charIdsForVocals.push(charKey);
       if (charData.opponent != null) charIdsForVocals.push(charData.opponent);
     }
 
-    var dialog:Dialog = openDialog(state, CHART_EDITOR_DIALOG_UPLOAD_VOCALS_LAYOUT, true, closable);
+    var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_UPLOAD_VOCALS_LAYOUT, true, closable);
+    if (dialog == null) throw 'Could not locate Upload Vocals dialog';
 
-    var dialogContainer:Component = dialog.findComponent('vocalContainer');
+    var dialogContainer:Null<Component> = dialog.findComponent('vocalContainer');
+    if (dialogContainer == null) throw 'Could not locate vocalContainer in Upload Vocals dialog';
 
-    var buttonCancel:Button = dialog.findComponent('dialogCancel', Button);
+    var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
+    if (buttonCancel == null) throw 'Could not locate dialogCancel button in Upload Vocals dialog';
     buttonCancel.onClick = function(_event) {
       dialog.hideDialog(DialogButton.CANCEL);
     }
 
-    var dialogNoVocals:Button = dialog.findComponent('dialogNoVocals', Button);
+    var dialogNoVocals:Null<Button> = dialog.findComponent('dialogNoVocals', Button);
+    if (dialogNoVocals == null) throw 'Could not locate dialogNoVocals button in Upload Vocals dialog';
     dialogNoVocals.onClick = function(_event) {
       // Dismiss
       dialog.hideDialog(DialogButton.APPLY);
@@ -607,13 +659,18 @@ class ChartEditorDialogHandler
     for (charKey in charIdsForVocals)
     {
       trace('Adding vocal upload for character ${charKey}');
-      var charMetadata:CharacterData = CharacterDataParser.fetchCharacterData(charKey);
+      var charMetadata:Null<CharacterData> = CharacterDataParser.fetchCharacterData(charKey);
       var charName:String = charMetadata != null ? charMetadata.name : charKey;
 
       var vocalsEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_UPLOAD_VOCALS_ENTRY_LAYOUT);
 
-      var vocalsEntryLabel:Label = vocalsEntry.findComponent('vocalsEntryLabel', Label);
+      var vocalsEntryLabel:Null<Label> = vocalsEntry.findComponent('vocalsEntryLabel', Label);
+      if (vocalsEntryLabel == null) throw 'Could not locate vocalsEntryLabel in Upload Vocals dialog';
+      #if FILE_DROP_SUPPORTED
       vocalsEntryLabel.text = 'Drag and drop vocals for $charName here, or click to browse.';
+      #else
+      vocalsEntryLabel.text = 'Click to browse for vocals for $charName.';
+      #end
 
       var onDropFile:String->Void = function(pathStr:String) {
         trace('Selected file: $pathStr');
@@ -622,6 +679,7 @@ class ChartEditorDialogHandler
         if (state.loadVocalsFromPath(path, charKey))
         {
           // Tell the user the load was successful.
+          #if !mac
           NotificationManager.instance.addNotification(
             {
               title: 'Success',
@@ -629,13 +687,18 @@ class ChartEditorDialogHandler
               type: NotificationType.Success,
               expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
             });
+
           vocalsEntryLabel.text = 'Vocals for $charName (drag and drop, or click to browse)\nSelected file: ${path.file}.${path.ext}';
+          #else
+          vocalsEntryLabel.text = 'Vocals for $charName (click to browse)\n${path.file}.${path.ext}';
+          #end
+
           dialogNoVocals.hidden = true;
           removeDropHandler(onDropFile);
         }
         else
         {
-          var message:String = if (!ChartEditorState.SUPPORTED_MUSIC_FORMATS.contains(path.ext))
+          var message:String = if (!ChartEditorState.SUPPORTED_MUSIC_FORMATS.contains(path.ext ?? ''))
           {
             'File format (${path.ext}) not supported for vocal track (${path.file}.${path.ext})';
           }
@@ -645,6 +708,7 @@ class ChartEditorDialogHandler
           }
 
           // Vocals failed to load.
+          #if !mac
           NotificationManager.instance.addNotification(
             {
               title: 'Failure',
@@ -652,18 +716,27 @@ class ChartEditorDialogHandler
               type: NotificationType.Error,
               expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
             });
+          #end
 
+          #if FILE_DROP_SUPPORTED
           vocalsEntryLabel.text = 'Drag and drop vocals for $charName here, or click to browse.';
+          #else
+          vocalsEntryLabel.text = 'Click to browse for vocals for $charName.';
+          #end
         }
       };
 
       vocalsEntry.onClick = function(_event) {
         Dialogs.openBinaryFile('Open $charName Vocals', [
           {label: 'Audio File (.ogg)', extension: 'ogg'}], function(selectedFile) {
-            if (selectedFile != null)
+            if (selectedFile != null && selectedFile.bytes != null)
             {
               trace('Selected file: ' + selectedFile.name);
+              #if FILE_DROP_SUPPORTED
+              vocalsEntryLabel.text = 'Vocals for $charName (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}';
+              #else
               vocalsEntryLabel.text = 'Vocals for $charName (click to browse)\n${selectedFile.name}';
+              #end
               state.loadVocalsFromBytes(selectedFile.bytes, charKey);
               dialogNoVocals.hidden = true;
               removeDropHandler(onDropFile);
@@ -672,11 +745,14 @@ class ChartEditorDialogHandler
       }
 
       // onDropFile
+      #if FILE_DROP_SUPPORTED
       addDropHandler(vocalsEntry, onDropFile);
+      #end
       dialogContainer.addComponent(vocalsEntry);
     }
 
-    var dialogContinue:Button = dialog.findComponent('dialogContinue', Button);
+    var dialogContinue:Null<Button> = dialog.findComponent('dialogContinue', Button);
+    if (dialogContinue == null) throw 'Could not locate dialogContinue button in Upload Vocals dialog';
     dialogContinue.onClick = function(_event) {
       // Dismiss
       dialog.hideDialog(DialogButton.APPLY);
@@ -694,20 +770,25 @@ class ChartEditorDialogHandler
   @:haxe.warning('-WVarInit')
   public static function openChartDialog(state:ChartEditorState, closable:Bool = true):Dialog
   {
-    var dialog:Dialog = openDialog(state, CHART_EDITOR_DIALOG_OPEN_CHART_LAYOUT, true, closable);
+    var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_OPEN_CHART_LAYOUT, true, closable);
+    if (dialog == null) throw 'Could not locate Open Chart dialog';
 
-    var buttonCancel:Button = dialog.findComponent('dialogCancel', Button);
+    var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
+    if (buttonCancel == null) throw 'Could not locate dialogCancel button in Open Chart dialog';
     buttonCancel.onClick = function(_event) {
       dialog.hideDialog(DialogButton.CANCEL);
     }
 
-    var chartContainerA:Component = dialog.findComponent('chartContainerA');
-    var chartContainerB:Component = dialog.findComponent('chartContainerB');
+    var chartContainerA:Null<Component> = dialog.findComponent('chartContainerA');
+    if (chartContainerA == null) throw 'Could not locate chartContainerA in Open Chart dialog';
+    var chartContainerB:Null<Component> = dialog.findComponent('chartContainerB');
+    if (chartContainerB == null) throw 'Could not locate chartContainerB in Open Chart dialog';
 
     var songMetadata:Map<String, SongMetadata> = [];
     var songChartData:Map<String, SongChartData> = [];
 
-    var buttonContinue:Button = dialog.findComponent('dialogContinue', Button);
+    var buttonContinue:Null<Button> = dialog.findComponent('dialogContinue', Button);
+    if (buttonContinue == null) throw 'Could not locate dialogContinue button in Open Chart dialog';
     buttonContinue.onClick = function(_event) {
       state.loadSong(songMetadata, songChartData);
 
@@ -728,8 +809,13 @@ class ChartEditorDialogHandler
 
       // Build an entry for -chart.json.
       var songDefaultChartDataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_ENTRY_LAYOUT);
-      var songDefaultChartDataEntryLabel:Label = songDefaultChartDataEntry.findComponent('chartEntryLabel', Label);
+      var songDefaultChartDataEntryLabel:Null<Label> = songDefaultChartDataEntry.findComponent('chartEntryLabel', Label);
+      if (songDefaultChartDataEntryLabel == null) throw 'Could not locate chartEntryLabel in Open Chart dialog';
+      #if FILE_DROP_SUPPORTED
       songDefaultChartDataEntryLabel.text = 'Drag and drop <song>-chart.json file, or click to browse.';
+      #else
+      songDefaultChartDataEntryLabel.text = 'Click to browse for <song>-chart.json file.';
+      #end
 
       songDefaultChartDataEntry.onClick = onClickChartDataVariation.bind(Constants.DEFAULT_VARIATION).bind(songDefaultChartDataEntryLabel);
       addDropHandler(songDefaultChartDataEntry, onDropFileChartDataVariation.bind(Constants.DEFAULT_VARIATION).bind(songDefaultChartDataEntryLabel));
@@ -739,20 +825,50 @@ class ChartEditorDialogHandler
       {
         // Build entries for -metadata-<variation>.json.
         var songVariationMetadataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_ENTRY_LAYOUT);
-        var songVariationMetadataEntryLabel:Label = songVariationMetadataEntry.findComponent('chartEntryLabel', Label);
+        var songVariationMetadataEntryLabel:Null<Label> = songVariationMetadataEntry.findComponent('chartEntryLabel', Label);
+        if (songVariationMetadataEntryLabel == null) throw 'Could not locate chartEntryLabel in Open Chart dialog';
+        #if FILE_DROP_SUPPORTED
         songVariationMetadataEntryLabel.text = 'Drag and drop <song>-metadata-${variation}.json file, or click to browse.';
+        #else
+        songVariationMetadataEntryLabel.text = 'Click to browse for <song>-metadata-${variation}.json file.';
+        #end
 
+        songVariationMetadataEntry.onMouseOver = function(_event) {
+          songVariationMetadataEntry.swapClass('upload-bg', 'upload-bg-hover');
+          Cursor.cursorMode = Pointer;
+        }
+        songVariationMetadataEntry.onMouseOut = function(_event) {
+          songVariationMetadataEntry.swapClass('upload-bg-hover', 'upload-bg');
+          Cursor.cursorMode = Default;
+        }
         songVariationMetadataEntry.onClick = onClickMetadataVariation.bind(variation).bind(songVariationMetadataEntryLabel);
+        #if FILE_DROP_SUPPORTED
         addDropHandler(songVariationMetadataEntry, onDropFileMetadataVariation.bind(variation).bind(songVariationMetadataEntryLabel));
+        #end
         chartContainerB.addComponent(songVariationMetadataEntry);
 
         // Build entries for -chart-<variation>.json.
         var songVariationChartDataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_ENTRY_LAYOUT);
-        var songVariationChartDataEntryLabel:Label = songVariationChartDataEntry.findComponent('chartEntryLabel', Label);
+        var songVariationChartDataEntryLabel:Null<Label> = songVariationChartDataEntry.findComponent('chartEntryLabel', Label);
+        if (songVariationChartDataEntryLabel == null) throw 'Could not locate chartEntryLabel in Open Chart dialog';
+        #if FILE_DROP_SUPPORTED
         songVariationChartDataEntryLabel.text = 'Drag and drop <song>-chart-${variation}.json file, or click to browse.';
+        #else
+        songVariationChartDataEntryLabel.text = 'Click to browse for <song>-chart-${variation}.json file.';
+        #end
 
+        songVariationChartDataEntry.onMouseOver = function(_event) {
+          songVariationChartDataEntry.swapClass('upload-bg', 'upload-bg-hover');
+          Cursor.cursorMode = Pointer;
+        }
+        songVariationChartDataEntry.onMouseOut = function(_event) {
+          songVariationChartDataEntry.swapClass('upload-bg-hover', 'upload-bg');
+          Cursor.cursorMode = Default;
+        }
         songVariationChartDataEntry.onClick = onClickChartDataVariation.bind(variation).bind(songVariationChartDataEntryLabel);
+        #if FILE_DROP_SUPPORTED
         addDropHandler(songVariationChartDataEntry, onDropFileChartDataVariation.bind(variation).bind(songVariationChartDataEntryLabel));
+        #end
         chartContainerB.addComponent(songVariationChartDataEntry);
       }
     }
@@ -768,6 +884,7 @@ class ChartEditorDialogHandler
       if (songMetadataVariation == null)
       {
         // Tell the user the load was not successful.
+        #if !mac
         NotificationManager.instance.addNotification(
           {
             title: 'Failure',
@@ -775,12 +892,14 @@ class ChartEditorDialogHandler
             type: NotificationType.Error,
             expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
           });
+        #end
         return;
       }
 
       songMetadata.set(variation, songMetadataVariation);
 
       // Tell the user the load was successful.
+      #if !mac
       NotificationManager.instance.addNotification(
         {
           title: 'Success',
@@ -788,8 +907,13 @@ class ChartEditorDialogHandler
           type: NotificationType.Success,
           expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
         });
+      #end
 
+      #if FILE_DROP_SUPPORTED
       label.text = 'Metadata file (drag and drop, or click to browse)\nSelected file: ${path.file}.${path.ext}';
+      #else
+      label.text = 'Metadata file (click to browse)\n${path.file}.${path.ext}';
+      #end
 
       if (variation == Constants.DEFAULT_VARIATION) constructVariationEntries(songMetadataVariation.playData.songVariations);
     };
@@ -797,7 +921,7 @@ class ChartEditorDialogHandler
     onClickMetadataVariation = function(variation:String, label:Label, _event:UIEvent) {
       Dialogs.openBinaryFile('Open Chart ($variation) Metadata', [
         {label: 'JSON File (.json)', extension: 'json'}], function(selectedFile) {
-          if (selectedFile != null)
+          if (selectedFile != null && selectedFile.bytes != null)
           {
             trace('Selected file: ' + selectedFile.name);
 
@@ -809,6 +933,7 @@ class ChartEditorDialogHandler
             songMetadata.set(variation, songMetadataVariation);
 
             // Tell the user the load was successful.
+            #if !mac
             NotificationManager.instance.addNotification(
               {
                 title: 'Success',
@@ -816,8 +941,13 @@ class ChartEditorDialogHandler
                 type: NotificationType.Success,
                 expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
               });
+            #end
 
+            #if FILE_DROP_SUPPORTED
             label.text = 'Metadata file (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}';
+            #else
+            label.text = 'Metadata file (click to browse)\n${selectedFile.name}';
+            #end
 
             if (variation == Constants.DEFAULT_VARIATION) constructVariationEntries(songMetadataVariation.playData.songVariations);
           }
@@ -838,6 +968,7 @@ class ChartEditorDialogHandler
       state.noteDisplayDirty = true;
 
       // Tell the user the load was successful.
+      #if !mac
       NotificationManager.instance.addNotification(
         {
           title: 'Success',
@@ -845,14 +976,19 @@ class ChartEditorDialogHandler
           type: NotificationType.Success,
           expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
         });
+      #end
 
+      #if FILE_DROP_SUPPORTED
       label.text = 'Chart data file (drag and drop, or click to browse)\nSelected file: ${path.file}.${path.ext}';
+      #else
+      label.text = 'Chart data file (click to browse)\n${path.file}.${path.ext}';
+      #end
     };
 
     onClickChartDataVariation = function(variation:String, label:Label, _event:UIEvent) {
       Dialogs.openBinaryFile('Open Chart ($variation) Metadata', [
         {label: 'JSON File (.json)', extension: 'json'}], function(selectedFile) {
-          if (selectedFile != null)
+          if (selectedFile != null && selectedFile.bytes != null)
           {
             trace('Selected file: ' + selectedFile.name);
 
@@ -866,6 +1002,7 @@ class ChartEditorDialogHandler
             state.noteDisplayDirty = true;
 
             // Tell the user the load was successful.
+            #if !mac
             NotificationManager.instance.addNotification(
               {
                 title: 'Success',
@@ -873,18 +1010,37 @@ class ChartEditorDialogHandler
                 type: NotificationType.Success,
                 expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
               });
+            #end
 
+            #if FILE_DROP_SUPPORTED
             label.text = 'Chart data file (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}';
+            #else
+            label.text = 'Chart data file (click to browse)\n${selectedFile.name}';
+            #end
           }
       });
     }
 
     var metadataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_ENTRY_LAYOUT);
-    var metadataEntryLabel:Label = metadataEntry.findComponent('chartEntryLabel', Label);
+    var metadataEntryLabel:Null<Label> = metadataEntry.findComponent('chartEntryLabel', Label);
+    if (metadataEntryLabel == null) throw 'Could not locate chartEntryLabel in Open Chart dialog';
+
+    #if FILE_DROP_SUPPORTED
     metadataEntryLabel.text = 'Drag and drop <song>-metadata.json file, or click to browse.';
+    #else
+    metadataEntryLabel.text = 'Click to browse for <song>-metadata.json file.';
+    #end
 
     metadataEntry.onClick = onClickMetadataVariation.bind(Constants.DEFAULT_VARIATION).bind(metadataEntryLabel);
     addDropHandler(metadataEntry, onDropFileMetadataVariation.bind(Constants.DEFAULT_VARIATION).bind(metadataEntryLabel));
+    metadataEntry.onMouseOver = function(_event) {
+      metadataEntry.swapClass('upload-bg', 'upload-bg-hover');
+      Cursor.cursorMode = Pointer;
+    }
+    metadataEntry.onMouseOut = function(_event) {
+      metadataEntry.swapClass('upload-bg-hover', 'upload-bg');
+      Cursor.cursorMode = Default;
+    }
 
     chartContainerA.addComponent(metadataEntry);
 
@@ -898,9 +1054,10 @@ class ChartEditorDialogHandler
    * @param closable
    * @return Dialog
    */
-  public static function openImportChartDialog(state:ChartEditorState, format:String, closable:Bool = true):Dialog
+  public static function openImportChartDialog(state:ChartEditorState, format:String, closable:Bool = true):Null<Dialog>
   {
-    var dialog:Dialog = openDialog(state, CHART_EDITOR_DIALOG_IMPORT_CHART_LAYOUT, true, closable);
+    var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_IMPORT_CHART_LAYOUT, true, closable);
+    if (dialog == null) return null;
 
     var prettyFormat:String = switch (format)
     {
@@ -916,19 +1073,20 @@ class ChartEditorDialogHandler
 
     dialog.title = 'Import Chart - ${prettyFormat}';
 
-    var buttonCancel:Button = dialog.findComponent('dialogCancel', Button);
+    var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
+    if (buttonCancel == null) throw 'Could not locate dialogCancel button in Import Chart dialog';
 
     buttonCancel.onClick = function(_event) {
       dialog.hideDialog(DialogButton.CANCEL);
     }
 
-    var importBox:Box = dialog.findComponent('importBox', Box);
+    var importBox:Null<Box> = dialog.findComponent('importBox', Box);
+    if (importBox == null) throw 'Could not locate importBox in Import Chart dialog';
 
     importBox.onMouseOver = function(_event) {
       importBox.swapClass('upload-bg', 'upload-bg-hover');
       Cursor.cursorMode = Pointer;
     }
-
     importBox.onMouseOut = function(_event) {
       importBox.swapClass('upload-bg-hover', 'upload-bg');
       Cursor.cursorMode = Default;
@@ -937,8 +1095,8 @@ class ChartEditorDialogHandler
     var onDropFile:String->Void;
 
     importBox.onClick = function(_event) {
-      Dialogs.openBinaryFile('Import Chart - ${prettyFormat}', [fileFilter], function(selectedFile:SelectedFileInfo) {
-        if (selectedFile != null)
+      Dialogs.openBinaryFile('Import Chart - ${prettyFormat}', fileFilter != null ? [fileFilter] : [], function(selectedFile:SelectedFileInfo) {
+        if (selectedFile != null && selectedFile.bytes != null)
         {
           trace('Selected file: ' + selectedFile.fullPath);
           var selectedFileJson:Dynamic = SerializerUtil.fromJSONBytes(selectedFile.bytes);
@@ -948,6 +1106,7 @@ class ChartEditorDialogHandler
           state.loadSong([Constants.DEFAULT_VARIATION => songMetadata], [Constants.DEFAULT_VARIATION => songChartData]);
 
           dialog.hideDialog(DialogButton.APPLY);
+          #if !mac
           NotificationManager.instance.addNotification(
             {
               title: 'Success',
@@ -955,6 +1114,7 @@ class ChartEditorDialogHandler
               type: NotificationType.Success,
               expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
             });
+          #end
         }
       });
     }
@@ -968,6 +1128,7 @@ class ChartEditorDialogHandler
       state.loadSong([Constants.DEFAULT_VARIATION => songMetadata], [Constants.DEFAULT_VARIATION => songChartData]);
 
       dialog.hideDialog(DialogButton.APPLY);
+      #if !mac
       NotificationManager.instance.addNotification(
         {
           title: 'Success',
@@ -975,6 +1136,7 @@ class ChartEditorDialogHandler
           type: NotificationType.Success,
           expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
         });
+      #end
     };
 
     addDropHandler(importBox, onDropFile);
@@ -988,7 +1150,7 @@ class ChartEditorDialogHandler
    * @param state The current chart editor state.
    * @return The dialog that was opened.
    */
-  public static inline function openUserGuideDialog(state:ChartEditorState):Dialog
+  public static inline function openUserGuideDialog(state:ChartEditorState):Null<Dialog>
   {
     return openDialog(state, CHART_EDITOR_DIALOG_USER_GUIDE_LAYOUT, true, true);
   }
@@ -998,9 +1160,9 @@ class ChartEditorDialogHandler
    * @param modal Makes the background uninteractable while the dialog is open.
    * @param closable Hides the close button on the dialog, preventing it from being closed unless the user interacts with the dialog.
    */
-  static function openDialog(state:ChartEditorState, key:String, modal:Bool = true, closable:Bool = true):Dialog
+  static function openDialog(state:ChartEditorState, key:String, modal:Bool = true, closable:Bool = true):Null<Dialog>
   {
-    var dialog:Dialog = cast state.buildComponent(key);
+    var dialog:Null<Dialog> = cast state.buildComponent(key);
     if (dialog == null) return null;
 
     dialog.destroyOnClose = true;
diff --git a/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx b/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx
index 2524f014c..4ee6eda9f 100644
--- a/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx
@@ -16,6 +16,7 @@ import funkin.play.song.SongData.SongEventData;
  * A event sprite that can be used to display a song event in a chart.
  * Designed to be used and reused efficiently. Has no gameplay functionality.
  */
+@:nullSafety
 class ChartEditorEventSprite extends FlxSprite
 {
   public static final DEFAULT_EVENT = 'Default';
@@ -45,16 +46,18 @@ class ChartEditorEventSprite extends FlxSprite
     refresh();
   }
 
+  static var eventFrames:Null<FlxFramesCollection> = null;
+
   /**
    * Build a set of animations to allow displaying different types of chart events.
    * @param force `true` to force rebuilding the frames.
    */
   static function buildFrames(force:Bool = false):FlxFramesCollection
   {
-    static var eventFrames:FlxFramesCollection = null;
-
     if (eventFrames != null && !force) return eventFrames;
-    eventFrames = new FlxAtlasFrames(null);
+
+    initEmptyEventFrames();
+    if (eventFrames == null) throw 'Failed to initialize empty event frames.';
 
     // Push the default event as a frame.
     var defaultFrames:FlxAtlasFrames = Paths.getSparrowAtlas('ui/chart-editor/events/$DEFAULT_EVENT');
@@ -83,6 +86,12 @@ class ChartEditorEventSprite extends FlxSprite
     return eventFrames;
   }
 
+  @:nullSafety(Off)
+  static function initEmptyEventFrames():Void
+  {
+    eventFrames = new FlxAtlasFrames(null);
+  }
+
   function buildAnimations():Void
   {
     var eventNames:Array<String> = [DEFAULT_EVENT].concat(SongEventParser.listEventIds());
@@ -133,6 +142,8 @@ class ChartEditorEventSprite extends FlxSprite
 
   public function updateEventPosition(?origin:FlxObject)
   {
+    if (this.eventData == null) return;
+
     this.x = (ChartEditorState.STRUMLINE_SIZE * 2 + 1 - 1) * ChartEditorState.GRID_SIZE;
     if (this.eventData.stepTime >= 0) this.y = this.eventData.stepTime * ChartEditorState.GRID_SIZE;
 
diff --git a/source/funkin/ui/debug/charting/ChartEditorHoldNoteSprite.hx b/source/funkin/ui/debug/charting/ChartEditorHoldNoteSprite.hx
index 5805874f6..ebf65c001 100644
--- a/source/funkin/ui/debug/charting/ChartEditorHoldNoteSprite.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorHoldNoteSprite.hx
@@ -14,6 +14,7 @@ import funkin.play.song.SongData.SongNoteData;
  * A hold note sprite that can be used to display a note in a chart.
  * Designed to be used and reused efficiently. Has no gameplay functionality.
  */
+@:nullSafety
 class ChartEditorHoldNoteSprite extends SustainTrail
 {
   /**
@@ -110,8 +111,10 @@ class ChartEditorHoldNoteSprite extends SustainTrail
     return !aboveViewArea && !belowViewArea;
   }
 
-  public function updateHoldNotePosition(?origin:FlxObject)
+  public function updateHoldNotePosition(?origin:FlxObject):Void
   {
+    if (this.noteData == null) return;
+
     var cursorColumn:Int = this.noteData.data;
 
     if (cursorColumn < 0) cursorColumn = 0;
@@ -139,8 +142,9 @@ class ChartEditorHoldNoteSprite extends SustainTrail
     {
       // noteData.stepTime is a calculated value which accounts for BPM changes
       var stepTime:Float = this.noteData.stepTime;
-      var roundedStepTime:Float = Math.floor(stepTime + 0.01); // Add epsilon to fix rounding issues
-      this.y = roundedStepTime * ChartEditorState.GRID_SIZE;
+      // Add epsilon to fix rounding issues?
+      // var roundedStepTime:Float = Math.floor((stepTime + 0.01) / noteSnapRatio) * noteSnapRatio;
+      this.y = stepTime * ChartEditorState.GRID_SIZE;
     }
 
     this.x += ChartEditorState.GRID_SIZE / 2;
diff --git a/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx b/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx
index 69655bfe5..be45676f2 100644
--- a/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx
@@ -10,6 +10,7 @@ import flixel.util.FlxSpriteUtil;
 /**
  * Handles the note scrollbar preview in the chart editor.
  */
+@:nullSafety
 class ChartEditorNotePreview extends FlxSprite
 {
   //
diff --git a/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx b/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx
index 1c440f6ed..10e0f9045 100644
--- a/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx
@@ -3,6 +3,8 @@ package funkin.ui.debug.charting;
 import flixel.FlxObject;
 import flixel.FlxSprite;
 import flixel.graphics.frames.FlxFramesCollection;
+import flixel.graphics.frames.FlxAtlasFrames;
+import flixel.graphics.frames.FlxFrame;
 import flixel.graphics.frames.FlxTileFrames;
 import flixel.math.FlxPoint;
 import funkin.play.song.SongData.SongNoteData;
@@ -11,6 +13,7 @@ import funkin.play.song.SongData.SongNoteData;
  * A note sprite that can be used to display a note in a chart.
  * Designed to be used and reused efficiently. Has no gameplay functionality.
  */
+@:nullSafety
 class ChartEditorNoteSprite extends FlxSprite
 {
   /**
@@ -32,7 +35,7 @@ class ChartEditorNoteSprite extends FlxSprite
   /**
    * The name of the note style currently in use.
    */
-  public var noteStyle(get, null):String;
+  public var noteStyle(get, never):String;
 
   public function new(parent:ChartEditorState)
   {
@@ -45,6 +48,8 @@ class ChartEditorNoteSprite extends FlxSprite
       initFrameCollection();
     }
 
+    if (noteFrameCollection == null) throw 'ERROR: Could not initialize note sprite animations.';
+
     this.frames = noteFrameCollection;
 
     // Initialize all the animations, not just the one we're going to use immediately,
@@ -77,18 +82,19 @@ class ChartEditorNoteSprite extends FlxSprite
    */
   static function initFrameCollection():Void
   {
-    noteFrameCollection = new FlxFramesCollection(null, ATLAS, null);
+    buildEmptyFrameCollection();
+    if (noteFrameCollection == null) return;
 
     // TODO: Automatically iterate over the list of note skins.
 
     // Normal notes
-    var frameCollectionNormal = Paths.getSparrowAtlas('NOTE_assets');
+    var frameCollectionNormal:FlxAtlasFrames = Paths.getSparrowAtlas('NOTE_assets');
 
     for (frame in frameCollectionNormal.frames)
     {
       noteFrameCollection.pushFrame(frame);
     }
-    var frameCollectionNormal2 = Paths.getSparrowAtlas('NoteHoldNormal');
+    var frameCollectionNormal2:FlxAtlasFrames = Paths.getSparrowAtlas('NoteHoldNormal');
 
     for (frame in frameCollectionNormal2.frames)
     {
@@ -101,13 +107,20 @@ class ChartEditorNoteSprite extends FlxSprite
     var frameCollectionPixel = FlxTileFrames.fromGraphic(graphicPixel, new FlxPoint(17, 17));
     for (i in 0...frameCollectionPixel.frames.length)
     {
-      var frame = frameCollectionPixel.frames[i];
+      var frame:Null<FlxFrame> = frameCollectionPixel.frames[i];
+      if (frame == null) continue;
 
       frame.name = 'pixel' + i;
       noteFrameCollection.pushFrame(frame);
     }
   }
 
+  @:nullSafety(Off)
+  static function buildEmptyFrameCollection():Void
+  {
+    noteFrameCollection = new FlxFramesCollection(null, ATLAS, null);
+  }
+
   function set_noteData(value:Null<SongNoteData>):Null<SongNoteData>
   {
     this.noteData = value;
@@ -130,7 +143,7 @@ class ChartEditorNoteSprite extends FlxSprite
     return this.noteData;
   }
 
-  public function updateNotePosition(?origin:FlxObject)
+  public function updateNotePosition(?origin:FlxObject):Void
   {
     if (this.noteData == null) return;
 
@@ -160,9 +173,7 @@ class ChartEditorNoteSprite extends FlxSprite
     if (this.noteData.stepTime >= 0)
     {
       // noteData.stepTime is a calculated value which accounts for BPM changes
-      var stepTime:Float = this.noteData.stepTime;
-      var roundedStepTime:Float = Math.floor(stepTime + 0.01); // Add epsilon to fix rounding issues
-      this.y = roundedStepTime * ChartEditorState.GRID_SIZE;
+      this.y = this.noteData.stepTime * ChartEditorState.GRID_SIZE;
     }
 
     if (origin != null)
@@ -180,6 +191,8 @@ class ChartEditorNoteSprite extends FlxSprite
 
   public function playNoteAnimation():Void
   {
+    if (this.noteData == null) return;
+
     // Decide whether to display a note or a sustain.
     var baseAnimationName:String = 'tap';
 
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 870b6953e..c0cb473e2 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -179,12 +179,24 @@ class ChartEditorState extends HaxeUIState
    */
   static final SNAP_QUANTS:Array<Int> = [4, 8, 12, 16, 20, 24, 32, 48, 64, 96, 192];
 
+  static final BASE_QUANT:Int = 16;
+
   /**
    * INSTANCE DATA
    */
   // ==============================
-  var noteSnapQuantIndex:Int = 3;
+  public var currentZoomLevel:Float = 1.0;
 
+  /**
+   * The internal index of what note snapping value is in use.
+   * Increment to make placement more preceise and decrement to make placement less precise.
+   */
+  var noteSnapQuantIndex:Int = 3; // default is 16
+
+  /**
+   * The current note snapping value.
+   * For example, `32` when snapping to 32nd notes.
+   */
   public var noteSnapQuant(get, never):Int;
 
   function get_noteSnapQuant():Int
@@ -192,6 +204,17 @@ class ChartEditorState extends HaxeUIState
     return SNAP_QUANTS[noteSnapQuantIndex];
   }
 
+  /**
+   * The ratio of the current note snapping value to the default.
+   * For example, `32` becomes `0.5` when snapping to 16th notes.
+   */
+  public var noteSnapRatio(get, never):Float;
+
+  function get_noteSnapRatio():Float
+  {
+    return BASE_QUANT / noteSnapQuant;
+  }
+
   /**
    * scrollPosition is the current position in the song, in pixels.
    * One pixel is 1/40 of 1 step, and 1/160 of 1 beat.
@@ -1098,6 +1121,11 @@ class ChartEditorState extends HaxeUIState
    */
   var gridGhostNote:Null<ChartEditorNoteSprite> = null;
 
+  /**
+   * A sprite used to indicate the note that will be placed on click.
+   */
+  var gridGhostHoldNote:Null<ChartEditorHoldNoteSprite> = null;
+
   /**
    * A sprite used to indicate the event that will be placed on click.
    */
@@ -1193,6 +1221,9 @@ class ChartEditorState extends HaxeUIState
     // Set the z-index of the HaxeUI.
     this.component.zIndex = 100;
 
+    // Show the mouse cursor.
+    Cursor.show();
+
     fixCamera();
 
     // Get rid of any music from the previous state.
@@ -1271,6 +1302,13 @@ class ChartEditorState extends HaxeUIState
     add(gridGhostNote);
     gridGhostNote.zIndex = 11;
 
+    gridGhostHoldNote = new ChartEditorHoldNoteSprite(this);
+    gridGhostHoldNote.alpha = 0.6;
+    gridGhostHoldNote.noteData = new SongNoteData(0, 0, 0, "");
+    gridGhostHoldNote.visible = false;
+    add(gridGhostHoldNote);
+    gridGhostHoldNote.zIndex = 11;
+
     gridGhostEvent = new ChartEditorEventSprite(this);
     gridGhostEvent.alpha = 0.6;
     gridGhostEvent.eventData = new SongEventData(-1, '', {});
@@ -1280,9 +1318,13 @@ class ChartEditorState extends HaxeUIState
 
     buildNoteGroup();
 
-    gridPlayheadScrollArea = new FlxSprite(gridTiledSprite.x - PLAYHEAD_SCROLL_AREA_WIDTH,
-      MENU_BAR_HEIGHT).makeGraphic(PLAYHEAD_SCROLL_AREA_WIDTH, FlxG.height - MENU_BAR_HEIGHT, PLAYHEAD_SCROLL_AREA_COLOR);
+    gridPlayheadScrollArea = new FlxSprite(0, 0);
+    gridPlayheadScrollArea.makeGraphic(10, 10, PLAYHEAD_SCROLL_AREA_COLOR); // Make it 10x10px and then scale it as needed.
     add(gridPlayheadScrollArea);
+    gridPlayheadScrollArea.setGraphicSize(PLAYHEAD_SCROLL_AREA_WIDTH, 3000);
+    gridPlayheadScrollArea.updateHitbox();
+    gridPlayheadScrollArea.x = gridTiledSprite.x - PLAYHEAD_SCROLL_AREA_WIDTH;
+    gridPlayheadScrollArea.y = MENU_BAR_HEIGHT + GRID_TOP_PAD;
     gridPlayheadScrollArea.zIndex = 25;
 
     // The playhead that show the current position in the song.
@@ -1766,6 +1808,7 @@ class ChartEditorState extends HaxeUIState
 
     // These ones only happen if the modal dialog is not open.
     handleScrollKeybinds();
+    // handleZoom();
     handleSnap();
     handleCursor();
 
@@ -1842,14 +1885,21 @@ class ChartEditorState extends HaxeUIState
   **/
   function handleScrollKeybinds():Void
   {
-    // Don't scroll when the cursor is over the UI.
-    if (isCursorOverHaxeUI) return;
+    // Don't scroll when the cursor is over the UI, unless a playbar button (the << >> ones) is pressed.
+    if (isCursorOverHaxeUI && playbarButtonPressed == null) return;
 
     var scrollAmount:Float = 0; // Amount to scroll the grid.
     var playheadAmount:Float = 0; // Amount to scroll the playhead relative to the grid.
     var shouldPause:Bool = false; // Whether to pause the song when scrolling.
     var shouldEase:Bool = false; // Whether to ease the scroll.
 
+    // Mouse Wheel = Scroll
+    if (FlxG.mouse.wheel != 0 && !FlxG.keys.pressed.CONTROL)
+    {
+      scrollAmount = -10 * FlxG.mouse.wheel;
+      shouldPause = true;
+    }
+
     // Up Arrow = Scroll Up
     if (upKeyHandler.activated && currentLiveInputStyle != LiveInputStyle.WASD)
     {
@@ -1867,13 +1917,15 @@ class ChartEditorState extends HaxeUIState
     if (pageUpKeyHandler.activated)
     {
       var measureHeight:Float = GRID_SIZE * 4 * Conductor.beatsPerMeasure;
-      var targetScrollPosition:Float = Math.floor(scrollPositionInPixels / measureHeight) * measureHeight;
+      var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels;
+      var targetScrollPosition:Float = Math.floor(playheadPos / measureHeight) * measureHeight;
       // If we would move less than one grid, instead move to the top of the previous measure.
-      if (Math.abs(targetScrollPosition - scrollPositionInPixels) < GRID_SIZE)
+      var targetScrollAmount = Math.abs(targetScrollPosition - playheadPos);
+      if (targetScrollAmount < GRID_SIZE)
       {
-        targetScrollPosition -= GRID_SIZE * 4 * Conductor.beatsPerMeasure;
+        targetScrollPosition -= GRID_SIZE * Constants.STEPS_PER_BEAT * Conductor.beatsPerMeasure;
       }
-      scrollAmount = targetScrollPosition - scrollPositionInPixels;
+      scrollAmount = targetScrollPosition - playheadPos;
 
       shouldPause = true;
     }
@@ -1888,13 +1940,15 @@ class ChartEditorState extends HaxeUIState
     if (pageDownKeyHandler.activated)
     {
       var measureHeight:Float = GRID_SIZE * 4 * Conductor.beatsPerMeasure;
-      var targetScrollPosition:Float = Math.ceil(scrollPositionInPixels / measureHeight) * measureHeight;
+      var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels;
+      var targetScrollPosition:Float = Math.ceil(playheadPos / measureHeight) * measureHeight;
       // If we would move less than one grid, instead move to the top of the next measure.
-      if (Math.abs(targetScrollPosition - scrollPositionInPixels) < GRID_SIZE)
+      var targetScrollAmount = Math.abs(targetScrollPosition - playheadPos);
+      if (targetScrollAmount < GRID_SIZE)
       {
-        targetScrollPosition += GRID_SIZE * 4 * Conductor.beatsPerMeasure;
+        targetScrollPosition += GRID_SIZE * Constants.STEPS_PER_BEAT * Conductor.beatsPerMeasure;
       }
-      scrollAmount = targetScrollPosition - scrollPositionInPixels;
+      scrollAmount = targetScrollPosition - playheadPos;
 
       shouldPause = true;
     }
@@ -1905,13 +1959,6 @@ class ChartEditorState extends HaxeUIState
       shouldPause = true;
     }
 
-    // Mouse Wheel = Scroll
-    if (FlxG.mouse.wheel != 0 && !FlxG.keys.pressed.CONTROL)
-    {
-      scrollAmount = -10 * FlxG.mouse.wheel;
-      shouldPause = true;
-    }
-
     // Middle Mouse + Drag = Scroll but move the playhead the same amount.
     if (FlxG.mouse.pressedMiddle)
     {
@@ -2020,6 +2067,11 @@ class ChartEditorState extends HaxeUIState
 
     if (shouldHandleCursor)
     {
+      // Over the course of this big conditional block,
+      // we determine what the cursor should look like,
+      // and fall back to the default cursor if none of the conditions are met.
+      var targetCursorMode:Null<CursorMode> = null;
+
       if (gridTiledSprite == null) throw "ERROR: Tried to handle cursor, but gridTiledSprite is null! Check ChartEditorState.buildGrid()";
 
       var overlapsGrid:Bool = FlxG.mouse.overlaps(gridTiledSprite);
@@ -2029,9 +2081,9 @@ class ChartEditorState extends HaxeUIState
       var cursorY:Float = FlxG.mouse.screenY - gridTiledSprite.y;
 
       var overlapsSelectionBorder:Bool = overlapsGrid
-        && (cursorX % 40) < (GRID_SELECTION_BORDER_WIDTH / 2)
+        && ((cursorX % 40) < (GRID_SELECTION_BORDER_WIDTH / 2)
           || (cursorX % 40) > (40 - (GRID_SELECTION_BORDER_WIDTH / 2))
-            || (cursorY % 40) < (GRID_SELECTION_BORDER_WIDTH / 2) || (cursorY % 40) > (40 - (GRID_SELECTION_BORDER_WIDTH / 2));
+            || (cursorY % 40) < (GRID_SELECTION_BORDER_WIDTH / 2) || (cursorY % 40) > (40 - (GRID_SELECTION_BORDER_WIDTH / 2)));
 
       if (FlxG.mouse.justPressed)
       {
@@ -2047,6 +2099,8 @@ class ChartEditorState extends HaxeUIState
         else if (!overlapsGrid || overlapsSelectionBorder)
         {
           selectionBoxStartPos = new FlxPoint(FlxG.mouse.screenX, FlxG.mouse.screenY);
+          // Drawing selection box.
+          targetCursorMode = Crosshair;
         }
         else
         {
@@ -2057,23 +2111,6 @@ class ChartEditorState extends HaxeUIState
         }
       }
 
-      if (gridPlayheadScrollAreaPressed)
-      {
-        Cursor.cursorMode = Grabbing;
-      }
-      else if (notePreviewScrollAreaStartPos != null)
-      {
-        Cursor.cursorMode = Pointer;
-      }
-      else if (gridPlayheadScrollArea != null && FlxG.mouse.overlaps(gridPlayheadScrollArea))
-      {
-        Cursor.cursorMode = Pointer;
-      }
-      else
-      {
-        Cursor.cursorMode = Default;
-      }
-
       if (gridPlayheadScrollAreaPressed && FlxG.mouse.released)
       {
         gridPlayheadScrollAreaPressed = false;
@@ -2090,11 +2127,18 @@ class ChartEditorState extends HaxeUIState
         // Move the playhead to the cursor position.
         this.playheadPositionInPixels = FlxG.mouse.screenY - MENU_BAR_HEIGHT - GRID_TOP_PAD;
         moveSongToScrollPosition();
+
+        // Cursor should be a grabby hand.
+        if (targetCursorMode == null) targetCursorMode = Grabbing;
       }
 
       // The song position of the cursor, in steps.
-      var cursorFractionalStep:Float = cursorY / GRID_SIZE / (16 / noteSnapQuant);
+      var cursorFractionalStep:Float = cursorY / GRID_SIZE;
       var cursorMs:Float = Conductor.getStepTimeInMs(cursorFractionalStep);
+      // Round the cursor step to the nearest snap quant.
+      var cursorSnappedStep:Float = Math.floor(cursorFractionalStep / noteSnapRatio) * noteSnapRatio;
+      var cursorSnappedMs:Float = Conductor.getStepTimeInMs(cursorSnappedStep);
+
       // The direction value for the column at the cursor.
       var cursorColumn:Int = Math.floor(cursorX / GRID_SIZE);
       if (cursorColumn < 0) cursorColumn = 0;
@@ -2217,6 +2261,8 @@ class ChartEditorState extends HaxeUIState
           }
           else
           {
+            // Clicking and dragging.
+
             // Scroll the screen if the mouse is above or below the grid.
             if (FlxG.mouse.screenY < MENU_BAR_HEIGHT)
             {
@@ -2242,6 +2288,8 @@ class ChartEditorState extends HaxeUIState
             selectionRect.width = Math.abs(FlxG.mouse.screenX - selectionBoxStartPos.x);
             selectionRect.height = Math.abs(FlxG.mouse.screenY - selectionBoxStartPos.y);
             setSelectionBoxBounds(selectionRect);
+
+            targetCursorMode = Crosshair;
           }
         }
         else if (FlxG.mouse.justReleased)
@@ -2333,7 +2381,9 @@ class ChartEditorState extends HaxeUIState
       }
       else if (notePreviewScrollAreaStartPos != null)
       {
-        trace('Updating current song time while clicking and holding...');
+        // Player is clicking and holding on note preview to scrub around.
+        targetCursorMode = Grabbing;
+
         var clickedPosInPixels:Float = FlxMath.remapToRange(FlxG.mouse.screenY, (notePreview?.y ?? 0.0),
           (notePreview?.y ?? 0.0) + (notePreview?.height ?? 0.0), 0, songLengthInPixels);
 
@@ -2344,15 +2394,17 @@ class ChartEditorState extends HaxeUIState
       {
         // Handle extending the note as you drag.
 
-        // TODO: This should be beat snapped?
-        var dragLengthSteps:Float = Conductor.getTimeInSteps(cursorMs) - currentPlaceNoteData.stepTime;
+        var dragLengthSteps:Float = Conductor.getTimeInSteps(cursorSnappedMs) - currentPlaceNoteData.stepTime;
+        var dragLengthMs:Float = dragLengthSteps * Conductor.stepLengthMs;
+        var dragLengthPixels:Float = dragLengthSteps * GRID_SIZE;
 
-        // Without this, the newly placed note feels too short compared to the user's input.
-        var INCREMENT:Float = 1.0;
-        // TODO: Make this not busted with BPM changes
-        var dragLengthMs:Float = Math.floor(dragLengthSteps + INCREMENT) * Conductor.stepLengthMs;
+        gridGhostHoldNote.visible = true;
+        gridGhostHoldNote.noteData = gridGhostNote.noteData;
+        gridGhostHoldNote.noteDirection = gridGhostNote.noteData.getDirection();
 
-        // TODO: Add and update some sort of preview?
+        gridGhostHoldNote.setHeightDirectly(dragLengthPixels);
+
+        gridGhostHoldNote.updateHoldNotePosition(renderedHoldNotes);
 
         if (FlxG.mouse.justReleased)
         {
@@ -2439,14 +2491,14 @@ class ChartEditorState extends HaxeUIState
                 {
                   // Create an event and place it in the chart.
                   // TODO: Figure out configuring event data.
-                  var newEventData:SongEventData = new SongEventData(cursorMs, selectedEventKind, selectedEventData);
+                  var newEventData:SongEventData = new SongEventData(cursorSnappedMs, selectedEventKind, selectedEventData);
 
                   performCommand(new AddEventsCommand([newEventData], FlxG.keys.pressed.CONTROL));
                 }
                 else
                 {
                   // Create a note and place it in the chart.
-                  var newNoteData:SongNoteData = new SongNoteData(cursorMs, cursorColumn, 0, selectedNoteKind);
+                  var newNoteData:SongNoteData = new SongNoteData(cursorSnappedMs, cursorColumn, 0, selectedNoteKind);
 
                   performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL));
 
@@ -2501,13 +2553,12 @@ class ChartEditorState extends HaxeUIState
         // Handle grid cursor.
         if (overlapsGrid && !overlapsSelectionBorder && !gridPlayheadScrollAreaPressed)
         {
-          Cursor.cursorMode = Pointer;
-
           // Indicate that we can place a note here.
 
           if (cursorColumn == eventColumn)
           {
             if (gridGhostNote != null) gridGhostNote.visible = false;
+            gridGhostHoldNote.visible = false;
 
             if (gridGhostEvent == null) throw "ERROR: Tried to handle cursor, but gridGhostEvent is null! Check ChartEditorState.buildGrid()";
 
@@ -2517,11 +2568,13 @@ class ChartEditorState extends HaxeUIState
             {
               eventData.event = selectedEventKind;
             }
-            eventData.time = cursorMs;
+            eventData.time = cursorSnappedMs;
 
             gridGhostEvent.visible = true;
             gridGhostEvent.eventData = eventData;
             gridGhostEvent.updateEventPosition(renderedEvents);
+
+            targetCursorMode = Cell;
           }
           else
           {
@@ -2537,35 +2590,63 @@ class ChartEditorState extends HaxeUIState
               noteData.data = cursorColumn;
               gridGhostNote.playNoteAnimation();
             }
-            noteData.time = cursorMs;
+            noteData.time = cursorSnappedMs;
 
             gridGhostNote.visible = true;
             gridGhostNote.noteData = noteData;
             gridGhostNote.updateNotePosition(renderedNotes);
-          }
 
-          // gridCursor.visible = true;
-          // // X and Y are the cursor position relative to the grid, snapped to the top left of the grid square.
-          // gridCursor.x = Math.floor(cursorX / GRID_SIZE) * GRID_SIZE + gridTiledSprite.x + (GRID_SELECTION_BORDER_WIDTH / 2);
-          // gridCursor.y = cursorStep * GRID_SIZE + gridTiledSprite.y + (GRID_SELECTION_BORDER_WIDTH / 2);
+            targetCursorMode = Cell;
+          }
         }
         else
         {
           if (gridGhostNote != null) gridGhostNote.visible = false;
+          if (gridGhostHoldNote != null) gridGhostHoldNote.visible = false;
           if (gridGhostEvent != null) gridGhostEvent.visible = false;
-          Cursor.cursorMode = Default;
         }
       }
+
+      if (targetCursorMode == null)
+      {
+        if (FlxG.mouse.pressed)
+        {
+          if (overlapsSelectionBorder)
+          {
+            targetCursorMode = Crosshair;
+          }
+        }
+        else
+        {
+          if (FlxG.mouse.overlaps(notePreview))
+          {
+            targetCursorMode = Pointer;
+          }
+          else if (FlxG.mouse.overlaps(gridPlayheadScrollArea))
+          {
+            targetCursorMode = Pointer;
+          }
+          else if (overlapsSelectionBorder)
+          {
+            targetCursorMode = Crosshair;
+          }
+          else if (overlapsGrid)
+          {
+            targetCursorMode = Cell;
+          }
+        }
+      }
+
+      // Actually set the cursor mode to the one we specified earlier.
+      Cursor.cursorMode = targetCursorMode ?? Default;
     }
     else
     {
       if (gridGhostNote != null) gridGhostNote.visible = false;
+      if (gridGhostHoldNote != null) gridGhostHoldNote.visible = false;
       if (gridGhostEvent != null) gridGhostEvent.visible = false;
-    }
 
-    if (isCursorOverHaxeUIButton && Cursor.cursorMode == Default)
-    {
-      Cursor.cursorMode = Pointer;
+      // Do not set Cursor.cursorMode here, because it will be set by the HaxeUI.
     }
   }
 
@@ -2617,7 +2698,7 @@ class ChartEditorState extends HaxeUIState
       var displayedHoldNoteData:Array<SongNoteData> = [];
       for (holdNoteSprite in renderedHoldNotes.members)
       {
-        if (holdNoteSprite == null || !holdNoteSprite.exists || !holdNoteSprite.visible) continue;
+        if (holdNoteSprite == null || holdNoteSprite.noteData == null || !holdNoteSprite.exists || !holdNoteSprite.visible) continue;
 
         if (!holdNoteSprite.isHoldNoteVisible(FlxG.height - MENU_BAR_HEIGHT, GRID_TOP_PAD))
         {
@@ -2693,7 +2774,8 @@ class ChartEditorState extends HaxeUIState
         // The note sprite handles animation playback and positioning.
         noteSprite.noteData = noteData;
 
-        // Setting note data resets position relative to the grid so we fix that.
+        // Setting note data resets the position relative to the group!
+        // If we don't update the note position AFTER setting the note data, the note will be rendered offscreen at y=5000.
         noteSprite.updateNotePosition(renderedNotes);
 
         // Add hold notes that are now visible (and not already displayed).
@@ -2811,10 +2893,10 @@ class ChartEditorState extends HaxeUIState
       }
 
       // Sort the notes DESCENDING. This keeps the sustain behind the associated note.
-      renderedNotes.sort(FlxSort.byY, FlxSort.DESCENDING);
+      renderedNotes.sort(FlxSort.byY, FlxSort.DESCENDING); // TODO: .group.insertionSort()
 
       // Sort the events DESCENDING. This keeps the sustain behind the associated note.
-      renderedEvents.sort(FlxSort.byY, FlxSort.DESCENDING);
+      renderedEvents.sort(FlxSort.byY, FlxSort.DESCENDING); // TODO: .group.insertionSort()
     }
 
     // Add a debug value which displays the current size of the note pool.
@@ -2874,6 +2956,18 @@ class ChartEditorState extends HaxeUIState
    */
   function handleFileKeybinds():Void
   {
+    // CTRL + N = New Chart
+    if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.N)
+    {
+      ChartEditorDialogHandler.openWelcomeDialog(this, true);
+    }
+
+    // CTRL + O = Open Chart
+    if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.O)
+    {
+      ChartEditorDialogHandler.openBrowseWizard(this, true);
+    }
+
     // CTRL + Q = Quit to Menu
     if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Q)
     {
@@ -3112,7 +3206,8 @@ class ChartEditorState extends HaxeUIState
       difficultySelectDirty = false;
 
       // Manage the Select Difficulty tree view.
-      var difficultyToolbox:CollapsibleDialog = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
+      var difficultyToolbox:Null<CollapsibleDialog> = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
+      if (difficultyToolbox == null) return;
 
       var treeView:Null<TreeView> = difficultyToolbox.findComponent('difficultyToolboxTree');
       if (treeView == null) return;
@@ -3158,7 +3253,7 @@ class ChartEditorState extends HaxeUIState
     if (treeView == null)
     {
       // Manage the Select Difficulty tree view.
-      var difficultyToolbox:CollapsibleDialog = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
+      var difficultyToolbox:Null<CollapsibleDialog> = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
       if (difficultyToolbox == null) return;
 
       treeView = difficultyToolbox.findComponent('difficultyToolboxTree');
@@ -3172,7 +3267,7 @@ class ChartEditorState extends HaxeUIState
   function handlePlayerPreviewToolbox():Void
   {
     // Manage the Select Difficulty tree view.
-    var charPreviewToolbox:CollapsibleDialog = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT);
+    var charPreviewToolbox:Null<CollapsibleDialog> = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT);
     if (charPreviewToolbox == null) return;
 
     var charPlayer:Null<CharacterPlayer> = charPreviewToolbox.findComponent('charPlayer');
@@ -3207,7 +3302,7 @@ class ChartEditorState extends HaxeUIState
   function handleOpponentPreviewToolbox():Void
   {
     // Manage the Select Difficulty tree view.
-    var charPreviewToolbox:CollapsibleDialog = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT);
+    var charPreviewToolbox:Null<CollapsibleDialog> = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT);
     if (charPreviewToolbox == null) return;
 
     var charPlayer:Null<CharacterPlayer> = charPreviewToolbox.findComponent('charPlayer');
@@ -3279,7 +3374,7 @@ class ChartEditorState extends HaxeUIState
   {
     if (treeView == null)
     {
-      var difficultyToolbox:CollapsibleDialog = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
+      var difficultyToolbox:Null<CollapsibleDialog> = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
       if (difficultyToolbox == null) return null;
 
       treeView = difficultyToolbox.findComponent('difficultyToolboxTree');
@@ -3342,7 +3437,8 @@ class ChartEditorState extends HaxeUIState
    */
   function refreshSongMetadataToolbox():Void
   {
-    var toolbox:CollapsibleDialog = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
+    var toolbox:Null<CollapsibleDialog> = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
+    if (toolbox == null) return;
 
     var inputSongName:Null<TextField> = toolbox.findComponent('inputSongName', TextField);
     if (inputSongName != null) inputSongName.value = currentSongMetadata.songName;
@@ -3670,25 +3766,29 @@ class ChartEditorState extends HaxeUIState
     this.scrollPositionInPixels = value;
 
     // Move the grid sprite to the correct position.
-    if (isViewDownscroll)
+    if (gridTiledSprite != null)
     {
-      if (gridTiledSprite != null) gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD);
-    }
-    else
-    {
-      if (gridTiledSprite != null) gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD);
+      if (isViewDownscroll)
+      {
+        gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD);
+        gridPlayheadScrollArea.y = gridTiledSprite.y;
+      }
+      else
+      {
+        gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD);
+        gridPlayheadScrollArea.y = gridTiledSprite.y;
+      }
     }
+
     // Move the rendered notes to the correct position.
     renderedNotes.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0);
     renderedHoldNotes.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0);
     renderedEvents.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0);
     renderedSelectionSquares.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0);
-
     // Offset the selection box start position, if we are dragging.
     if (selectionBoxStartPos != null) selectionBoxStartPos.y -= diff;
     // Update the note preview viewport box.
     setNotePreviewViewportBounds(calculateNotePreviewViewportBounds());
-
     return this.scrollPositionInPixels;
   }
 
@@ -3847,6 +3947,11 @@ class ChartEditorState extends HaxeUIState
       songLengthInMs = audioInstTrack.length;
 
       if (gridTiledSprite != null) gridTiledSprite.height = songLengthInPixels;
+      if (gridPlayheadScrollArea != null)
+      {
+        gridPlayheadScrollArea.setGraphicSize(Std.int(gridPlayheadScrollArea.width), songLengthInPixels);
+        gridPlayheadScrollArea.updateHitbox();
+      }
 
       buildSpectrogram(audioInstTrack);
     }
@@ -3988,6 +4093,7 @@ class ChartEditorState extends HaxeUIState
       }
     }
 
+    #if !mac
     NotificationManager.instance.addNotification(
       {
         title: 'Success',
@@ -3995,6 +4101,7 @@ class ChartEditorState extends HaxeUIState
         type: NotificationType.Success,
         expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
       });
+    #end
   }
 
   /**
@@ -4131,10 +4238,12 @@ class ChartEditorState extends HaxeUIState
 
   function sortChartData():Void
   {
+    // TODO: .insertionSort()
     currentSongChartNoteData.sort(function(a:SongNoteData, b:SongNoteData):Int {
       return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time);
     });
 
+    // TODO: .insertionSort()
     currentSongChartEventData.sort(function(a:SongEventData, b:SongEventData):Int {
       return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time);
     });
@@ -4182,6 +4291,9 @@ class ChartEditorState extends HaxeUIState
 
     cleanupAutoSave();
 
+    // Hide the mouse cursor on other states.
+    Cursor.hide();
+
     @:privateAccess
     ChartEditorNoteSprite.noteFrameCollection = null;
   }
diff --git a/source/funkin/ui/debug/charting/ChartEditorThemeHandler.hx b/source/funkin/ui/debug/charting/ChartEditorThemeHandler.hx
index 17906dac2..8a9bb8b03 100644
--- a/source/funkin/ui/debug/charting/ChartEditorThemeHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorThemeHandler.hx
@@ -20,6 +20,7 @@ enum ChartEditorTheme
 /**
  * Static functions which handle building themed UI elements for a provided ChartEditorState.
  */
+@:nullSafety
 class ChartEditorThemeHandler
 {
   // TODO: There's probably a better system of organization for these colors.
@@ -50,6 +51,11 @@ class ChartEditorThemeHandler
   static final GRID_MEASURE_DIVIDER_COLOR_DARK:FlxColor = 0xFFC4C4C4;
   static final GRID_MEASURE_DIVIDER_WIDTH:Float = ChartEditorState.GRID_SELECTION_BORDER_WIDTH;
 
+  // Horizontal divider between beats.
+  static final GRID_BEAT_DIVIDER_COLOR_LIGHT:FlxColor = 0xFFC1C1C1;
+  static final GRID_BEAT_DIVIDER_COLOR_DARK:FlxColor = 0xFF848484;
+  static final GRID_BEAT_DIVIDER_WIDTH:Float = ChartEditorState.GRID_SELECTION_BORDER_WIDTH;
+
   // Border on the square highlighting selected notes.
   static final SELECTION_SQUARE_BORDER_COLOR_LIGHT:FlxColor = 0xFF339933;
   static final SELECTION_SQUARE_BORDER_COLOR_DARK:FlxColor = 0xFF339933;
@@ -92,6 +98,7 @@ class ChartEditorThemeHandler
    */
   static function updateBackground(state:ChartEditorState):Void
   {
+    if (state.menuBG == null) return;
     state.menuBG.color = switch (state.currentTheme)
     {
       case ChartEditorTheme.Light: BACKGROUND_COLOR_LIGHT;
@@ -141,7 +148,7 @@ class ChartEditorThemeHandler
       ChartEditorState.GRID_SELECTION_BORDER_WIDTH),
       selectionBorderColor);
 
-    // Selection borders in the middle.
+    // Selection borders horizontally along the middle.
     for (i in 1...(Conductor.stepsPerMeasure))
     {
       state.gridBitmap.fillRect(new Rectangle(0, (ChartEditorState.GRID_SIZE * i) - (ChartEditorState.GRID_SELECTION_BORDER_WIDTH / 2),
@@ -159,7 +166,7 @@ class ChartEditorThemeHandler
       state.gridBitmap.height),
       selectionBorderColor);
 
-    // Selection borders across the middle.
+    // Selection borders vertically along the middle.
     for (i in 1...TOTAL_COLUMN_COUNT)
     {
       state.gridBitmap.fillRect(new Rectangle((ChartEditorState.GRID_SIZE * i) - (ChartEditorState.GRID_SELECTION_BORDER_WIDTH / 2), 0,
@@ -172,7 +179,7 @@ class ChartEditorThemeHandler
       ChartEditorState.GRID_SELECTION_BORDER_WIDTH, state.gridBitmap.height),
       selectionBorderColor);
 
-    // Draw dividers between the measures.
+    // Draw horizontal dividers between the measures.
 
     var gridMeasureDividerColor:FlxColor = switch (state.currentTheme)
     {
@@ -187,7 +194,30 @@ class ChartEditorThemeHandler
     var dividerLineBY:Float = state.gridBitmap.height - (GRID_MEASURE_DIVIDER_WIDTH / 2);
     state.gridBitmap.fillRect(new Rectangle(0, dividerLineBY, state.gridBitmap.width, GRID_MEASURE_DIVIDER_WIDTH / 2), gridMeasureDividerColor);
 
-    // Draw dividers between the strumlines.
+    // Draw horizontal dividers between the beats.
+
+    var gridBeatDividerColor:FlxColor = switch (state.currentTheme)
+    {
+      case Light: GRID_BEAT_DIVIDER_COLOR_LIGHT;
+      case Dark: GRID_BEAT_DIVIDER_COLOR_DARK;
+      default: GRID_BEAT_DIVIDER_COLOR_LIGHT;
+    };
+
+    // Selection borders horizontally in the middle.
+    for (i in 1...(Conductor.stepsPerMeasure))
+    {
+      if ((i % Conductor.beatsPerMeasure) == 0)
+      {
+        state.gridBitmap.fillRect(new Rectangle(0, (ChartEditorState.GRID_SIZE * i) - (GRID_BEAT_DIVIDER_WIDTH / 2), state.gridBitmap.width,
+          GRID_BEAT_DIVIDER_WIDTH),
+          gridBeatDividerColor);
+      }
+    }
+
+    // Divider at top
+    state.gridBitmap.fillRect(new Rectangle(0, 0, state.gridBitmap.width, GRID_MEASURE_DIVIDER_WIDTH / 2), gridMeasureDividerColor);
+
+    // Draw vertical dividers between the strumlines.
 
     var gridStrumlineDividerColor:FlxColor = switch (state.currentTheme)
     {
diff --git a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
index 152615568..f67a69112 100644
--- a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
@@ -39,6 +39,7 @@ enum ChartEditorToolMode
 /**
  * Static functions which handle building themed UI elements for a provided ChartEditorState.
  */
+@:nullSafety
 @:allow(funkin.ui.debug.charting.ChartEditorState)
 class ChartEditorToolboxHandler
 {
@@ -56,7 +57,7 @@ class ChartEditorToolboxHandler
 
   public static function showToolbox(state:ChartEditorState, id:String):Void
   {
-    var toolbox:CollapsibleDialog = state.activeToolboxes.get(id);
+    var toolbox:Null<CollapsibleDialog> = state.activeToolboxes.get(id);
 
     if (toolbox == null) toolbox = initToolbox(state, id);
 
@@ -95,7 +96,7 @@ class ChartEditorToolboxHandler
 
   public static function hideToolbox(state:ChartEditorState, id:String):Void
   {
-    var toolbox:CollapsibleDialog = state.activeToolboxes.get(id);
+    var toolbox:Null<CollapsibleDialog> = state.activeToolboxes.get(id);
 
     if (toolbox == null) toolbox = initToolbox(state, id);
 
@@ -134,7 +135,7 @@ class ChartEditorToolboxHandler
 
   public static function minimizeToolbox(state:ChartEditorState, id:String):Void
   {
-    var toolbox:CollapsibleDialog = state.activeToolboxes.get(id);
+    var toolbox:Null<CollapsibleDialog> = state.activeToolboxes.get(id);
 
     if (toolbox == null) return;
 
@@ -143,16 +144,16 @@ class ChartEditorToolboxHandler
 
   public static function maximizeToolbox(state:ChartEditorState, id:String):Void
   {
-    var toolbox:CollapsibleDialog = state.activeToolboxes.get(id);
+    var toolbox:Null<CollapsibleDialog> = state.activeToolboxes.get(id);
 
     if (toolbox == null) return;
 
     toolbox.minimized = false;
   }
 
-  public static function initToolbox(state:ChartEditorState, id:String):CollapsibleDialog
+  public static function initToolbox(state:ChartEditorState, id:String):Null<CollapsibleDialog>
   {
-    var toolbox:CollapsibleDialog = null;
+    var toolbox:Null<CollapsibleDialog> = null;
     switch (id)
     {
       case ChartEditorState.CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT:
@@ -193,9 +194,9 @@ class ChartEditorToolboxHandler
    * @param id The asset ID of the toolbox layout.
    * @return The toolbox.
    */
-  public static function getToolbox(state:ChartEditorState, id:String):CollapsibleDialog
+  public static function getToolbox(state:ChartEditorState, id:String):Null<CollapsibleDialog>
   {
-    var toolbox:CollapsibleDialog = state.activeToolboxes.get(id);
+    var toolbox:Null<CollapsibleDialog> = state.activeToolboxes.get(id);
 
     // Initialize the toolbox without showing it.
     if (toolbox == null) toolbox = initToolbox(state, id);
@@ -205,7 +206,7 @@ class ChartEditorToolboxHandler
     return toolbox;
   }
 
-  static function buildToolboxToolsLayout(state:ChartEditorState):CollapsibleDialog
+  static function buildToolboxToolsLayout(state:ChartEditorState):Null<CollapsibleDialog>
   {
     var toolbox:CollapsibleDialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT);
 
@@ -219,7 +220,8 @@ class ChartEditorToolboxHandler
       state.setUICheckboxSelected('menubarItemToggleToolboxTools', false);
     }
 
-    var toolsGroup:Group = toolbox.findComponent('toolboxToolsGroup', Group);
+    var toolsGroup:Null<Group> = toolbox.findComponent('toolboxToolsGroup', Group);
+    if (toolsGroup == null) throw 'ChartEditorToolboxHandler.buildToolboxToolsLayout() - Could not find toolboxToolsGroup component.';
 
     if (toolsGroup == null) return null;
 
@@ -242,7 +244,7 @@ class ChartEditorToolboxHandler
 
   static function onHideToolboxTools(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
 
-  static function buildToolboxNoteDataLayout(state:ChartEditorState):CollapsibleDialog
+  static function buildToolboxNoteDataLayout(state:ChartEditorState):Null<CollapsibleDialog>
   {
     var toolbox:CollapsibleDialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT);
 
@@ -256,9 +258,13 @@ class ChartEditorToolboxHandler
       state.setUICheckboxSelected('menubarItemToggleToolboxNotes', false);
     }
 
-    var toolboxNotesNoteKind:DropDown = toolbox.findComponent('toolboxNotesNoteKind', DropDown);
-    var toolboxNotesCustomKindLabel:Label = toolbox.findComponent('toolboxNotesCustomKindLabel', Label);
-    var toolboxNotesCustomKind:TextField = toolbox.findComponent('toolboxNotesCustomKind', TextField);
+    var toolboxNotesNoteKind:Null<DropDown> = toolbox.findComponent('toolboxNotesNoteKind', DropDown);
+    if (toolboxNotesNoteKind == null) throw 'ChartEditorToolboxHandler.buildToolboxNoteDataLayout() - Could not find toolboxNotesNoteKind component.';
+    var toolboxNotesCustomKindLabel:Null<Label> = toolbox.findComponent('toolboxNotesCustomKindLabel', Label);
+    if (toolboxNotesCustomKindLabel == null)
+      throw 'ChartEditorToolboxHandler.buildToolboxNoteDataLayout() - Could not find toolboxNotesCustomKindLabel component.';
+    var toolboxNotesCustomKind:Null<TextField> = toolbox.findComponent('toolboxNotesCustomKind', TextField);
+    if (toolboxNotesCustomKind == null) throw 'ChartEditorToolboxHandler.buildToolboxNoteDataLayout() - Could not find toolboxNotesCustomKind component.';
 
     toolboxNotesNoteKind.onChange = function(event:UIEvent) {
       var isCustom:Bool = (event.data.id == '~CUSTOM~');
@@ -290,7 +296,7 @@ class ChartEditorToolboxHandler
 
   static function onHideToolboxNoteData(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
 
-  static function buildToolboxEventDataLayout(state:ChartEditorState):CollapsibleDialog
+  static function buildToolboxEventDataLayout(state:ChartEditorState):Null<CollapsibleDialog>
   {
     var toolbox:CollapsibleDialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT);
 
@@ -304,8 +310,10 @@ class ChartEditorToolboxHandler
       state.setUICheckboxSelected('menubarItemToggleToolboxEvents', false);
     }
 
-    var toolboxEventsEventKind:DropDown = toolbox.findComponent('toolboxEventsEventKind', DropDown);
-    var toolboxEventsDataGrid:Grid = toolbox.findComponent('toolboxEventsDataGrid', Grid);
+    var toolboxEventsEventKind:Null<DropDown> = toolbox.findComponent('toolboxEventsEventKind', DropDown);
+    if (toolboxEventsEventKind == null) throw 'ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Could not find toolboxEventsEventKind component.';
+    var toolboxEventsDataGrid:Null<Grid> = toolbox.findComponent('toolboxEventsDataGrid', Grid);
+    if (toolboxEventsDataGrid == null) throw 'ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Could not find toolboxEventsDataGrid component.';
 
     toolboxEventsEventKind.dataSource = new ArrayDataSource();
 
@@ -349,6 +357,8 @@ class ChartEditorToolboxHandler
 
     for (field in schema)
     {
+      if (field == null) continue;
+
       // Add a label.
       var label:Label = new Label();
       label.text = field.title;
@@ -360,33 +370,36 @@ class ChartEditorToolboxHandler
         case INTEGER:
           var numberStepper:NumberStepper = new NumberStepper();
           numberStepper.id = field.name;
-          numberStepper.step = field.step == null ? 1.0 : field.step;
-          numberStepper.min = field.min;
-          numberStepper.max = field.max;
-          numberStepper.value = field.defaultValue;
+          numberStepper.step = field.step ?? 1.0;
+          numberStepper.min = field.min ?? 0.0;
+          numberStepper.max = field.max ?? 10.0;
+          if (field.defaultValue != null) numberStepper.value = field.defaultValue;
           input = numberStepper;
         case FLOAT:
           var numberStepper:NumberStepper = new NumberStepper();
           numberStepper.id = field.name;
-          numberStepper.step = field.step == null ? 0.1 : field.step;
-          numberStepper.min = field.min;
-          numberStepper.max = field.max;
-          numberStepper.value = field.defaultValue;
+          numberStepper.step = field.step ?? 0.1;
+          numberStepper.min = field.min ?? 0.0;
+          numberStepper.max = field.max ?? 1.0;
+          if (field.defaultValue != null) numberStepper.value = field.defaultValue;
           input = numberStepper;
         case BOOL:
           var checkBox:CheckBox = new CheckBox();
           checkBox.id = field.name;
-          checkBox.selected = field.defaultValue;
+          if (field.defaultValue != null) checkBox.selected = field.defaultValue;
           input = checkBox;
         case ENUM:
           var dropDown:DropDown = new DropDown();
           dropDown.id = field.name;
           dropDown.dataSource = new ArrayDataSource();
 
+          if (field.keys == null) throw 'Field "${field.name}" is of Enum type but has no keys.';
+
           // Add entries to the dropdown.
+
           for (optionName in field.keys.keys())
           {
-            var optionValue:String = field.keys.get(optionName);
+            var optionValue:Null<String> = field.keys.get(optionName);
             trace('$optionName : $optionValue');
             dropDown.dataSource.add({value: optionValue, text: optionName});
           }
@@ -397,7 +410,7 @@ class ChartEditorToolboxHandler
         case STRING:
           input = new TextField();
           input.id = field.name;
-          input.text = field.defaultValue;
+          if (field.defaultValue != null) input.text = field.defaultValue;
         default:
           // Unknown type. Display a label so we know what it is.
           input = new Label();
@@ -417,7 +430,7 @@ class ChartEditorToolboxHandler
     }
   }
 
-  static function buildToolboxDifficultyLayout(state:ChartEditorState):CollapsibleDialog
+  static function buildToolboxDifficultyLayout(state:ChartEditorState):Null<CollapsibleDialog>
   {
     var toolbox:CollapsibleDialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
 
@@ -431,11 +444,20 @@ class ChartEditorToolboxHandler
       state.setUICheckboxSelected('menubarItemToggleToolboxDifficulty', false);
     }
 
-    var difficultyToolboxSaveMetadata:Button = toolbox.findComponent('difficultyToolboxSaveMetadata', Button);
-    var difficultyToolboxSaveChart:Button = toolbox.findComponent('difficultyToolboxSaveChart', Button);
-    var difficultyToolboxSaveAll:Button = toolbox.findComponent('difficultyToolboxSaveAll', Button);
-    var difficultyToolboxLoadMetadata:Button = toolbox.findComponent('difficultyToolboxLoadMetadata', Button);
-    var difficultyToolboxLoadChart:Button = toolbox.findComponent('difficultyToolboxLoadChart', Button);
+    var difficultyToolboxSaveMetadata:Null<Button> = toolbox.findComponent('difficultyToolboxSaveMetadata', Button);
+    if (difficultyToolboxSaveMetadata == null)
+      throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxSaveMetadata component.';
+    var difficultyToolboxSaveChart:Null<Button> = toolbox.findComponent('difficultyToolboxSaveChart', Button);
+    if (difficultyToolboxSaveChart == null)
+      throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxSaveChart component.';
+    var difficultyToolboxSaveAll:Null<Button> = toolbox.findComponent('difficultyToolboxSaveAll', Button);
+    if (difficultyToolboxSaveAll == null) throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxSaveAll component.';
+    var difficultyToolboxLoadMetadata:Null<Button> = toolbox.findComponent('difficultyToolboxLoadMetadata', Button);
+    if (difficultyToolboxLoadMetadata == null)
+      throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxLoadMetadata component.';
+    var difficultyToolboxLoadChart:Null<Button> = toolbox.findComponent('difficultyToolboxLoadChart', Button);
+    if (difficultyToolboxLoadChart == null)
+      throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxLoadChart component.';
 
     difficultyToolboxSaveMetadata.onClick = function(event:UIEvent) {
       SongSerializer.exportSongMetadata(state.currentSongMetadata, state.currentSongId);
@@ -472,16 +494,18 @@ class ChartEditorToolboxHandler
   static function onShowToolboxDifficulty(state:ChartEditorState, toolbox:CollapsibleDialog):Void
   {
     // Update the selected difficulty when reopening the toolbox.
-    var treeView:TreeView = toolbox.findComponent('difficultyToolboxTree');
+    var treeView:Null<TreeView> = toolbox.findComponent('difficultyToolboxTree');
     if (treeView == null) return;
 
-    treeView.selectedNode = state.getCurrentTreeDifficultyNode(treeView);
+    var current = state.getCurrentTreeDifficultyNode(treeView);
+    if (current == null) return;
+    treeView.selectedNode = current;
     trace('selected node: ${treeView.selectedNode}');
   }
 
   static function onHideToolboxDifficulty(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
 
-  static function buildToolboxMetadataLayout(state:ChartEditorState):CollapsibleDialog
+  static function buildToolboxMetadataLayout(state:ChartEditorState):Null<CollapsibleDialog>
   {
     var toolbox:CollapsibleDialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
 
@@ -495,7 +519,8 @@ class ChartEditorToolboxHandler
       state.setUICheckboxSelected('menubarItemToggleToolboxMetadata', false);
     }
 
-    var inputSongName:TextField = toolbox.findComponent('inputSongName', TextField);
+    var inputSongName:Null<TextField> = toolbox.findComponent('inputSongName', TextField);
+    if (inputSongName == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputSongName component.';
     inputSongName.onChange = function(event:UIEvent) {
       var valid:Bool = event.target.text != null && event.target.text != '';
 
@@ -506,12 +531,13 @@ class ChartEditorToolboxHandler
       }
       else
       {
-        state.currentSongMetadata.songName = null;
+        state.currentSongMetadata.songName = '';
       }
     };
     inputSongName.value = state.currentSongMetadata.songName;
 
-    var inputSongArtist:TextField = toolbox.findComponent('inputSongArtist', TextField);
+    var inputSongArtist:Null<TextField> = toolbox.findComponent('inputSongArtist', TextField);
+    if (inputSongArtist == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputSongArtist component.';
     inputSongArtist.onChange = function(event:UIEvent) {
       var valid:Bool = event.target.text != null && event.target.text != '';
 
@@ -522,12 +548,13 @@ class ChartEditorToolboxHandler
       }
       else
       {
-        state.currentSongMetadata.artist = null;
+        state.currentSongMetadata.artist = '';
       }
     };
     inputSongArtist.value = state.currentSongMetadata.artist;
 
-    var inputStage:DropDown = toolbox.findComponent('inputStage', DropDown);
+    var inputStage:Null<DropDown> = toolbox.findComponent('inputStage', DropDown);
+    if (inputStage == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputStage component.';
     inputStage.onChange = function(event:UIEvent) {
       var valid:Bool = event.data != null && event.data.id != null;
 
@@ -538,14 +565,16 @@ class ChartEditorToolboxHandler
     };
     inputStage.value = state.currentSongMetadata.playData.stage;
 
-    var inputNoteSkin:DropDown = toolbox.findComponent('inputNoteSkin', DropDown);
+    var inputNoteSkin:Null<DropDown> = toolbox.findComponent('inputNoteSkin', DropDown);
+    if (inputNoteSkin == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputNoteSkin component.';
     inputNoteSkin.onChange = function(event:UIEvent) {
       if ((event?.data?.id ?? null) == null) return;
       state.currentSongMetadata.playData.noteSkin = event.data.id;
     };
     inputNoteSkin.value = state.currentSongMetadata.playData.noteSkin;
 
-    var inputBPM:NumberStepper = toolbox.findComponent('inputBPM', NumberStepper);
+    var inputBPM:Null<NumberStepper> = toolbox.findComponent('inputBPM', NumberStepper);
+    if (inputBPM == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputBPM component.';
     inputBPM.onChange = function(event:UIEvent) {
       if (event.value == null || event.value <= 0) return;
 
@@ -565,9 +594,11 @@ class ChartEditorToolboxHandler
     };
     inputBPM.value = state.currentSongMetadata.timeChanges[0].bpm;
 
-    var labelScrollSpeed:Label = toolbox.findComponent('labelScrollSpeed', Label);
+    var labelScrollSpeed:Null<Label> = toolbox.findComponent('labelScrollSpeed', Label);
+    if (labelScrollSpeed == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find labelScrollSpeed component.';
 
-    var inputScrollSpeed:Slider = toolbox.findComponent('inputScrollSpeed', Slider);
+    var inputScrollSpeed:Null<Slider> = toolbox.findComponent('inputScrollSpeed', Slider);
+    if (inputScrollSpeed == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputScrollSpeed component.';
     inputScrollSpeed.onChange = function(event:UIEvent) {
       var valid:Bool = event.target.value != null && event.target.value > 0;
 
@@ -585,10 +616,12 @@ class ChartEditorToolboxHandler
     inputScrollSpeed.value = state.currentSongChartScrollSpeed;
     labelScrollSpeed.text = 'Scroll Speed: ${state.currentSongChartScrollSpeed}x';
 
-    var frameVariation:Frame = toolbox.findComponent('frameVariation', Frame);
+    var frameVariation:Null<Frame> = toolbox.findComponent('frameVariation', Frame);
+    if (frameVariation == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find frameVariation component.';
     frameVariation.text = 'Variation: ${state.selectedVariation.toTitleCase()}';
 
-    var frameDifficulty:Frame = toolbox.findComponent('frameDifficulty', Frame);
+    var frameDifficulty:Null<Frame> = toolbox.findComponent('frameDifficulty', Frame);
+    if (frameDifficulty == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find frameDifficulty component.';
     frameDifficulty.text = 'Difficulty: ${state.selectedDifficulty.toTitleCase()}';
 
     return toolbox;
@@ -601,7 +634,7 @@ class ChartEditorToolboxHandler
 
   static function onHideToolboxMetadata(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
 
-  static function buildToolboxCharactersLayout(state:ChartEditorState):CollapsibleDialog
+  static function buildToolboxCharactersLayout(state:ChartEditorState):Null<CollapsibleDialog>
   {
     var toolbox:CollapsibleDialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT);
 
@@ -622,7 +655,7 @@ class ChartEditorToolboxHandler
 
   static function onHideToolboxCharacters(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
 
-  static function buildToolboxPlayerPreviewLayout(state:ChartEditorState):CollapsibleDialog
+  static function buildToolboxPlayerPreviewLayout(state:ChartEditorState):Null<CollapsibleDialog>
   {
     var toolbox:CollapsibleDialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT);
 
@@ -636,7 +669,8 @@ class ChartEditorToolboxHandler
       state.setUICheckboxSelected('menubarItemToggleToolboxPlayerPreview', false);
     }
 
-    var charPlayer:CharacterPlayer = toolbox.findComponent('charPlayer');
+    var charPlayer:Null<CharacterPlayer> = toolbox.findComponent('charPlayer');
+    if (charPlayer == null) throw 'ChartEditorToolboxHandler.buildToolboxPlayerPreviewLayout() - Could not find charPlayer component.';
     // TODO: We need to implement character swapping in ChartEditorState.
     charPlayer.loadCharacter('bf');
     charPlayer.characterType = CharacterType.BF;
@@ -650,7 +684,7 @@ class ChartEditorToolboxHandler
 
   static function onHideToolboxPlayerPreview(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
 
-  static function buildToolboxOpponentPreviewLayout(state:ChartEditorState):CollapsibleDialog
+  static function buildToolboxOpponentPreviewLayout(state:ChartEditorState):Null<CollapsibleDialog>
   {
     var toolbox:CollapsibleDialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT);
 
@@ -664,7 +698,8 @@ class ChartEditorToolboxHandler
       state.setUICheckboxSelected('menubarItemToggleToolboxOpponentPreview', false);
     }
 
-    var charPlayer:CharacterPlayer = toolbox.findComponent('charPlayer');
+    var charPlayer:Null<CharacterPlayer> = toolbox.findComponent('charPlayer');
+    if (charPlayer == null) throw 'ChartEditorToolboxHandler.buildToolboxOpponentPreviewLayout() - Could not find charPlayer component.';
     // TODO: We need to implement character swapping in ChartEditorState.
     charPlayer.loadCharacter('dad');
     charPlayer.characterType = CharacterType.DAD;
diff --git a/source/funkin/ui/haxeui/components/CharacterPlayer.hx b/source/funkin/ui/haxeui/components/CharacterPlayer.hx
index c638e8a72..66b94bfa2 100644
--- a/source/funkin/ui/haxeui/components/CharacterPlayer.hx
+++ b/source/funkin/ui/haxeui/components/CharacterPlayer.hx
@@ -30,7 +30,7 @@ typedef AnimationInfo =
 @:composite(Layout)
 class CharacterPlayer extends Box
 {
-  var character:BaseCharacter;
+  var character:Null<BaseCharacter>;
 
   public function new(defaultToBf:Bool = true)
   {
@@ -47,7 +47,7 @@ class CharacterPlayer extends Box
 
   function get_charId():String
   {
-    return character.characterId;
+    return character?.characterId ?? '';
   }
 
   function set_charId(value:String):String
@@ -56,11 +56,11 @@ class CharacterPlayer extends Box
     return value;
   }
 
-  public var charName(get, null):String;
+  public var charName(get, never):String;
 
   function get_charName():String
   {
-    return character.characterName;
+    return character?.characterName ?? "Unknown";
   }
 
   // possible haxeui bug: if listener is added after event is dispatched, event is "lost"... is it smart to "collect and redispatch"? Not sure
@@ -86,7 +86,11 @@ class CharacterPlayer extends Box
 
     // Prevent script issues by fetching with debug=true.
     var newCharacter:BaseCharacter = CharacterDataParser.fetchCharacter(id, true);
-    if (newCharacter == null) return; // Fail if character doesn't exist.
+    if (newCharacter == null)
+    {
+      character = null;
+      return; // Fail if character doesn't exist.
+    }
 
     // Assign character.
     character = newCharacter;
diff --git a/source/funkin/ui/haxeui/components/FunkinButton.hx b/source/funkin/ui/haxeui/components/FunkinButton.hx
new file mode 100644
index 000000000..45987b9ec
--- /dev/null
+++ b/source/funkin/ui/haxeui/components/FunkinButton.hx
@@ -0,0 +1,30 @@
+package funkin.ui.haxeui.components;
+
+import funkin.input.Cursor;
+import haxe.ui.events.MouseEvent;
+import haxe.ui.components.Button;
+
+/**
+ * A HaxeUI button which:
+ * - Changes the current cursor when hovered over.
+ */
+class FunkinButton extends Button
+{
+  public function new()
+  {
+    super();
+
+    this.onMouseOver = handleMouseOver;
+    this.onMouseOut = handleMouseOut;
+  }
+
+  private function handleMouseOver(event:MouseEvent)
+  {
+    Cursor.cursorMode = Pointer;
+  }
+
+  private function handleMouseOut(event:MouseEvent)
+  {
+    Cursor.cursorMode = Default;
+  }
+}
diff --git a/source/funkin/ui/haxeui/components/FunkinHorizontalSlider.hx b/source/funkin/ui/haxeui/components/FunkinHorizontalSlider.hx
new file mode 100644
index 000000000..baf42aada
--- /dev/null
+++ b/source/funkin/ui/haxeui/components/FunkinHorizontalSlider.hx
@@ -0,0 +1,30 @@
+package funkin.ui.haxeui.components;
+
+import haxe.ui.components.HorizontalSlider;
+import funkin.input.Cursor;
+import haxe.ui.events.MouseEvent;
+
+/**
+ * A HaxeUI horizontal slider which:
+ * - Changes the current cursor when hovered over.
+ */
+class FunkinHorizontalSlider extends HorizontalSlider
+{
+  public function new()
+  {
+    super();
+
+    this.onMouseOver = handleMouseOver;
+    this.onMouseOut = handleMouseOut;
+  }
+
+  private function handleMouseOver(event:MouseEvent)
+  {
+    Cursor.cursorMode = Pointer;
+  }
+
+  private function handleMouseOut(event:MouseEvent)
+  {
+    Cursor.cursorMode = Default;
+  }
+}
diff --git a/source/funkin/ui/haxeui/components/FunkinLink.hx b/source/funkin/ui/haxeui/components/FunkinLink.hx
new file mode 100644
index 000000000..74eb6e7c4
--- /dev/null
+++ b/source/funkin/ui/haxeui/components/FunkinLink.hx
@@ -0,0 +1,30 @@
+package funkin.ui.haxeui.components;
+
+import funkin.input.Cursor;
+import haxe.ui.events.MouseEvent;
+import haxe.ui.components.Link;
+
+/**
+ * A HaxeUI link which:
+ * - Changes the current cursor when hovered over.
+ */
+class FunkinLink extends Link
+{
+  public function new()
+  {
+    super();
+
+    this.onMouseOver = handleMouseOver;
+    this.onMouseOut = handleMouseOut;
+  }
+
+  private function handleMouseOver(event:MouseEvent)
+  {
+    Cursor.cursorMode = Pointer;
+  }
+
+  private function handleMouseOut(event:MouseEvent)
+  {
+    Cursor.cursorMode = Default;
+  }
+}
diff --git a/source/funkin/ui/haxeui/components/FunkinMenuBar.hx b/source/funkin/ui/haxeui/components/FunkinMenuBar.hx
new file mode 100644
index 000000000..393372d74
--- /dev/null
+++ b/source/funkin/ui/haxeui/components/FunkinMenuBar.hx
@@ -0,0 +1,32 @@
+package funkin.ui.haxeui.components;
+
+import funkin.input.Cursor;
+import haxe.ui.events.MouseEvent;
+import haxe.ui.containers.menus.MenuBar;
+import haxe.ui.core.CompositeBuilder;
+
+/**
+ * A HaxeUI menu bar which:
+ * - Changes the current cursor when each button is hovered over.
+ */
+class FunkinMenuBar extends MenuBar
+{
+  public function new()
+  {
+    super();
+
+    registerListeners();
+  }
+
+  private function registerListeners():Void {}
+
+  private function handleMouseOver(event:MouseEvent)
+  {
+    Cursor.cursorMode = Pointer;
+  }
+
+  private function handleMouseOut(event:MouseEvent)
+  {
+    Cursor.cursorMode = Default;
+  }
+}
diff --git a/source/funkin/ui/haxeui/components/FunkinMenuCheckBox.hx b/source/funkin/ui/haxeui/components/FunkinMenuCheckBox.hx
new file mode 100644
index 000000000..263277c6f
--- /dev/null
+++ b/source/funkin/ui/haxeui/components/FunkinMenuCheckBox.hx
@@ -0,0 +1,30 @@
+package funkin.ui.haxeui.components;
+
+import funkin.input.Cursor;
+import haxe.ui.events.MouseEvent;
+import haxe.ui.containers.menus.MenuCheckBox;
+
+/**
+ * A HaxeUI menu checkbox which:
+ * - Changes the current cursor when hovered over.
+ */
+class FunkinMenuCheckBox extends MenuCheckBox
+{
+  public function new()
+  {
+    super();
+
+    this.onMouseOver = handleMouseOver;
+    this.onMouseOut = handleMouseOut;
+  }
+
+  private function handleMouseOver(event:MouseEvent)
+  {
+    Cursor.cursorMode = Pointer;
+  }
+
+  private function handleMouseOut(event:MouseEvent)
+  {
+    Cursor.cursorMode = Default;
+  }
+}
diff --git a/source/funkin/ui/haxeui/components/FunkinMenuItem.hx b/source/funkin/ui/haxeui/components/FunkinMenuItem.hx
new file mode 100644
index 000000000..2eb7db729
--- /dev/null
+++ b/source/funkin/ui/haxeui/components/FunkinMenuItem.hx
@@ -0,0 +1,30 @@
+package funkin.ui.haxeui.components;
+
+import funkin.input.Cursor;
+import haxe.ui.events.MouseEvent;
+import haxe.ui.containers.menus.MenuItem;
+
+/**
+ * A HaxeUI menu item which:
+ * - Changes the current cursor when hovered over.
+ */
+class FunkinMenuItem extends MenuItem
+{
+  public function new()
+  {
+    super();
+
+    this.onMouseOver = handleMouseOver;
+    this.onMouseOut = handleMouseOut;
+  }
+
+  private function handleMouseOver(event:MouseEvent)
+  {
+    Cursor.cursorMode = Pointer;
+  }
+
+  private function handleMouseOut(event:MouseEvent)
+  {
+    Cursor.cursorMode = Default;
+  }
+}
diff --git a/source/funkin/ui/haxeui/components/FunkinMenuOptionBox.hx b/source/funkin/ui/haxeui/components/FunkinMenuOptionBox.hx
new file mode 100644
index 000000000..d9985eede
--- /dev/null
+++ b/source/funkin/ui/haxeui/components/FunkinMenuOptionBox.hx
@@ -0,0 +1,30 @@
+package funkin.ui.haxeui.components;
+
+import haxe.ui.containers.menus.MenuOptionBox;
+import funkin.input.Cursor;
+import haxe.ui.events.MouseEvent;
+
+/**
+ * A HaxeUI menu option box which:
+ * - Changes the current cursor when hovered over.
+ */
+class FunkinMenuOptionBox extends MenuOptionBox
+{
+  public function new()
+  {
+    super();
+
+    this.onMouseOver = handleMouseOver;
+    this.onMouseOut = handleMouseOut;
+  }
+
+  private function handleMouseOver(event:MouseEvent)
+  {
+    Cursor.cursorMode = Pointer;
+  }
+
+  private function handleMouseOut(event:MouseEvent)
+  {
+    Cursor.cursorMode = Default;
+  }
+}
diff --git a/source/funkin/ui/story/Level.hx b/source/funkin/ui/story/Level.hx
index 1e5d60bf3..764606bf3 100644
--- a/source/funkin/ui/story/Level.hx
+++ b/source/funkin/ui/story/Level.hx
@@ -98,19 +98,40 @@ class Level implements IRegistryEntry<LevelData>
     return true;
   }
 
+  /**
+   * Build a sprite for the background of the level.
+   * Can be overriden by ScriptedLevel. Not used if `isBackgroundSimple` returns true.
+   */
   public function buildBackground():FlxSprite
   {
-    if (_data.background.startsWith('#'))
-    {
-      // Color specified
-      var color:FlxColor = FlxColor.fromString(_data.background);
-      return new FlxSprite().makeGraphic(FlxG.width, 400, color);
-    }
-    else
+    if (!_data.background.startsWith('#'))
     {
       // Image specified
       return new FlxSprite().loadGraphic(Paths.image(_data.background));
     }
+
+    // Color specified
+    var result:FlxSprite = new FlxSprite().makeGraphic(FlxG.width, 400, FlxColor.WHITE);
+    result.color = getBackgroundColor();
+    return result;
+  }
+
+  /**
+   * Returns true if the background is a solid color.
+   * If you have a ScriptedLevel with a fancy background, you may want to override this to false.
+   */
+  public function isBackgroundSimple():Bool
+  {
+    return _data.background.startsWith('#');
+  }
+
+  /**
+   * Returns true if the background is a solid color.
+   * If you have a ScriptedLevel with a fancy background, you may want to override this to false.
+   */
+  public function getBackgroundColor():FlxColor
+  {
+    return FlxColor.fromString(_data.background);
   }
 
   public function getDifficulties():Array<String>
diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx
index 8276777ab..34dd49e22 100644
--- a/source/funkin/ui/story/StoryMenuState.hx
+++ b/source/funkin/ui/story/StoryMenuState.hx
@@ -135,10 +135,15 @@ class StoryMenuState extends MusicBeatState
     this.bgColor = FlxColor.BLACK;
 
     levelTitles = new FlxTypedGroup<LevelTitle>();
+    levelTitles.zIndex = 15;
     add(levelTitles);
 
     updateBackground();
 
+    var black:FlxSprite = new FlxSprite(levelBackground.x, 0).makeGraphic(FlxG.width, Std.int(400 + levelBackground.y), FlxColor.BLACK);
+    black.zIndex = levelBackground.zIndex - 1;
+    add(black);
+
     levelProps = new FlxTypedGroup<LevelProp>();
     levelProps.zIndex = 1000;
     add(levelProps);
@@ -153,17 +158,20 @@ class StoryMenuState extends MusicBeatState
 
     scoreText = new FlxText(10, 10, 0, 'HIGH SCORE: 42069420');
     scoreText.setFormat("VCR OSD Mono", 32);
+    scoreText.zIndex = 1000;
     add(scoreText);
 
     modeText = new FlxText(10, 10, 0, 'Base Game Levels [TAB to switch]');
     modeText.setFormat("VCR OSD Mono", 32);
     modeText.screenCenter(X);
     modeText.visible = hasModdedLevels();
+    modeText.zIndex = 1000;
     add(modeText);
 
     levelTitleText = new FlxText(FlxG.width * 0.7, 10, 0, 'LEVEL 1');
     levelTitleText.setFormat("VCR OSD Mono", 32, FlxColor.WHITE, RIGHT);
     levelTitleText.alpha = 0.7;
+    levelTitleText.zIndex = 1000;
     add(levelTitleText);
 
     buildLevelTitles();
@@ -384,6 +392,7 @@ class StoryMenuState extends MusicBeatState
     if (currentIndex < 0) currentIndex = levelList.length - 1;
     if (currentIndex >= levelList.length) currentIndex = 0;
 
+    var previousLevelId:String = currentLevelId;
     currentLevelId = levelList[currentIndex];
 
     updateData();
@@ -399,18 +408,14 @@ class StoryMenuState extends MusicBeatState
         currentLevelTitle = item;
         item.alpha = 1.0;
       }
-      else if (index > currentIndex)
-      {
-        item.alpha = 0.6;
-      }
       else
       {
-        item.alpha = 0.0;
+        item.alpha = 0.6;
       }
     }
 
     updateText();
-    updateBackground();
+    updateBackground(previousLevelId);
     updateProps();
     refresh();
   }
@@ -533,32 +538,66 @@ class StoryMenuState extends MusicBeatState
     });
   }
 
-  function updateBackground():Void
+  function updateBackground(?previousLevelId:String = ''):Void
   {
-    if (levelBackground != null)
+    if (levelBackground == null || previousLevelId == '')
     {
-      var oldBackground:FlxSprite = levelBackground;
-
-      FlxTween.tween(oldBackground, {alpha: 0.0}, 0.6,
-        {
-          ease: FlxEase.linear,
-          onComplete: function(_) {
-            remove(oldBackground);
-          }
-        });
+      // Build a new background and display it immediately.
+      levelBackground = currentLevel.buildBackground();
+      levelBackground.x = 0;
+      levelBackground.y = 56;
+      levelBackground.zIndex = 100;
+      levelBackground.alpha = 1.0; // Not hidden.
+      add(levelBackground);
     }
+    else
+    {
+      var previousLevel = LevelRegistry.instance.fetchEntry(previousLevelId);
 
-    levelBackground = currentLevel.buildBackground();
-    levelBackground.x = 0;
-    levelBackground.y = 56;
-    levelBackground.alpha = 0.0;
-    levelBackground.zIndex = 100;
-    add(levelBackground);
-
-    FlxTween.tween(levelBackground, {alpha: 1.0}, 0.6,
+      if (currentLevel.isBackgroundSimple() && previousLevel.isBackgroundSimple())
       {
-        ease: FlxEase.linear
-      });
+        var previousColor:FlxColor = previousLevel.getBackgroundColor();
+        var currentColor:FlxColor = currentLevel.getBackgroundColor();
+        if (previousColor != currentColor)
+        {
+          // Both the previous and current level were simple backgrounds.
+          // Fade between colors directly, rather than fading one background out and another in.
+          FlxTween.color(levelBackground, 0.4, previousColor, currentColor);
+        }
+        else
+        {
+          // Do no fade at all if the colors aren't different.
+        }
+      }
+      else
+      {
+        // Either the previous or current level has a complex background.
+        // We need to fade the old background out and the new one in.
+
+        // Reference the old background and fade it out.
+        var oldBackground:FlxSprite = levelBackground;
+        FlxTween.tween(oldBackground, {alpha: 0.0}, 0.6,
+          {
+            ease: FlxEase.linear,
+            onComplete: function(_) {
+              remove(oldBackground);
+            }
+          });
+
+        // Build a new background and fade it in.
+        levelBackground = currentLevel.buildBackground();
+        levelBackground.x = 0;
+        levelBackground.y = 56;
+        levelBackground.alpha = 0.0; // Hidden to start.
+        levelBackground.zIndex = 100;
+        add(levelBackground);
+
+        FlxTween.tween(levelBackground, {alpha: 1.0}, 0.6,
+          {
+            ease: FlxEase.linear
+          });
+      }
+    }
   }
 
   function updateProps():Void
diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index 71cfe7b8e..b454ca429 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -20,7 +20,7 @@ class Constants
    * The current version number of the game.
    * Modify this in the `project.xml` file.
    */
-  public static var VERSION(get, null):String;
+  public static var VERSION(get, never):String;
 
   /**
    * A suffix to add to the game version.
diff --git a/source/funkin/util/FileUtil.hx b/source/funkin/util/FileUtil.hx
index 21c2920d9..3a6f4e330 100644
--- a/source/funkin/util/FileUtil.hx
+++ b/source/funkin/util/FileUtil.hx
@@ -240,6 +240,10 @@ class FileUtil
       onSaveAll(paths);
     }
 
+    trace('Browsing for directory to save individual files to...');
+    #if mac
+    defaultPath = null;
+    #end
     browseForDirectory(null, onSelectDir, onCancel, defaultPath, 'Choose directory to save all files to...');
 
     return true;