diff --git a/src/Shared.Core/Shared.Core.projitems b/src/Shared.Core/Shared.Core.projitems
index 405248f..ced7f3e 100644
--- a/src/Shared.Core/Shared.Core.projitems
+++ b/src/Shared.Core/Shared.Core.projitems
@@ -35,6 +35,7 @@
+
diff --git a/src/Shared.Core/Utilities/TextureStorage.cs b/src/Shared.Core/Utilities/TextureStorage.cs
new file mode 100644
index 0000000..4d5dd8c
--- /dev/null
+++ b/src/Shared.Core/Utilities/TextureStorage.cs
@@ -0,0 +1,245 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using ExtensibleSaveFormat;
+using KKAPI;
+using KKAPI.Utilities;
+using UnityEngine;
+using Object = UnityEngine.Object;
+using Random = UnityEngine.Random;
+
+namespace KoiSkinOverlayX
+{
+ ///
+ /// A class for storing textures that should be saved and loaded from extended data's (e.g. to character cards and scenes).
+ /// Duplicate textures are automatically handled so that only one copy of the texture is held in memory and saved.
+ ///
+ internal class TextureStorage : IDisposable
+ {
+ // Do not change or it will break stuff that used this marker previously
+ private const string DataMarker = "_TextureID_";
+
+ private readonly Dictionary _data = new Dictionary();
+ private readonly TextureFormat _format;
+
+ ///
+ /// Create a new TextureStorage.
+ ///
+ /// Format of the loaded textures. It doesn't affect data saved to extended data.
+ public TextureStorage(TextureFormat format = TextureFormat.ARGB32)
+ {
+ _format = format;
+ }
+
+ void IDisposable.Dispose()
+ {
+ lock (_data)
+ {
+ foreach (var tex in _data) tex.Value?.Dispose();
+ _data.Clear();
+ }
+ }
+
+ ///
+ /// Remove unused textures based on a list of used IDs. Textures with IDs not in the list will be removed.
+ ///
+ /// A list of IDs to be kept if they exist
+ public void PurgeUnused(IEnumerable usedIDs)
+ {
+ if (usedIDs == null) throw new ArgumentNullException(nameof(usedIDs));
+ var lookup = new HashSet(usedIDs);
+
+ lock (_data)
+ {
+ foreach (var kvp in _data.ToList())
+ {
+ var contains = lookup.Contains(kvp.Key);
+ if (!contains || kvp.Value?.Data == null)
+ {
+ Console.WriteLine($"Removing {(contains ? "empty" : "unused")} texture with ID {kvp.Key}");
+ kvp.Value?.Dispose();
+ _data.Remove(kvp.Key);
+ }
+ }
+ }
+ }
+
+ ///
+ /// Get IDs of all textures stored in this object.
+ ///
+ public int[] GetAllTextureIDs()
+ {
+ lock (_data)
+ {
+ return _data.Keys.ToArray();
+ }
+ }
+
+ ///
+ /// Clear the texture list and optionally destroy all textures.
+ ///
+ public void Clear(bool destroy = true)
+ {
+ lock (_data)
+ {
+ if (destroy)
+ ((IDisposable)this).Dispose();
+ else
+ _data.Clear();
+ }
+ }
+
+ ///
+ /// Load textures from extended data that were stored with .
+ ///
+ public void Load(PluginData data)
+ {
+ if (data == null) throw new ArgumentNullException(nameof(data));
+ lock (_data)
+ {
+ foreach (var dataPair in data.data.Where(x => x.Key.StartsWith(DataMarker)))
+ {
+ var idStr = dataPair.Key.Substring(DataMarker.Length);
+ if (!int.TryParse(idStr, out var id))
+ {
+ KoikatuAPI.Logger.LogDebug($"Invalid ID {idStr} in key {dataPair.Key}");
+ continue;
+ }
+
+ var value = dataPair.Value as byte[];
+ if (value == null && dataPair.Value != null)
+ {
+ KoikatuAPI.Logger.LogDebug($"Invalid value of ID {id}. Should be of type byte[] but is {dataPair.Value.GetType()}");
+ continue;
+ }
+
+ _data[id] = new TextureHolder(value, _format);
+ }
+ }
+ }
+
+ ///
+ /// Save textures stored in this object to extended data. Can be loaded later with .
+ ///
+ public void Save(PluginData data)
+ {
+ if (data == null) throw new ArgumentNullException(nameof(data));
+ lock (_data)
+ {
+ foreach (var tex in _data)
+ {
+ if (tex.Value == null) continue;
+ data.data[DataMarker + tex.Key] = tex.Value.Data;
+ }
+ }
+ }
+
+ ///
+ /// Store a texture and get an ID representing it. The ID can be used to get the texture with .
+ /// If you try to store a texture that was already stored before, the ID of the previous texture is returned so there are no multiple identical textures stored.
+ ///
+ /// Raw PNG data of the texture. If you reuse a texture make sure you always use the same PNG data or deduplicating won't work.
+ public int StoreTexture(byte[] tex)
+ {
+ if (tex == null) throw new ArgumentNullException(nameof(tex));
+ lock (_data)
+ {
+ var existing = _data.FirstOrDefault(x => x.Value != null && x.Value.Data.SequenceEqual(tex));
+ if (existing.Value != null)
+ {
+ Console.WriteLine("StoreTexture - Texture already exists, reusing it");
+ return existing.Key;
+ }
+
+ // Use random ID instaed of sequential to help catch code using IDs that no longer exist
+ for (var i = Random.Range(1000, 9990); ; i++)
+ {
+ if (!_data.ContainsKey(i))
+ {
+ _data[i] = new TextureHolder(tex, _format);
+ return i;
+ }
+ }
+ }
+ }
+
+ /* todo remove? very slow and potentially not very useful
+ public int StoreTexture(Texture2D tex)
+ {
+ if (tex == null) throw new ArgumentNullException(nameof(tex));
+ var rawTextureData = tex.GetRawTextureData();
+ lock (_data)
+ {
+ var existing = _data.FirstOrDefault(x =>
+ x.Value != null && x.Value.Texture.GetRawTextureData().SequenceEqual(rawTextureData));
+ if (existing.Value != null) return existing.Key;
+ return StoreTexture(tex.EncodeToPNG());
+ }
+ }*/
+
+ ///
+ /// Get a texture based on texture ID. The same texture is returned every time, so it shouldn't be destroyed.
+ ///
+ /// ID of the texture you want to get. You get the ID when using .
+ ///
+ public Texture2D GetSharedTexture(int id)
+ {
+ lock (_data)
+ {
+ if (_data.TryGetValue(id, out var data))
+ {
+ if (data == null)
+ return null;
+ if (data.Texture.IsDestroyed())
+ KoikatuAPI.Logger.LogDebug($"Texture ID={id} from TextureStorage was destroyed, recreating");
+ return data.Texture;
+ }
+ }
+
+ KoikatuAPI.Logger.LogWarning("Tried getting texture with nonexisting ID: " + id);
+ return null;
+ }
+
+ private sealed class TextureHolder : IDisposable
+ {
+ private readonly TextureFormat _format;
+ private byte[] _data;
+ private Texture2D _texture;
+
+ public TextureHolder(byte[] data, TextureFormat format)
+ {
+ Data = data ?? throw new ArgumentNullException(nameof(data));
+ _format = format;
+ }
+
+ public byte[] Data
+ {
+ get => _data;
+ set
+ {
+ Dispose();
+ _data = value;
+ }
+ }
+
+ public Texture2D Texture
+ {
+ get
+ {
+ if (_texture == null && _data != null)
+ _texture = _data.LoadTexture(_format);
+ return _texture;
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_texture != null)
+ {
+ Object.Destroy(_texture);
+ _texture = null;
+ }
+ }
+ }
+ }
+}
\ No newline at end of file