From 60e741434c6ecd1f15c4e62f4ea364b8648c3538 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 18 Jun 2024 17:56:24 -0400
Subject: [PATCH] Implemented playable character registry, added Freeplay
 character filtering, added alt instrumental support

---
 assets                                        |   2 +-
 source/funkin/InitState.hx                    |   2 +
 .../funkin/data/freeplay/player/CHANGELOG.md  |   9 ++
 .../funkin/data/freeplay/player/PlayerData.hx |  63 ++++++++
 .../data/freeplay/player/PlayerRegistry.hx    | 151 ++++++++++++++++++
 source/funkin/modding/PolymodHandler.hx       |   6 +-
 source/funkin/play/song/Song.hx               |  34 ++--
 .../handlers/ChartEditorDialogHandler.hx      |   7 +-
 source/funkin/ui/freeplay/FreeplayState.hx    |  80 +++++++---
 .../freeplay/charselect/PlayableCharacter.hx  | 108 +++++++++++++
 .../charselect/ScriptedPlayableCharacter.hx   |   8 +
 source/funkin/util/VersionUtil.hx             |   1 -
 12 files changed, 433 insertions(+), 38 deletions(-)
 create mode 100644 source/funkin/data/freeplay/player/CHANGELOG.md
 create mode 100644 source/funkin/data/freeplay/player/PlayerData.hx
 create mode 100644 source/funkin/data/freeplay/player/PlayerRegistry.hx
 create mode 100644 source/funkin/ui/freeplay/charselect/PlayableCharacter.hx
 create mode 100644 source/funkin/ui/freeplay/charselect/ScriptedPlayableCharacter.hx

diff --git a/assets b/assets
index 2e1594ee4..fece99b3b 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 2e1594ee4c04c7148628bae471bdd061c9deb6b7
+Subproject commit fece99b3b121045fb2f6f02dba485201b32f1c87
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index 49b15ddf6..c2a56bdc2 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -1,5 +1,6 @@
 package funkin;
 
+import funkin.data.freeplay.player.PlayerRegistry;
 import funkin.ui.debug.charting.ChartEditorState;
 import funkin.ui.transition.LoadingState;
 import flixel.FlxState;
@@ -164,6 +165,7 @@ class InitState extends FlxState
     SongRegistry.instance.loadEntries();
     LevelRegistry.instance.loadEntries();
     NoteStyleRegistry.instance.loadEntries();
+    PlayerRegistry.instance.loadEntries();
     ConversationRegistry.instance.loadEntries();
     DialogueBoxRegistry.instance.loadEntries();
     SpeakerRegistry.instance.loadEntries();
diff --git a/source/funkin/data/freeplay/player/CHANGELOG.md b/source/funkin/data/freeplay/player/CHANGELOG.md
new file mode 100644
index 000000000..7a31e11ca
--- /dev/null
+++ b/source/funkin/data/freeplay/player/CHANGELOG.md
@@ -0,0 +1,9 @@
+# Freeplay Playable Character Data Schema Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [1.0.0]
+Initial release.
diff --git a/source/funkin/data/freeplay/player/PlayerData.hx b/source/funkin/data/freeplay/player/PlayerData.hx
new file mode 100644
index 000000000..d7b814584
--- /dev/null
+++ b/source/funkin/data/freeplay/player/PlayerData.hx
@@ -0,0 +1,63 @@
+package funkin.data.freeplay.player;
+
+import funkin.data.animation.AnimationData;
+
+@:nullSafety
+class PlayerData
+{
+  /**
+   * The sematic version number of the player data JSON format.
+   * Supports fancy comparisons like NPM does it's neat.
+   */
+  @:default(funkin.data.freeplay.player.PlayerRegistry.PLAYER_DATA_VERSION)
+  public var version:String;
+
+  /**
+   * A readable name for this playable character.
+   */
+  public var name:String = 'Unknown';
+
+  /**
+   * The character IDs this character is associated with.
+   * Only songs that use these characters will show up in Freeplay.
+   */
+  @:default([])
+  public var ownedChars:Array<String> = [];
+
+  /**
+   * Whether to show songs with character IDs that aren't associated with any specific character.
+   */
+  @:optional
+  @:default(false)
+  public var showUnownedChars:Bool = false;
+
+  /**
+   * Whether this character is unlocked by default.
+   * Use a ScriptedPlayableCharacter to add custom logic.
+   */
+  @:optional
+  @:default(true)
+  public var unlocked:Bool = true;
+
+  public function new()
+  {
+    this.version = PlayerRegistry.PLAYER_DATA_VERSION;
+  }
+
+  /**
+   * Convert this StageData into a JSON string.
+   */
+  public function serialize(pretty:Bool = true):String
+  {
+    // Update generatedBy and version before writing.
+    updateVersionToLatest();
+
+    var writer = new json2object.JsonWriter<PlayerData>();
+    return writer.write(this, pretty ? '  ' : null);
+  }
+
+  public function updateVersionToLatest():Void
+  {
+    this.version = PlayerRegistry.PLAYER_DATA_VERSION;
+  }
+}
diff --git a/source/funkin/data/freeplay/player/PlayerRegistry.hx b/source/funkin/data/freeplay/player/PlayerRegistry.hx
new file mode 100644
index 000000000..3de9efd41
--- /dev/null
+++ b/source/funkin/data/freeplay/player/PlayerRegistry.hx
@@ -0,0 +1,151 @@
+package funkin.data.freeplay.player;
+
+import funkin.data.freeplay.player.PlayerData;
+import funkin.ui.freeplay.charselect.PlayableCharacter;
+import funkin.ui.freeplay.charselect.ScriptedPlayableCharacter;
+
+class PlayerRegistry extends BaseRegistry<PlayableCharacter, PlayerData>
+{
+  /**
+   * The current version string for the stage data format.
+   * Handle breaking changes by incrementing this value
+   * and adding migration to the `migratePlayerData()` function.
+   */
+  public static final PLAYER_DATA_VERSION:thx.semver.Version = "1.0.0";
+
+  public static final PLAYER_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x";
+
+  public static var instance(get, never):PlayerRegistry;
+  static var _instance:Null<PlayerRegistry> = null;
+
+  static function get_instance():PlayerRegistry
+  {
+    if (_instance == null) _instance = new PlayerRegistry();
+    return _instance;
+  }
+
+  /**
+   * A mapping between stage character IDs and Freeplay playable character IDs.
+   */
+  var ownedCharacterIds:Map<String, String> = [];
+
+  public function new()
+  {
+    super('PLAYER', 'players', PLAYER_DATA_VERSION_RULE);
+  }
+
+  public override function loadEntries():Void
+  {
+    super.loadEntries();
+
+    for (playerId in listEntryIds())
+    {
+      var player = fetchEntry(playerId);
+      if (player == null) continue;
+
+      var currentPlayerCharIds = player.getOwnedCharacterIds();
+      for (characterId in currentPlayerCharIds)
+      {
+        ownedCharacterIds.set(characterId, playerId);
+      }
+    }
+
+    log('Loaded ${countEntries()} playable characters with ${ownedCharacterIds.size()} associations.');
+  }
+
+  /**
+   * Get the playable character associated with a given stage character.
+   * @param characterId The stage character ID.
+   * @return The playable character.
+   */
+  public function getCharacterOwnerId(characterId:String):String
+  {
+    return ownedCharacterIds[characterId];
+  }
+
+  /**
+   * Return true if the given stage character is associated with a specific playable character.
+   * If so, the level should only appear if that character is selected in Freeplay.
+   * @param characterId The stage character ID.
+   * @return Whether the character is owned by any one character.
+   */
+  public function isCharacterOwned(characterId:String):Bool
+  {
+    return ownedCharacterIds.exists(characterId);
+  }
+
+  /**
+   * Read, parse, and validate the JSON data and produce the corresponding data object.
+   */
+  public function parseEntryData(id:String):Null<PlayerData>
+  {
+    // JsonParser does not take type parameters,
+    // otherwise this function would be in BaseRegistry.
+    var parser = new json2object.JsonParser<PlayerData>();
+    parser.ignoreUnknownVariables = false;
+
+    switch (loadEntryFile(id))
+    {
+      case {fileName: fileName, contents: contents}:
+        parser.fromJson(contents, fileName);
+      default:
+        return null;
+    }
+
+    if (parser.errors.length > 0)
+    {
+      printErrors(parser.errors, id);
+      return null;
+    }
+    return parser.value;
+  }
+
+  /**
+   * Parse and validate the JSON data and produce the corresponding data object.
+   *
+   * NOTE: Must be implemented on the implementation class.
+   * @param contents The JSON as a string.
+   * @param fileName An optional file name for error reporting.
+   */
+  public function parseEntryDataRaw(contents:String, ?fileName:String):Null<PlayerData>
+  {
+    var parser = new json2object.JsonParser<PlayerData>();
+    parser.ignoreUnknownVariables = false;
+    parser.fromJson(contents, fileName);
+
+    if (parser.errors.length > 0)
+    {
+      printErrors(parser.errors, fileName);
+      return null;
+    }
+    return parser.value;
+  }
+
+  function createScriptedEntry(clsName:String):PlayableCharacter
+  {
+    return ScriptedPlayableCharacter.init(clsName, "unknown");
+  }
+
+  function getScriptedClassNames():Array<String>
+  {
+    return ScriptedPlayableCharacter.listScriptClasses();
+  }
+
+  /**
+   * A list of all the playable characters from the base game, in order.
+   */
+  public function listBaseGamePlayerIds():Array<String>
+  {
+    return ["bf", "pico"];
+  }
+
+  /**
+   * A list of all installed playable characters that are not from the base game.
+   */
+  public function listModdedPlayerIds():Array<String>
+  {
+    return listEntryIds().filter(function(id:String):Bool {
+      return listBaseGamePlayerIds().indexOf(id) == -1;
+    });
+  }
+}
diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx
index ae754b780..c352aa606 100644
--- a/source/funkin/modding/PolymodHandler.hx
+++ b/source/funkin/modding/PolymodHandler.hx
@@ -8,6 +8,7 @@ import funkin.data.event.SongEventRegistry;
 import funkin.data.story.level.LevelRegistry;
 import funkin.data.notestyle.NoteStyleRegistry;
 import funkin.data.song.SongRegistry;
+import funkin.data.freeplay.player.PlayerRegistry;
 import funkin.data.stage.StageRegistry;
 import funkin.data.freeplay.album.AlbumRegistry;
 import funkin.modding.module.ModuleHandler;
@@ -369,15 +370,18 @@ class PolymodHandler
 
     // These MUST be imported at the top of the file and not referred to by fully qualified name,
     // to ensure build macros work properly.
+    SongEventRegistry.loadEventCache();
+
     SongRegistry.instance.loadEntries();
     LevelRegistry.instance.loadEntries();
     NoteStyleRegistry.instance.loadEntries();
-    SongEventRegistry.loadEventCache();
+    PlayerRegistry.instance.loadEntries();
     ConversationRegistry.instance.loadEntries();
     DialogueBoxRegistry.instance.loadEntries();
     SpeakerRegistry.instance.loadEntries();
     AlbumRegistry.instance.loadEntries();
     StageRegistry.instance.loadEntries();
+
     CharacterDataParser.loadCharacterCache(); // TODO: Migrate characters to BaseRegistry.
     ModuleHandler.loadModuleCache();
   }
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index dde5ee7b8..91d35d8fa 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -14,6 +14,7 @@ import funkin.data.song.SongData.SongTimeFormat;
 import funkin.data.song.SongRegistry;
 import funkin.modding.IScriptedClass.IPlayStateScriptedClass;
 import funkin.modding.events.ScriptEvent;
+import funkin.ui.freeplay.charselect.PlayableCharacter;
 import funkin.util.SortUtil;
 import openfl.utils.Assets;
 
@@ -401,11 +402,11 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
     return null;
   }
 
-  public function getFirstValidVariation(?diffId:String, ?possibleVariations:Array<String>):Null<String>
+  public function getFirstValidVariation(?diffId:String, ?currentCharacter:PlayableCharacter, ?possibleVariations:Array<String>):Null<String>
   {
     if (possibleVariations == null)
     {
-      possibleVariations = variations;
+      possibleVariations = getVariationsByCharacter(currentCharacter);
       possibleVariations.sort(SortUtil.defaultsThenAlphabetically.bind(Constants.DEFAULT_VARIATION_LIST));
     }
     if (diffId == null) diffId = listDifficulties(null, possibleVariations)[0];
@@ -422,22 +423,29 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
   /**
    * Given that this character is selected in the Freeplay menu,
    * which variations should be available?
-   * @param charId The character ID to query.
+   * @param char The playable character to query.
    * @return An array of available variations.
    */
-  public function getVariationsByCharId(?charId:String):Array<String>
+  public function getVariationsByCharacter(?char:PlayableCharacter):Array<String>
   {
-    if (charId == null) charId = Constants.DEFAULT_CHARACTER;
+    if (char == null) return variations;
 
-    if (variations.contains(charId))
+    var result = [];
+    trace('Evaluating variations for ${this.id} ${char.id}: ${this.variations}');
+    for (variation in variations)
     {
-      return [charId];
-    }
-    else
-    {
-      // TODO: How to exclude character variations while keeping other custom variations?
-      return variations;
+      var metadata = _metadata.get(variation);
+
+      var playerCharId = metadata?.playData?.characters?.player;
+      if (playerCharId == null) continue;
+
+      if (char.shouldShowCharacter(playerCharId))
+      {
+        result.push(variation);
+      }
     }
+
+    return result;
   }
 
   /**
@@ -455,6 +463,8 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
     if (variationIds == null) variationIds = [];
     if (variationId != null) variationIds.push(variationId);
 
+    if (variationIds.length == 0) return [];
+
     // The difficulties array contains entries like 'normal', 'nightmare-erect', and 'normal-pico',
     // so we have to map it to the actual difficulty names.
     // We also filter out difficulties that don't match the variation or that don't exist.
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
index b84c68f8d..ab13da1d9 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
@@ -808,8 +808,11 @@ class ChartEditorDialogHandler
         }
         songVariationMetadataEntry.onClick = onClickMetadataVariation.bind(variation).bind(songVariationMetadataEntryLabel);
         #if FILE_DROP_SUPPORTED
-        state.addDropHandler({component: songVariationMetadataEntry, handler: onDropFileMetadataVariation.bind(variation)
-          .bind(songVariationMetadataEntryLabel)});
+        state.addDropHandler(
+          {
+            component: songVariationMetadataEntry,
+            handler: onDropFileMetadataVariation.bind(variation).bind(songVariationMetadataEntryLabel)
+          });
         #end
         chartContainerB.addComponent(songVariationMetadataEntry);
 
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 56ce613ba..30863f2a9 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -23,6 +23,7 @@ import flixel.util.FlxTimer;
 import funkin.audio.FunkinSound;
 import funkin.data.story.level.LevelRegistry;
 import funkin.data.song.SongRegistry;
+import funkin.data.freeplay.player.PlayerRegistry;
 import funkin.graphics.FunkinCamera;
 import funkin.graphics.FunkinSprite;
 import funkin.graphics.shaders.AngleMask;
@@ -47,6 +48,7 @@ import lime.utils.Assets;
 import flixel.tweens.misc.ShakeTween;
 import funkin.effects.IntervalShake;
 import funkin.ui.freeplay.SongMenuItem.FreeplayRank;
+import funkin.ui.freeplay.charselect.PlayableCharacter;
 
 /**
  * Parameters used to initialize the FreeplayState.
@@ -102,7 +104,9 @@ class FreeplayState extends MusicBeatSubState
    * The current character for this FreeplayState.
    * You can't change this without transitioning to a new FreeplayState.
    */
-  final currentCharacter:String;
+  final currentCharacterId:String;
+
+  final currentCharacter:PlayableCharacter;
 
   /**
    * For the audio preview, the duration of the fade-in effect.
@@ -205,7 +209,8 @@ class FreeplayState extends MusicBeatSubState
 
   public function new(?params:FreeplayStateParams, ?stickers:StickerSubState)
   {
-    currentCharacter = params?.character ?? Constants.DEFAULT_CHARACTER;
+    currentCharacterId = params?.character ?? Constants.DEFAULT_CHARACTER;
+    currentCharacter = PlayerRegistry.instance.fetchEntry(currentCharacterId);
 
     fromResultsParams = params?.fromResults;
 
@@ -290,11 +295,10 @@ class FreeplayState extends MusicBeatSubState
         }
 
         // Only display songs which actually have available difficulties for the current character.
-        var displayedVariations = song.getVariationsByCharId(currentCharacter);
-        trace(songId);
-        trace(displayedVariations);
+        var displayedVariations = song.getVariationsByCharacter(currentCharacter);
+        trace('Displayed Variations (${songId}): $displayedVariations');
         var availableDifficultiesForSong:Array<String> = song.listDifficulties(displayedVariations, false);
-        trace(availableDifficultiesForSong);
+        trace('Available Difficulties: $availableDifficultiesForSong');
         if (availableDifficultiesForSong.length == 0) continue;
 
         songs.push(new FreeplaySongData(levelId, songId, song, displayedVariations));
@@ -454,7 +458,7 @@ class FreeplayState extends MusicBeatSubState
       });
 
     // TODO: Replace this.
-    if (currentCharacter == 'pico') dj.visible = false;
+    if (currentCharacterId == 'pico') dj.visible = false;
 
     add(dj);
 
@@ -1195,6 +1199,16 @@ class FreeplayState extends MusicBeatSubState
       rankAnimStart(fromResultsParams);
     }
 
+    if (FlxG.keys.justPressed.P)
+    {
+      FlxG.switchState(FreeplayState.build(
+        {
+          {
+            character: currentCharacterId == "pico" ? "bf" : "pico",
+          }
+        }));
+    }
+
     // if (FlxG.keys.justPressed.H)
     // {
     //   rankDisplayNew(fromResultsParams);
@@ -1302,9 +1316,9 @@ class FreeplayState extends MusicBeatSubState
   {
     if (busy) return;
 
-    var upP:Bool = controls.UI_UP_P && !FlxG.keys.pressed.CONTROL;
-    var downP:Bool = controls.UI_DOWN_P && !FlxG.keys.pressed.CONTROL;
-    var accepted:Bool = controls.ACCEPT && !FlxG.keys.pressed.CONTROL;
+    var upP:Bool = controls.UI_UP_P;
+    var downP:Bool = controls.UI_DOWN_P;
+    var accepted:Bool = controls.ACCEPT;
 
     if (FlxG.onMobile)
     {
@@ -1378,7 +1392,7 @@ class FreeplayState extends MusicBeatSubState
     }
     #end
 
-    if (!FlxG.keys.pressed.CONTROL && (controls.UI_UP || controls.UI_DOWN))
+    if ((controls.UI_UP || controls.UI_DOWN))
     {
       if (spamming)
       {
@@ -1440,13 +1454,13 @@ class FreeplayState extends MusicBeatSubState
     }
     #end
 
-    if (controls.UI_LEFT_P && !FlxG.keys.pressed.CONTROL)
+    if (controls.UI_LEFT_P)
     {
       dj.resetAFKTimer();
       changeDiff(-1);
       generateSongList(currentFilter, true);
     }
-    if (controls.UI_RIGHT_P && !FlxG.keys.pressed.CONTROL)
+    if (controls.UI_RIGHT_P)
     {
       dj.resetAFKTimer();
       changeDiff(1);
@@ -1720,7 +1734,7 @@ class FreeplayState extends MusicBeatSubState
       return;
     }
     var targetDifficultyId:String = currentDifficulty;
-    var targetVariation:String = targetSong.getFirstValidVariation(targetDifficultyId);
+    var targetVariation:String = targetSong.getFirstValidVariation(targetDifficultyId, currentCharacter);
     PlayStatePlaylist.campaignId = cap.songData.levelId;
 
     var targetDifficulty:SongDifficulty = targetSong.getDifficulty(targetDifficultyId, targetVariation);
@@ -1730,8 +1744,18 @@ class FreeplayState extends MusicBeatSubState
       return;
     }
 
-    // TODO: Change this with alternate instrumentals
-    var targetInstId:String = targetDifficulty.characters.instrumental;
+    var baseInstrumentalId:String = targetDifficulty?.characters?.instrumental ?? '';
+    var altInstrumentalIds:Array<String> = targetDifficulty?.characters?.altInstrumentals ?? [];
+
+    var targetInstId:String = baseInstrumentalId;
+
+    // TODO: Make this a UI element.
+    #if (debug || FORCE_DEBUG_VERSION)
+    if (altInstrumentalIds.length > 0 && FlxG.keys.pressed.CONTROL)
+    {
+      targetInstId = altInstrumentalIds[0];
+    }
+    #end
 
     // Visual and audio effects.
     FunkinSound.playOnce(Paths.sound('confirmMenu'));
@@ -1883,9 +1907,23 @@ class FreeplayState extends MusicBeatSubState
     else
     {
       var previewSong:Null<Song> = SongRegistry.instance.fetchEntry(daSongCapsule.songData.songId);
-      var instSuffix:String = previewSong?.getDifficulty(currentDifficulty,
-        previewSong?.getVariationsByCharId(currentCharacter) ?? Constants.DEFAULT_VARIATION_LIST)?.characters?.instrumental ?? '';
+      var songDifficulty = previewSong?.getDifficulty(currentDifficulty,
+        previewSong?.getVariationsByCharacter(currentCharacter) ?? Constants.DEFAULT_VARIATION_LIST);
+      var baseInstrumentalId:String = songDifficulty?.characters?.instrumental ?? '';
+      var altInstrumentalIds:Array<String> = songDifficulty?.characters?.altInstrumentals ?? [];
+
+      var instSuffix:String = baseInstrumentalId;
+
+      // TODO: Make this a UI element.
+      #if (debug || FORCE_DEBUG_VERSION)
+      if (altInstrumentalIds.length > 0 && FlxG.keys.pressed.CONTROL)
+      {
+        instSuffix = altInstrumentalIds[0];
+      }
+      #end
+
       instSuffix = (instSuffix != '') ? '-$instSuffix' : '';
+
       FunkinSound.playMusic(daSongCapsule.songData.songId,
         {
           startingVolume: 0.0,
@@ -1913,7 +1951,7 @@ class FreeplayState extends MusicBeatSubState
   public static function build(?params:FreeplayStateParams, ?stickers:StickerSubState):MusicBeatState
   {
     var result:MainMenuState;
-    if (params?.fromResults.playRankAnim) result = new MainMenuState(true);
+    if (params?.fromResults?.playRankAnim) result = new MainMenuState(true);
     else
       result = new MainMenuState(false);
 
@@ -1951,8 +1989,8 @@ class DifficultySelector extends FlxSprite
 
   override function update(elapsed:Float):Void
   {
-    if (flipX && controls.UI_RIGHT_P && !FlxG.keys.pressed.CONTROL) moveShitDown();
-    if (!flipX && controls.UI_LEFT_P && !FlxG.keys.pressed.CONTROL) moveShitDown();
+    if (flipX && controls.UI_RIGHT_P) moveShitDown();
+    if (!flipX && controls.UI_LEFT_P) moveShitDown();
 
     super.update(elapsed);
   }
diff --git a/source/funkin/ui/freeplay/charselect/PlayableCharacter.hx b/source/funkin/ui/freeplay/charselect/PlayableCharacter.hx
new file mode 100644
index 000000000..743345004
--- /dev/null
+++ b/source/funkin/ui/freeplay/charselect/PlayableCharacter.hx
@@ -0,0 +1,108 @@
+package funkin.ui.freeplay.charselect;
+
+import funkin.data.IRegistryEntry;
+import funkin.data.freeplay.player.PlayerData;
+import funkin.data.freeplay.player.PlayerRegistry;
+
+/**
+ * An object used to retrieve data about a playable character (also known as "weeks").
+ * Can be scripted to override each function, for custom behavior.
+ */
+class PlayableCharacter implements IRegistryEntry<PlayerData>
+{
+  /**
+   * The ID of the playable character.
+   */
+  public final id:String;
+
+  /**
+   * Playable character data as parsed from the JSON file.
+   */
+  public final _data:PlayerData;
+
+  /**
+   * @param id The ID of the JSON file to parse.
+   */
+  public function new(id:String)
+  {
+    this.id = id;
+    _data = _fetchData(id);
+
+    if (_data == null)
+    {
+      throw 'Could not parse playable character data for id: $id';
+    }
+  }
+
+  /**
+   * Retrieve the readable name of the playable character.
+   */
+  public function getName():String
+  {
+    // TODO: Maybe add localization support?
+    return _data.name;
+  }
+
+  /**
+   * Retrieve the list of stage character IDs associated with this playable character.
+   * @return The list of associated character IDs
+   */
+  public function getOwnedCharacterIds():Array<String>
+  {
+    return _data.ownedChars;
+  }
+
+  /**
+   * Return `true` if, when this character is selected in Freeplay,
+   * songs unassociated with a specific character should appear.
+   */
+  public function shouldShowUnownedChars():Bool
+  {
+    return _data.showUnownedChars;
+  }
+
+  public function shouldShowCharacter(id:String):Bool
+  {
+    if (_data.ownedChars.contains(id))
+    {
+      return true;
+    }
+
+    if (_data.showUnownedChars)
+    {
+      var result = !PlayerRegistry.instance.isCharacterOwned(id);
+      return result;
+    }
+
+    return false;
+  }
+
+  /**
+   * Returns whether this character is unlocked.
+   */
+  public function isUnlocked():Bool
+  {
+    return _data.unlocked;
+  }
+
+  /**
+   * Called when the character is destroyed.
+   * TODO: Document when this gets called
+   */
+  public function destroy():Void {}
+
+  public function toString():String
+  {
+    return 'PlayableCharacter($id)';
+  }
+
+  /**
+   * Retrieve and parse the JSON data for a playable character by ID.
+   * @param id The ID of the character
+   * @return The parsed player data, or null if not found or invalid
+   */
+  static function _fetchData(id:String):Null<PlayerData>
+  {
+    return PlayerRegistry.instance.parseEntryDataWithMigration(id, PlayerRegistry.instance.fetchEntryVersion(id));
+  }
+}
diff --git a/source/funkin/ui/freeplay/charselect/ScriptedPlayableCharacter.hx b/source/funkin/ui/freeplay/charselect/ScriptedPlayableCharacter.hx
new file mode 100644
index 000000000..f75a58092
--- /dev/null
+++ b/source/funkin/ui/freeplay/charselect/ScriptedPlayableCharacter.hx
@@ -0,0 +1,8 @@
+package funkin.ui.freeplay.charselect;
+
+/**
+ * A script that can be tied to a PlayableCharacter.
+ * Create a scripted class that extends PlayableCharacter to use this.
+ */
+@:hscriptClass
+class ScriptedPlayableCharacter extends funkin.ui.freeplay.charselect.PlayableCharacter implements polymod.hscript.HScriptedClass {}
diff --git a/source/funkin/util/VersionUtil.hx b/source/funkin/util/VersionUtil.hx
index 832ce008a..9bf46a188 100644
--- a/source/funkin/util/VersionUtil.hx
+++ b/source/funkin/util/VersionUtil.hx
@@ -24,7 +24,6 @@ class VersionUtil
     try
     {
       var versionRaw:thx.semver.Version.SemVer = version;
-      trace('${versionRaw} satisfies (${versionRule})? ${version.satisfies(versionRule)}');
       return version.satisfies(versionRule);
     }
     catch (e)