From 66085ff8673d1512ce0716a31a1a12f6effc23da Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 12 Mar 2024 21:34:50 -0400
Subject: [PATCH 1/5] Song scripts can now be (optionally) enabled in the Chart
 Editor playtest

---
 source/funkin/data/BaseRegistry.hx            | 87 ++++++++++++++---
 source/funkin/data/song/SongRegistry.hx       |  1 +
 source/funkin/graphics/FunkinSprite.hx        |  9 +-
 source/funkin/play/Countdown.hx               |  2 +-
 source/funkin/play/GitarooPause.hx            |  2 +-
 source/funkin/play/PlayState.hx               |  4 +-
 source/funkin/play/components/PopUpStuff.hx   |  6 +-
 source/funkin/play/song/Song.hx               | 93 +++++++++++++++++--
 source/funkin/play/stage/Stage.hx             |  2 +-
 .../ui/debug/charting/ChartEditorState.hx     | 12 ++-
 .../handlers/ChartEditorToolboxHandler.hx     | 11 +++
 source/funkin/ui/freeplay/FreeplayState.hx    |  2 +-
 source/funkin/ui/transition/LoadingState.hx   | 10 +-
 .../funkin/ui/transition/StickerSubState.hx   |  2 +-
 source/funkin/util/tools/StringTools.hx       | 27 +++++-
 15 files changed, 224 insertions(+), 46 deletions(-)

diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx
index 2df0c18f0..ad028fa94 100644
--- a/source/funkin/data/BaseRegistry.hx
+++ b/source/funkin/data/BaseRegistry.hx
@@ -1,6 +1,5 @@
 package funkin.data;
 
-import openfl.Assets;
 import funkin.util.assets.DataAssets;
 import funkin.util.VersionUtil;
 import haxe.Constraints.Constructible;
@@ -19,12 +18,23 @@ typedef EntryConstructorFunction = String->Void;
 @:generic
 abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructorFunction>), J>
 {
+  /**
+   * The ID of the registry. Used when logging.
+   */
   public final registryId:String;
 
   final dataFilePath:String;
 
+  /**
+   * A map of entry IDs to entries.
+   */
   final entries:Map<String, T>;
 
+  /**
+   * A map of entry IDs to scripted class names.
+   */
+  final scriptedEntryIds:Map<String, String>;
+
   /**
    * The version rule to use when loading entries.
    * If the entry's version does not match this rule, migration is needed.
@@ -37,17 +47,18 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
    * @param registryId A readable ID for this registry, used when logging.
    * @param dataFilePath The path (relative to `assets/data`) to search for JSON files.
    */
-  public function new(registryId:String, dataFilePath:String, versionRule:thx.semver.VersionRule = null)
+  public function new(registryId:String, dataFilePath:String, ?versionRule:thx.semver.VersionRule)
   {
     this.registryId = registryId;
     this.dataFilePath = dataFilePath;
-    this.versionRule = versionRule == null ? "1.0.x" : versionRule;
+    this.versionRule = versionRule == null ? '1.0.x' : versionRule;
 
     this.entries = new Map<String, T>();
+    this.scriptedEntryIds = [];
   }
 
   /**
-   * TODO: Create a `loadEntriesAsync()` function.
+   * TODO: Create a `loadEntriesAsync(onProgress, onComplete)` function.
    */
   public function loadEntries():Void
   {
@@ -66,7 +77,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
       {
         entry = createScriptedEntry(entryCls);
       }
-      catch (e:Dynamic)
+      catch (e)
       {
         log('Failed to create scripted entry (${entryCls})');
         continue;
@@ -76,6 +87,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
       {
         log('Successfully created scripted entry (${entryCls} = ${entry.id})');
         entries.set(entry.id, entry);
+        scriptedEntryIds.set(entry.id, entryCls);
       }
       else
       {
@@ -102,7 +114,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
           entries.set(entry.id, entry);
         }
       }
-      catch (e:Dynamic)
+      catch (e)
       {
         // Print the error.
         trace('  Failed to load entry data: ${entryId}');
@@ -130,6 +142,36 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
     return entries.size();
   }
 
+  /**
+   * Return whether the entry ID is known to have an attached script.
+   * @param id The ID of the entry.
+   * @return `true` if the entry has an attached script, `false` otherwise.
+   */
+  public function isScriptedEntry(id:String):Bool
+  {
+    return scriptedEntryIds.exists(id);
+  }
+
+  /**
+   * Return the class name of the scripted entry with the given ID, if it exists.
+   * @param id The ID of the entry.
+   * @return The class name, or `null` if it does not exist.
+   */
+  public function getScriptedEntryClassName(id:String):String
+  {
+    return scriptedEntryIds.get(id);
+  }
+
+  /**
+   * Return whether the registry has successfully parsed an entry with the given ID.
+   * @param id The ID of the entry.
+   * @return `true` if the entry exists, `false` otherwise.
+   */
+  public function hasEntry(id:String):Bool
+  {
+    return entries.exists(id);
+  }
+
   /**
    * Fetch an entry by its ID.
    * @param id The ID of the entry to fetch.
@@ -145,6 +187,11 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
     return 'Registry(' + registryId + ', ${countEntries()} entries)';
   }
 
+  /**
+   * Retrieve the data for an entry and parse its Semantic Version.
+   * @param id The ID of the entry.
+   * @return The entry's version, or `null` if it does not exist or is invalid.
+   */
   public function fetchEntryVersion(id:String):Null<thx.semver.Version>
   {
     var entryStr:String = loadEntryFile(id).contents;
@@ -185,6 +232,8 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
    * Read, parse, and validate the JSON data and produce the corresponding data object.
    *
    * NOTE: Must be implemented on the implementation class.
+   * @param id The ID of the entry.
+   * @return The created entry.
    */
   public abstract function parseEntryData(id:String):Null<J>;
 
@@ -194,6 +243,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
    * NOTE: Must be implemented on the implementation class.
    * @param contents The JSON as a string.
    * @param fileName An optional file name for error reporting.
+   * @return The created entry.
    */
   public abstract function parseEntryDataRaw(contents:String, ?fileName:String):Null<J>;
 
@@ -202,6 +252,9 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
    * accounting for old versions of the data.
    *
    * NOTE: Extend this function to handle migration.
+   * @param id The ID of the entry.
+   * @param version The entry's version (use `fetchEntryVersion(id)`).
+   * @return The created entry.
    */
   public function parseEntryDataWithMigration(id:String, version:thx.semver.Version):Null<J>
   {
@@ -220,12 +273,17 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
       throw '[${registryId}] Entry ${id} does not support migration to version ${versionRule}.';
     }
 
-    // Example:
-    // if (VersionUtil.validateVersion(version, "0.1.x")) {
-    //   return parseEntryData_v0_1_x(id);
-    // } else {
-    //   super.parseEntryDataWithMigration(id, version);
-    // }
+    /*
+     * An example of what you should override this with:
+     *
+     * ```haxe
+     * if (VersionUtil.validateVersion(version, "0.1.x")) {
+     *   return parseEntryData_v0_1_x(id);
+     * } else {
+     *   super.parseEntryDataWithMigration(id, version);
+     * }
+     * ```
+     */
   }
 
   /**
@@ -255,10 +313,15 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
     trace('[${registryId}] Failed to parse entry data: ${id}');
 
     for (error in errors)
+    {
       DataError.printError(error);
+    }
   }
 }
 
+/**
+ * A pair of a file name and its contents.
+ */
 typedef JsonFile =
 {
   fileName:String,
diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx
index dad287e82..9f811d45e 100644
--- a/source/funkin/data/song/SongRegistry.hx
+++ b/source/funkin/data/song/SongRegistry.hx
@@ -68,6 +68,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
       {
         log('Successfully created scripted entry (${entryCls} = ${entry.id})');
         entries.set(entry.id, entry);
+        scriptedEntryIds.set(entry.id, entryCls);
       }
       else
       {
diff --git a/source/funkin/graphics/FunkinSprite.hx b/source/funkin/graphics/FunkinSprite.hx
index f47b4138a..03382f757 100644
--- a/source/funkin/graphics/FunkinSprite.hx
+++ b/source/funkin/graphics/FunkinSprite.hx
@@ -81,9 +81,10 @@ class FunkinSprite extends FlxSprite
    */
   public function loadTexture(key:String):FunkinSprite
   {
-    if (!isTextureCached(key)) FlxG.log.warn('Texture not cached, may experience stuttering! $key');
+    var graphicKey:String = Paths.image(key);
+    if (!isTextureCached(graphicKey)) FlxG.log.warn('Texture not cached, may experience stuttering! $graphicKey');
 
-    loadGraphic(key);
+    loadGraphic(graphicKey);
 
     return this;
   }
@@ -95,7 +96,7 @@ class FunkinSprite extends FlxSprite
    */
   public function loadSparrow(key:String):FunkinSprite
   {
-    var graphicKey = Paths.image(key);
+    var graphicKey:String = Paths.image(key);
     if (!isTextureCached(graphicKey)) FlxG.log.warn('Texture not cached, may experience stuttering! $graphicKey');
 
     this.frames = Paths.getSparrowAtlas(key);
@@ -110,7 +111,7 @@ class FunkinSprite extends FlxSprite
    */
   public function loadPacker(key:String):FunkinSprite
   {
-    var graphicKey = Paths.image(key);
+    var graphicKey:String = Paths.image(key);
     if (!isTextureCached(graphicKey)) FlxG.log.warn('Texture not cached, may experience stuttering! $graphicKey');
 
     this.frames = Paths.getPackerAtlas(key);
diff --git a/source/funkin/play/Countdown.hx b/source/funkin/play/Countdown.hx
index 38e8986ef..747565100 100644
--- a/source/funkin/play/Countdown.hx
+++ b/source/funkin/play/Countdown.hx
@@ -215,7 +215,7 @@ class Countdown
 
     if (spritePath == null) return;
 
-    var countdownSprite:FunkinSprite = FunkinSprite.create(Paths.image(spritePath));
+    var countdownSprite:FunkinSprite = FunkinSprite.create(spritePath);
     countdownSprite.scrollFactor.set(0, 0);
 
     if (isPixelStyle) countdownSprite.setGraphicSize(Std.int(countdownSprite.width * Constants.PIXEL_ART_SCALE));
diff --git a/source/funkin/play/GitarooPause.hx b/source/funkin/play/GitarooPause.hx
index 1ed9dcf3b..eae56a9c3 100644
--- a/source/funkin/play/GitarooPause.hx
+++ b/source/funkin/play/GitarooPause.hx
@@ -28,7 +28,7 @@ class GitarooPause extends MusicBeatState
   {
     if (FlxG.sound.music != null) FlxG.sound.music.stop();
 
-    var bg:FunkinSprite = FunkinSprite.create(Paths.image('pauseAlt/pauseBG'));
+    var bg:FunkinSprite = FunkinSprite.create('pauseAlt/pauseBG');
     add(bg);
 
     var bf:FunkinSprite = FunkinSprite.createSparrow(0, 30, 'pauseAlt/bfLol');
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index a6e4b4632..55c54e0fb 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1416,7 +1416,7 @@ class PlayState extends MusicBeatSubState
   function initHealthBar():Void
   {
     var healthBarYPos:Float = Preferences.downscroll ? FlxG.height * 0.1 : FlxG.height * 0.9;
-    healthBarBG = FunkinSprite.create(0, healthBarYPos, Paths.image('healthBar'));
+    healthBarBG = FunkinSprite.create(0, healthBarYPos, 'healthBar');
     healthBarBG.screenCenter(X);
     healthBarBG.scrollFactor.set(0, 0);
     healthBarBG.zIndex = 800;
@@ -1453,7 +1453,7 @@ class PlayState extends MusicBeatSubState
   function initMinimalMode():Void
   {
     // Create the green background.
-    var menuBG = FunkinSprite.create(Paths.image('menuDesat'));
+    var menuBG = FunkinSprite.create('menuDesat');
     menuBG.color = 0xFF4CAF50;
     menuBG.setGraphicSize(Std.int(menuBG.width * 1.1));
     menuBG.updateHitbox();
diff --git a/source/funkin/play/components/PopUpStuff.hx b/source/funkin/play/components/PopUpStuff.hx
index 0fe50f513..105fce2b8 100644
--- a/source/funkin/play/components/PopUpStuff.hx
+++ b/source/funkin/play/components/PopUpStuff.hx
@@ -25,7 +25,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
 
     if (PlayState.instance.currentStageId.startsWith('school')) ratingPath = "weeb/pixelUI/" + ratingPath + "-pixel";
 
-    var rating:FunkinSprite = FunkinSprite.create(0, 0, Paths.image(ratingPath));
+    var rating:FunkinSprite = FunkinSprite.create(0, 0, ratingPath);
     rating.scrollFactor.set(0.2, 0.2);
 
     rating.zIndex = 1000;
@@ -76,7 +76,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
       pixelShitPart1 = 'weeb/pixelUI/';
       pixelShitPart2 = '-pixel';
     }
-    var comboSpr:FunkinSprite = FunkinSprite.create(Paths.image(pixelShitPart1 + 'combo' + pixelShitPart2));
+    var comboSpr:FunkinSprite = FunkinSprite.create(pixelShitPart1 + 'combo' + pixelShitPart2);
     comboSpr.y = FlxG.camera.height * 0.4 + 80;
     comboSpr.x = FlxG.width * 0.50;
     // comboSpr.x -= FlxG.camera.scroll.x * 0.2;
@@ -124,7 +124,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
     var daLoop:Int = 1;
     for (i in seperatedScore)
     {
-      var numScore:FunkinSprite = FunkinSprite.create(0, comboSpr.y, Paths.image(pixelShitPart1 + 'num' + Std.int(i) + pixelShitPart2));
+      var numScore:FunkinSprite = FunkinSprite.create(0, comboSpr.y, pixelShitPart1 + 'num' + Std.int(i) + pixelShitPart2);
 
       if (PlayState.instance.currentStageId.startsWith('school'))
       {
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index 3997692c2..1b7740408 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -1,6 +1,5 @@
 package funkin.play.song;
 
-import flixel.sound.FlxSound;
 import funkin.audio.VoicesGroup;
 import funkin.audio.FunkinSound;
 import funkin.data.IRegistryEntry;
@@ -13,9 +12,8 @@ import funkin.data.song.SongData.SongOffsets;
 import funkin.data.song.SongData.SongTimeChange;
 import funkin.data.song.SongData.SongTimeFormat;
 import funkin.data.song.SongRegistry;
-import funkin.data.song.SongRegistry;
+import funkin.modding.IScriptedClass.IPlayStateScriptedClass;
 import funkin.modding.events.ScriptEvent;
-import funkin.modding.IScriptedClass;
 import funkin.util.SortUtil;
 import openfl.utils.Assets;
 
@@ -31,14 +29,44 @@ import openfl.utils.Assets;
 @:nullSafety
 class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMetadata>
 {
-  public static final DEFAULT_SONGNAME:String = "Unknown";
-  public static final DEFAULT_ARTIST:String = "Unknown";
+  /**
+   * The default value for the song's name
+   */
+  public static final DEFAULT_SONGNAME:String = 'Unknown';
+
+  /**
+   * The default value for the song's artist
+   */
+  public static final DEFAULT_ARTIST:String = 'Unknown';
+
+  /**
+   * The default value for the song's time format
+   */
   public static final DEFAULT_TIMEFORMAT:SongTimeFormat = SongTimeFormat.MILLISECONDS;
+
+  /**
+   * The default value for the song's divisions
+   */
   public static final DEFAULT_DIVISIONS:Null<Int> = null;
+
+  /**
+   * The default value for whether the song loops.
+   */
   public static final DEFAULT_LOOPED:Bool = false;
-  public static final DEFAULT_STAGE:String = "mainStage";
+
+  /**
+   * The default value for the song's playable stage.
+   */
+  public static final DEFAULT_STAGE:String = 'mainStage';
+
+  /**
+   * The default value for the song's scroll speed.
+   */
   public static final DEFAULT_SCROLLSPEED:Float = 1.0;
 
+  /**
+   * The internal ID of the song.
+   */
   public final id:String;
 
   /**
@@ -53,6 +81,9 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
   final _metadata:Map<String, SongMetadata>;
   final difficulties:Map<String, SongDifficulty>;
 
+  /**
+   * The list of variations a song has.
+   */
   public var variations(get, never):Array<String>;
 
   function get_variations():Array<String>
@@ -65,6 +96,9 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
    */
   public var validScore:Bool = true;
 
+  /**
+   * The readable name of the song.
+   */
   public var songName(get, never):String;
 
   function get_songName():String
@@ -74,6 +108,9 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
     return DEFAULT_SONGNAME;
   }
 
+  /**
+   * The artist of the song.
+   */
   public var songArtist(get, never):String;
 
   function get_songArtist():String
@@ -101,7 +138,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
     {
       for (vari in _data.playData.songVariations)
       {
-        var variMeta = fetchVariationMetadata(id, vari);
+        var variMeta:Null<SongMetadata> = fetchVariationMetadata(id, vari);
         if (variMeta != null) _metadata.set(variMeta.variation, variMeta);
       }
     }
@@ -115,27 +152,62 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
     populateDifficulties();
   }
 
-  @:allow(funkin.play.song.Song)
+  /**
+   * Build a song from existing metadata rather than loading it from the `assets` folder.
+   * Used by the Chart Editor.
+   *
+   * @param songId The ID of the song.
+   * @param metadata The metadata of the song.
+   * @param variations The list of variations this song has.
+   * @param charts The chart data for each variation.
+   * @param includeScript Whether to initialize the scripted class tied to the song, if it exists.
+   * @param validScore Whether the song is elegible for highscores.
+   * @return The constructed song object.
+   */
   public static function buildRaw(songId:String, metadata:Array<SongMetadata>, variations:Array<String>, charts:Map<String, SongChartData>,
-      validScore:Bool = false):Song
+      includeScript:Bool = true, validScore:Bool = false):Song
   {
-    var result:Song = new Song(songId);
+    @:privateAccess
+    var result:Null<Song>;
+
+    if (includeScript && SongRegistry.instance.isScriptedEntry(songId))
+    {
+      var songClassName:String = SongRegistry.instance.getScriptedEntryClassName(songId);
+
+      @:privateAccess
+      result = SongRegistry.instance.createScriptedEntry(songClassName);
+    }
+    else
+    {
+      @:privateAccess
+      result = SongRegistry.instance.createEntry(songId);
+    }
+
+    if (result == null) throw 'ERROR: Could not build Song instance ($songId), is the attached script bad?';
 
     result._metadata.clear();
     for (meta in metadata)
+    {
       result._metadata.set(meta.variation, meta);
+    }
 
     result.difficulties.clear();
     result.populateDifficulties();
 
     for (variation => chartData in charts)
+    {
       result.applyChartData(chartData, variation);
+    }
 
     result.validScore = validScore;
 
     return result;
   }
 
+  /**
+   * Retrieve a list of the raw metadata for the song.
+   * @return The metadata JSON objects for the song's variations.
+   */
   public function getRawMetadata():Array<SongMetadata>
   {
     return _metadata.values();
@@ -192,6 +264,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
 
   /**
    * Parse and cache the chart for all difficulties of this song.
+   * @param force Whether to forcibly clear the list of charts first.
    */
   public function cacheCharts(force:Bool = false):Void
   {
diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx
index 9605c6989..56026469a 100644
--- a/source/funkin/play/stage/Stage.hx
+++ b/source/funkin/play/stage/Stage.hx
@@ -212,7 +212,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
       else
       {
         // Initalize static sprite.
-        propSprite.loadTexture(Paths.image(dataProp.assetPath));
+        propSprite.loadTexture(dataProp.assetPath);
 
         // Disables calls to update() for a performance boost.
         propSprite.active = false;
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 942f28297..1eb2a0b02 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -602,6 +602,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   var enabledDebuggerPopup:Bool = true;
 
+  /**
+   * Whether song scripts should be enabled during playtesting.
+   * You should probably check the box if the song has custom mechanics.
+   */
+  var playtestSongScripts:Bool = true;
+
   // Visuals
 
   /**
@@ -1396,7 +1402,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
   function get_currentSongId():String
   {
-    return currentSongName.toLowerKebabCase().replace('.', '').replace(' ', '-');
+    return currentSongName.toLowerKebabCase().replace(' ', '-').sanitize();
   }
 
   var currentSongArtist(get, set):String;
@@ -5320,7 +5326,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     var targetSong:Song;
     try
     {
-      targetSong = Song.buildRaw(currentSongId, songMetadata.values(), availableVariations, songChartData, false);
+      targetSong = Song.buildRaw(currentSongId, songMetadata.values(), availableVariations, songChartData, playtestSongScripts, false);
     }
     catch (e)
     {
@@ -5348,7 +5354,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         Paths.setCurrentLevel('week6');
       case 'tankmanBattlefield':
         Paths.setCurrentLevel('week7');
-      case 'phillyStreets' | 'phillyBlazin':
+      case 'phillyStreets' | 'phillyBlazin' | 'phillyBlazin2':
         Paths.setCurrentLevel('weekend1');
     }
 
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
index 3b32edf5d..8c7b1a8c1 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
@@ -318,6 +318,17 @@ class ChartEditorToolboxHandler
       state.enabledDebuggerPopup = checkboxDebugger.selected;
     };
 
+    var checkboxSongScripts:Null<CheckBox> = toolbox.findComponent('playtestSongScriptsCheckbox', CheckBox);
+
+    if (checkboxSongScripts == null)
+      throw 'ChartEditorToolboxHandler.buildToolboxPlaytestPropertiesLayout() - Could not find playtestSongScriptsCheckbox component.';
+
+    state.playtestSongScripts = checkboxSongScripts.selected;
+
+    checkboxSongScripts.onClick = _ -> {
+      state.playtestSongScripts = checkboxSongScripts.selected;
+    };
+
     return toolbox;
   }
 
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 45f9a4d27..24008b19d 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -227,7 +227,7 @@ class FreeplayState extends MusicBeatSubState
     trace(FlxG.camera.initialZoom);
     trace(FlxCamera.defaultZoom);
 
-    var pinkBack:FunkinSprite = FunkinSprite.create(Paths.image('freeplay/pinkBack'));
+    var pinkBack:FunkinSprite = FunkinSprite.create('freeplay/pinkBack');
     pinkBack.color = 0xFFffd4e9; // sets it to pink!
     pinkBack.x -= pinkBack.width;
 
diff --git a/source/funkin/ui/transition/LoadingState.hx b/source/funkin/ui/transition/LoadingState.hx
index 5f755872f..e2f89a8b3 100644
--- a/source/funkin/ui/transition/LoadingState.hx
+++ b/source/funkin/ui/transition/LoadingState.hx
@@ -48,7 +48,7 @@ class LoadingState extends MusicBeatState
     var bg:FunkinSprite = new FunkinSprite().makeSolidColor(FlxG.width, FlxG.height, 0xFFcaff4d);
     add(bg);
 
-    funkay = FunkinSprite.create(Paths.image('funkay'));
+    funkay = FunkinSprite.create('funkay');
     funkay.setGraphicSize(0, FlxG.height);
     funkay.updateHitbox();
     add(funkay);
@@ -389,9 +389,15 @@ class MultiCallback
   public function getUnfired():Array<Void->Void>
     return unfired.array();
 
+  /**
+   * Perform an FlxG.switchState with a nice transition
+   * @param state
+   * @param transitionTex
+   * @param time
+   */
   public static function coolSwitchState(state:NextState, transitionTex:String = "shaderTransitionStuff/coolDots", time:Float = 2)
   {
-    var screenShit:FunkinSprite = FunkinSprite.create(Paths.image("shaderTransitionStuff/coolDots"));
+    var screenShit:FunkinSprite = FunkinSprite.create('shaderTransitionStuff/coolDots');
     var screenWipeShit:ScreenWipeShader = new ScreenWipeShader();
 
     screenWipeShit.funnyShit.input = screenShit.pixels;
diff --git a/source/funkin/ui/transition/StickerSubState.hx b/source/funkin/ui/transition/StickerSubState.hx
index 40fce6f7d..981a30e09 100644
--- a/source/funkin/ui/transition/StickerSubState.hx
+++ b/source/funkin/ui/transition/StickerSubState.hx
@@ -313,7 +313,7 @@ class StickerSprite extends FunkinSprite
   public function new(x:Float, y:Float, stickerSet:String, stickerName:String):Void
   {
     super(x, y);
-    loadTexture(Paths.image('transitionSwag/' + stickerSet + '/' + stickerName));
+    loadTexture('transitionSwag/' + stickerSet + '/' + stickerName);
     updateHitbox();
     scrollFactor.set();
   }
diff --git a/source/funkin/util/tools/StringTools.hx b/source/funkin/util/tools/StringTools.hx
index 0585ffeae..b15808d00 100644
--- a/source/funkin/util/tools/StringTools.hx
+++ b/source/funkin/util/tools/StringTools.hx
@@ -13,15 +13,15 @@ class StringTools
    */
   public static function toTitleCase(value:String):String
   {
-    var words:Array<String> = value.split(" ");
-    var result:String = "";
+    var words:Array<String> = value.split(' ');
+    var result:String = '';
     for (i in 0...words.length)
     {
       var word:String = words[i];
       result += word.charAt(0).toUpperCase() + word.substr(1).toLowerCase();
       if (i < words.length - 1)
       {
-        result += " ";
+        result += ' ';
       }
     }
     return result;
@@ -35,7 +35,7 @@ class StringTools
    */
   public static function toLowerKebabCase(value:String):String
   {
-    return value.toLowerCase().replace(' ', "-");
+    return value.toLowerCase().replace(' ', '-');
   }
 
   /**
@@ -46,13 +46,30 @@ class StringTools
    */
   public static function toUpperKebabCase(value:String):String
   {
-    return value.toUpperCase().replace(' ', "-");
+    return value.toUpperCase().replace(' ', '-');
+  }
+
+  /**
+   * The regular expression to sanitize strings.
+   */
+  static final SANTIZE_REGEX:EReg = ~/[^-a-zA-Z0-9]/g;
+
+  /**
+   * Remove all instances of symbols other than alpha-numeric characters (and dashes)from a string.
+   * @param value The string to sanitize.
+   * @return The sanitized string.
+   */
+  public static function sanitize(value:String):String
+  {
+    return SANTIZE_REGEX.replace(value, '');
   }
 
   /**
    * Parses the string data as JSON and returns the resulting object.
    * This is here so you can use `string.parseJSON()` when `using StringTools`.
    *
+   * TODO: Remove this and replace with `json2object`
+   * @param value The
    * @return The parsed object.
    */
   public static function parseJSON(value:String):Dynamic

From 43cf1e71e2785ff95c0de779d6af1032140ecdc4 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 12 Mar 2024 21:35:55 -0400
Subject: [PATCH 2/5] Disable camera events in the minimal playtest.

---
 source/funkin/play/event/FocusCameraSongEvent.hx | 3 +++
 source/funkin/play/event/ZoomCameraSongEvent.hx  | 5 ++++-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/source/funkin/play/event/FocusCameraSongEvent.hx b/source/funkin/play/event/FocusCameraSongEvent.hx
index 847df4a60..4ea6fa8c0 100644
--- a/source/funkin/play/event/FocusCameraSongEvent.hx
+++ b/source/funkin/play/event/FocusCameraSongEvent.hx
@@ -57,6 +57,9 @@ class FocusCameraSongEvent extends SongEvent
     // Does nothing if there is no PlayState camera or stage.
     if (PlayState.instance == null || PlayState.instance.currentStage == null) return;
 
+    // Does nothing if we are minimal mode.
+    if (PlayState.instance.minimalMode) return;
+
     var posX:Null<Float> = data.getFloat('x');
     if (posX == null) posX = 0.0;
     var posY:Null<Float> = data.getFloat('y');
diff --git a/source/funkin/play/event/ZoomCameraSongEvent.hx b/source/funkin/play/event/ZoomCameraSongEvent.hx
index 809130499..3a903a4ff 100644
--- a/source/funkin/play/event/ZoomCameraSongEvent.hx
+++ b/source/funkin/play/event/ZoomCameraSongEvent.hx
@@ -55,7 +55,10 @@ class ZoomCameraSongEvent extends SongEvent
   public override function handleEvent(data:SongEventData):Void
   {
     // Does nothing if there is no PlayState camera or stage.
-    if (PlayState.instance == null) return;
+    if (PlayState.instance == null || PlayState.instance.currentStage == null) return;
+
+    // Does nothing if we are minimal mode.
+    if (PlayState.instance.minimalMode) return;
 
     var zoom:Null<Float> = data.getFloat('zoom');
     if (zoom == null) zoom = 1.0;

From 541c7b40414a191b0464f57e0cea6e4948d122ec Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 12 Mar 2024 21:36:32 -0400
Subject: [PATCH 3/5] Update assets submodule

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

diff --git a/assets b/assets
index fe8c987eb..b71005291 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit fe8c987eb846ceb73b8518879b506111aaccdf80
+Subproject commit b71005291132a09043cabb59511d9316a21039ca

From a6fecd05e05d555b1769a43a7c38b28d9d324e1c Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Wed, 13 Mar 2024 18:58:18 -0700
Subject: [PATCH 4/5] assets submod

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

diff --git a/assets b/assets
index b71005291..b498f7f75 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit b71005291132a09043cabb59511d9316a21039ca
+Subproject commit b498f7f7569af24c25c88836d087f93529c2c6be

From b5bc63fecaf6674f60d744de7cf02e82b0536f4f Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Wed, 13 Mar 2024 21:27:09 -0700
Subject: [PATCH 5/5] use isMinimalMode

---
 source/funkin/play/event/FocusCameraSongEvent.hx | 2 +-
 source/funkin/play/event/ZoomCameraSongEvent.hx  | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/source/funkin/play/event/FocusCameraSongEvent.hx b/source/funkin/play/event/FocusCameraSongEvent.hx
index 4ea6fa8c0..625b9cb7a 100644
--- a/source/funkin/play/event/FocusCameraSongEvent.hx
+++ b/source/funkin/play/event/FocusCameraSongEvent.hx
@@ -58,7 +58,7 @@ class FocusCameraSongEvent extends SongEvent
     if (PlayState.instance == null || PlayState.instance.currentStage == null) return;
 
     // Does nothing if we are minimal mode.
-    if (PlayState.instance.minimalMode) return;
+    if (PlayState.instance.isMinimalMode) return;
 
     var posX:Null<Float> = data.getFloat('x');
     if (posX == null) posX = 0.0;
diff --git a/source/funkin/play/event/ZoomCameraSongEvent.hx b/source/funkin/play/event/ZoomCameraSongEvent.hx
index 3a903a4ff..d1ce97e40 100644
--- a/source/funkin/play/event/ZoomCameraSongEvent.hx
+++ b/source/funkin/play/event/ZoomCameraSongEvent.hx
@@ -58,7 +58,7 @@ class ZoomCameraSongEvent extends SongEvent
     if (PlayState.instance == null || PlayState.instance.currentStage == null) return;
 
     // Does nothing if we are minimal mode.
-    if (PlayState.instance.minimalMode) return;
+    if (PlayState.instance.isMinimalMode) return;
 
     var zoom:Null<Float> = data.getFloat('zoom');
     if (zoom == null) zoom = 1.0;