using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Reflection; using System.Resources; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Security; using System.Security.Permissions; using System.Text; using HarmonyLib; using Il2CppInterop.Runtime.InteropTypes; using Il2CppInterop.Runtime.InteropTypes.Arrays; using Il2CppScheduleOne.UI.CharacterCustomization; using Il2CppTMPro; using Inkorporated; using Inkorporated.Config; using Inkorporated.Content; using Inkorporated.Model; using Inkorporated.Registration; using Inkorporated.Shop; using MelonLoader; using MelonLoader.Preferences; using MelonLoader.Utils; using Microsoft.CodeAnalysis; using Newtonsoft.Json; using S1API.Rendering; using UnityEngine; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: MelonInfo(typeof(Core), "Inkorporated", "1.1.0", "DooDesch", "https://github.com/DooDesch-Mods/ScheduleOne-Inkorporated")] [assembly: MelonGame("TVGS", "Schedule I")] [assembly: MelonOptionalDependencies(new string[] { "ModManager&PhoneApp" })] [assembly: TargetFramework(".NETCoreApp,Version=v6.0", FrameworkDisplayName = ".NET 6.0")] [assembly: AssemblyCompany("Inkorporated")] [assembly: AssemblyConfiguration("Release")] [assembly: AssemblyFileVersion("1.1.0.0")] [assembly: AssemblyInformationalVersion("1.1.0+4b11ddb24c640398473cc451a6cb1843bf120d9a")] [assembly: AssemblyProduct("Inkorporated")] [assembly: AssemblyTitle("Inkorporated")] [assembly: NeutralResourcesLanguage("en-US")] [assembly: SecurityPermission(SecurityAction.RequestMinimum, SkipVerification = true)] [assembly: AssemblyVersion("1.1.0.0")] [module: UnverifiableCode] [module: RefSafetyRules(11)] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)] internal sealed class RefSafetyRulesAttribute : Attribute { public readonly int Version; public RefSafetyRulesAttribute(int P_0) { Version = P_0; } } } namespace Inkorporated { public static class API { public static bool RegisterTattoo(string id, string displayName, TattooPlacement placement, Texture2D texture, float price = 0f, string source = "API") { return TattooRegistry.Add(new TattooDef { Id = id, DisplayName = (string.IsNullOrWhiteSpace(displayName) ? id : displayName), Placement = placement, Price = ((price < 0f) ? 0f : price), Texture = texture, Source = (string.IsNullOrWhiteSpace(source) ? "API" : source) }); } public static bool RegisterTattooFromFile(string id, string displayName, TattooPlacement placement, string pngPath, float price = 0f, string source = "API") { return TattooRegistry.Add(new TattooDef { Id = id, DisplayName = (string.IsNullOrWhiteSpace(displayName) ? id : displayName), Placement = placement, Price = ((price < 0f) ? 0f : price), PngPath = pngPath, Source = (string.IsNullOrWhiteSpace(source) ? "API" : source) }); } [MethodImpl(MethodImplOptions.NoInlining)] public static bool RegisterTattooFromResource(string id, string displayName, TattooPlacement placement, string resourceName, float price = 0f, string source = "API") { return RegisterTattooFromResource(id, displayName, placement, Assembly.GetCallingAssembly(), resourceName, price, source); } public static bool RegisterTattooFromResource(string id, string displayName, TattooPlacement placement, Assembly assembly, string resourceName, float price = 0f, string source = "API") { return TattooRegistry.Add(new TattooDef { Id = id, DisplayName = (string.IsNullOrWhiteSpace(displayName) ? id : displayName), Placement = placement, Price = ((price < 0f) ? 0f : price), ResourceAssembly = assembly, ResourceName = resourceName, Source = (string.IsNullOrWhiteSpace(source) ? "API" : source) }); } } public sealed class Core : MelonMod { public static Core Instance { get; private set; } public static Instance Log { get; private set; } public override void OnInitializeMelon() { Instance = this; Log = ((MelonBase)this).LoggerInstance; Preferences.Initialize(); ExamplePack.ExtractIfEnabled(); int value = TattooRegistry.AddRange(PackLoader.LoadAll()); try { ((MelonBase)this).HarmonyInstance.PatchAll(Assembly.GetExecutingAssembly()); } catch (Exception ex) { Log.Warning("Harmony patch failed: " + ex.Message); } Log.Msg($"Inkorporated 1.1.0 - {value} pack tattoo(s) loaded ({TattooRegistry.AllDefs.Count} total). Shop injection armed."); Log.Msg("Drop packs in: " + PackLoader.PacksRoot); } public override void OnSceneWasUnloaded(int buildIndex, string sceneName) { ShopInjector.Reset(); } } } namespace Inkorporated.Shop { [HarmonyPatch(typeof(CharacterCustomizationCategory), "Awake")] internal static class CategoryAwakePatch { private static void Prefix(CharacterCustomizationCategory __instance) { ShopInjector.TryInject(__instance); } } internal static class ShopInjector { private static readonly HashSet _processed = new HashSet(); public static void Reset() { _processed.Clear(); } public static void TryInject(CharacterCustomizationCategory category) { try { if ((Object)(object)category == (Object)null || !_processed.Add(((Il2CppObjectBase)category).Pointer) || (Object)(object)((Component)category).GetComponentInParent() == (Object)null) { return; } Il2CppArrayBase componentsInChildren = ((Component)category).GetComponentsInChildren(true); if (componentsInChildren == null || componentsInChildren.Length == 0) { return; } CharacterCustomizationOption val = PickTemplate(componentsInChildren); if ((Object)(object)val == (Object)null) { return; } List list = new List(); for (int i = 0; i < componentsInChildren.Length; i++) { string text = (((Object)(object)componentsInChildren[i] != (Object)null) ? componentsInChildren[i].Label : null); if (!string.IsNullOrEmpty(text)) { list.Add(text); } } int num = 0; foreach (TattooDef allDef in TattooRegistry.AllDefs) { if (LabelsContain(list, TattooRegistry.CategoryToken(allDef.Placement)) && TattooRegistry.EnsureRegistered(allDef) && !HasLabel(componentsInChildren, allDef.ResourcePath) && CloneOption(category, val, allDef)) { num++; } } if (num <= 0) { return; } Transform parent = ((Component)val).transform.parent; if ((Object)(object)parent != (Object)null) { List list2 = new List(); for (int j = 0; j < parent.childCount; j++) { Transform child = parent.GetChild(j); if ((Object)(object)((Component)child).GetComponent() == (Object)null) { list2.Add(child); } } foreach (Transform item in list2) { item.SetAsLastSibling(); } } Instance log = Core.Log; if (log != null) { log.Msg($"Injected {num} custom tattoo(s) into category '{category.CategoryName}'."); } } catch (Exception ex) { Instance log2 = Core.Log; if (log2 != null) { log2.Warning("Shop injection error: " + ex.Message); } } } private static CharacterCustomizationOption PickTemplate(Il2CppArrayBase opts) { for (int i = 0; i < opts.Length; i++) { if ((Object)(object)opts[i] != (Object)null && ((Component)opts[i]).gameObject.activeSelf) { return opts[i]; } } if (opts.Length <= 0) { return null; } return opts[0]; } private static bool LabelsContain(List labels, string token) { foreach (string label in labels) { if (label.IndexOf(token, StringComparison.OrdinalIgnoreCase) >= 0) { return true; } } return false; } private static bool HasLabel(Il2CppArrayBase opts, string label) { if (label == null) { return false; } for (int i = 0; i < opts.Length; i++) { if ((Object)(object)opts[i] != (Object)null && string.Equals(opts[i].Label, label, StringComparison.Ordinal)) { return true; } } return false; } private static bool CloneOption(CharacterCustomizationCategory category, CharacterCustomizationOption template, TattooDef def) { //IL_0025: Unknown result type (might be due to invalid IL or missing references) try { Transform parent = ((Component)template).transform.parent; GameObject val = ((Il2CppObjectBase)Object.Instantiate(((Component)template).gameObject, parent, false)).Cast(); val.transform.localScale = Vector3.one; ((Object)val).name = "Inkorporated_" + def.Source + "_" + def.Id; CharacterCustomizationOption component = val.GetComponent(); if ((Object)(object)component == (Object)null) { Object.Destroy((Object)(object)val); return false; } component.Name = def.DisplayName; component.Label = def.ResourcePath; component.Price = def.Price; component.RequireLevel = false; if ((Object)(object)component.NameLabel != (Object)null) { ((TMP_Text)component.NameLabel).text = def.DisplayName; } if ((Object)(object)component.PriceLabel != (Object)null) { ((TMP_Text)component.PriceLabel).text = ((def.Price > 0f) ? Mathf.RoundToInt(def.Price).ToString() : "Free"); } if (!val.activeSelf) { val.SetActive(true); } return true; } catch (Exception ex) { Instance log = Core.Log; if (log != null) { log.Warning("Tattoo '" + def.Key + "': failed to create shop button - " + ex.Message); } return false; } } } } namespace Inkorporated.Registration { internal static class TattooRegistry { private static readonly List _all = new List(); private static readonly HashSet _keys = new HashSet(StringComparer.OrdinalIgnoreCase); public static IReadOnlyList AllDefs => _all; public static bool Add(TattooDef def) { if (def == null || string.IsNullOrWhiteSpace(def.Id)) { return false; } if (!_keys.Add(def.Key)) { return false; } _all.Add(def); return true; } public static int AddRange(IEnumerable defs) { int num = 0; if (defs == null) { return 0; } foreach (TattooDef def in defs) { if (Add(def)) { num++; } } return num; } public static string CategoryToken(TattooPlacement p) { return p switch { TattooPlacement.Chest => "/chest/", TattooPlacement.LeftArm => "/leftarm/", TattooPlacement.RightArm => "/rightarm/", TattooPlacement.Face => "/face/", _ => "/chest/", }; } private static string SourceLayer(TattooPlacement p) { return p switch { TattooPlacement.Chest => "Avatar/Layers/Tattoos/chest/Chest_Bird", TattooPlacement.LeftArm => "Avatar/Layers/Tattoos/leftarm/LeftArm_Web", TattooPlacement.RightArm => "Avatar/Layers/Tattoos/rightarm/RightArm_Web", TattooPlacement.Face => "Avatar/Layers/Tattoos/face/Face_Teardrop", _ => "Avatar/Layers/Tattoos/chest/Chest_Bird", }; } private static string TargetPath(TattooDef def) { string text = ((def.Placement == TattooPlacement.Face) ? "Face" : def.Placement.ToString().ToLowerInvariant()); return "Avatar/Layers/Tattoos/custom/" + text + "/" + Sanitize(def.Source) + "_" + Sanitize(def.Id); } public static bool EnsureRegistered(TattooDef def) { if (def == null) { return false; } if (def.ResourcePath != null) { return true; } try { Texture2D val = def.Texture; if ((Object)(object)val == (Object)null) { if (!string.IsNullOrEmpty(def.PngPath)) { val = TextureUtils.LoadTextureFromFile(def.PngPath, (FilterMode)1, (TextureWrapMode)1); } else { if (!(def.ResourceAssembly != null) || string.IsNullOrEmpty(def.ResourceName)) { Instance log = Core.Log; if (log != null) { log.Warning("Tattoo '" + def.Key + "': no texture, PNG path or embedded resource."); } return false; } byte[] array = ReadResource(def.ResourceAssembly, def.ResourceName); if (array != null) { val = TextureUtils.LoadTextureFromBytes(array, (FilterMode)1, (TextureWrapMode)1); } } if ((Object)(object)val == (Object)null) { Instance log2 = Core.Log; if (log2 != null) { log2.Warning("Tattoo '" + def.Key + "': failed to load image."); } return false; } } string text = TargetPath(def); string text2 = SourceLayer(def.Placement); if (!AvatarLayerFactory.CreateAndRegisterAvatarLayer(text2, text, def.DisplayName ?? def.Id, val)) { Instance log3 = Core.Log; if (log3 != null) { log3.Warning($"Tattoo '{def.Key}': CreateAndRegisterAvatarLayer failed (source '{text2}')."); } return false; } def.ResourcePath = text; Instance log4 = Core.Log; if (log4 != null) { log4.Msg("Registered tattoo '" + def.Key + "' -> " + text); } return true; } catch (Exception ex) { Instance log5 = Core.Log; if (log5 != null) { log5.Warning("Tattoo '" + def.Key + "': registration error - " + ex.Message); } return false; } } private static byte[] ReadResource(Assembly asm, string name) { try { string name2 = name; if (asm.GetManifestResourceStream(name2) == null) { string[] manifestResourceNames = asm.GetManifestResourceNames(); foreach (string text in manifestResourceNames) { if (text == name || text.EndsWith("." + name, StringComparison.OrdinalIgnoreCase)) { name2 = text; break; } } } using Stream stream = asm.GetManifestResourceStream(name2); if (stream == null) { Instance log = Core.Log; if (log != null) { log.Warning("Embedded resource not found: '" + name + "' in " + asm.GetName().Name); } return null; } using MemoryStream memoryStream = new MemoryStream(); stream.CopyTo(memoryStream); return memoryStream.ToArray(); } catch (Exception ex) { Instance log2 = Core.Log; if (log2 != null) { log2.Warning("Embedded resource read failed '" + name + "': " + ex.Message); } return null; } } private static string Sanitize(string s) { if (string.IsNullOrEmpty(s)) { return "x"; } StringBuilder stringBuilder = new StringBuilder(s.Length); foreach (char c in s) { stringBuilder.Append((char.IsLetterOrDigit(c) || c == '-' || c == '_') ? c : '_'); } return stringBuilder.ToString(); } } } namespace Inkorporated.Model { public enum TattooPlacement { Chest, LeftArm, RightArm, Face } public sealed class TattooDef { public string Id; public string DisplayName; public TattooPlacement Placement; public float Price; public string PngPath; public Texture2D Texture; public Assembly ResourceAssembly; public string ResourceName; public string Source; public string ResourcePath; public string Key => (Source ?? "?") + "/" + (Id ?? "?"); } } namespace Inkorporated.Content { internal static class ExamplePack { private const string ResourcePrefix = "Inkorporated.Assets.ExamplePack."; public static void ExtractIfEnabled() { if (!Preferences.LoadExamplePack) { return; } string text = Path.Combine(PackLoader.PacksRoot, "Examples"); try { if (Directory.Exists(text) && File.Exists(Path.Combine(text, "manifest.json"))) { Instance log = Core.Log; if (log != null) { log.Msg("Example pack already present - leaving it untouched."); } return; } Directory.CreateDirectory(text); Assembly executingAssembly = Assembly.GetExecutingAssembly(); int num = 0; string[] manifestResourceNames = executingAssembly.GetManifestResourceNames(); foreach (string text2 in manifestResourceNames) { if (!text2.StartsWith("Inkorporated.Assets.ExamplePack.", StringComparison.Ordinal)) { continue; } string path = text2.Substring("Inkorporated.Assets.ExamplePack.".Length); using Stream stream = executingAssembly.GetManifestResourceStream(text2); if (stream != null) { using FileStream destination = File.Create(Path.Combine(text, path)); stream.CopyTo(destination); num++; } } Instance log2 = Core.Log; if (log2 != null) { log2.Msg($"Extracted bundled example pack ({num} file(s)) -> {text}"); } } catch (Exception ex) { Instance log3 = Core.Log; if (log3 != null) { log3.Warning("Example pack extraction failed: " + ex.Message); } } } } internal static class PackLoader { public static string PacksRoot => Path.Combine(MelonEnvironment.UserDataDirectory, "Inkorporated", "Packs"); public static List LoadAll() { List list = new List(); string packsRoot = PacksRoot; try { Directory.CreateDirectory(packsRoot); WriteReadmeIfMissing(packsRoot); } catch (Exception ex) { Instance log = Core.Log; if (log != null) { log.Warning("Could not prepare packs folder '" + packsRoot + "': " + ex.Message); } return list; } string[] directories = Directory.GetDirectories(packsRoot); foreach (string text in directories) { string path = Path.Combine(text, "manifest.json"); if (!File.Exists(path)) { continue; } string name = new DirectoryInfo(text).Name; try { PackManifest packManifest = JsonConvert.DeserializeObject(File.ReadAllText(path)); if (packManifest?.tattoos == null) { Instance log2 = Core.Log; if (log2 != null) { log2.Warning("Pack '" + name + "': manifest has no 'tattoos' array - skipped."); } continue; } int num = 0; foreach (ManifestEntry tattoo in packManifest.tattoos) { TattooDef tattooDef = ToDef(name, text, tattoo); if (tattooDef != null) { list.Add(tattooDef); num++; } } Instance log3 = Core.Log; if (log3 != null) { log3.Msg($"Pack '{name}' ({packManifest.name ?? "unnamed"}): {num} tattoo(s)."); } } catch (Exception ex2) { Instance log4 = Core.Log; if (log4 != null) { log4.Warning("Pack '" + name + "': failed to read manifest.json - " + ex2.Message); } } } return list; } private static TattooDef ToDef(string packName, string packDir, ManifestEntry e) { if (e == null || string.IsNullOrWhiteSpace(e.id)) { Instance log = Core.Log; if (log != null) { log.Warning("Pack '" + packName + "': an entry is missing 'id' - skipped."); } return null; } if (!TryParsePlacement(e.placement, out var placement)) { Instance log2 = Core.Log; if (log2 != null) { log2.Warning($"Pack '{packName}' tattoo '{e.id}': unknown placement '{e.placement}' (expected chest|leftarm|rightarm|face) - skipped."); } return null; } string path = (string.IsNullOrWhiteSpace(e.file) ? (e.id + ".png") : e.file); string text = Path.Combine(packDir, path); if (!File.Exists(text)) { Instance log3 = Core.Log; if (log3 != null) { log3.Warning($"Pack '{packName}' tattoo '{e.id}': PNG not found at '{text}' - skipped."); } return null; } return new TattooDef { Id = e.id, DisplayName = (string.IsNullOrWhiteSpace(e.name) ? e.id : e.name), Placement = placement, Price = ((e.price < 0f) ? 0f : e.price), PngPath = text, Source = packName }; } private static bool TryParsePlacement(string s, out TattooPlacement placement) { placement = TattooPlacement.Chest; if (string.IsNullOrWhiteSpace(s)) { return false; } switch (s.Trim().ToLowerInvariant()) { case "chest": placement = TattooPlacement.Chest; return true; case "left_arm": case "left": case "leftarm": placement = TattooPlacement.LeftArm; return true; case "right": case "rightarm": case "right_arm": placement = TattooPlacement.RightArm; return true; case "face": placement = TattooPlacement.Face; return true; default: return false; } } private static void WriteReadmeIfMissing(string root) { string path = Path.Combine(root, "README.txt"); if (!File.Exists(path)) { File.WriteAllText(path, "Inkorporated - custom tattoo packs\n==================================\n\nDrop one folder per pack in this directory. Each pack needs a manifest.json and the PNG files it lists.\n\nFolder layout:\n Packs/\n MyPack/\n manifest.json\n my_chest_tattoo.png\n my_face_tattoo.png\n\nmanifest.json:\n{\n \"name\": \"My Pack\",\n \"author\": \"you\",\n \"tattoos\": [\n { \"id\": \"skull\", \"name\": \"Skull\", \"placement\": \"chest\", \"file\": \"my_chest_tattoo.png\" },\n { \"id\": \"teardrop\",\"name\": \"Teardrop X\", \"placement\": \"face\", \"file\": \"my_face_tattoo.png\", \"price\": 250 }\n ]\n}\n\nplacement: chest | leftarm | rightarm | face\nprice: optional, omit or 0 for \"Free\"\n\nTip: PNGs must match the game's body/face UV layout to sit correctly on the skin. Use an existing\nin-game tattoo texture as a template. Tattoos appear in the in-game tattoo shop.\n"); } } } public sealed class PackManifest { public string name; public string author; public List tattoos; } public sealed class ManifestEntry { public string id; public string name; public string placement; public string file; public float price; } } namespace Inkorporated.Config { internal static class Preferences { private const string CategoryId = "Inkorporated_01_Main"; private static MelonPreferences_Category _category; private static MelonPreferences_Entry _loadExamplePack; internal static bool LoadExamplePack => _loadExamplePack?.Value ?? false; internal static void Initialize() { if (_category == null) { _category = MelonPreferences.CreateCategory("Inkorporated_01_Main", "Inkorporated (Custom Tattoos)"); _loadExamplePack = _category.CreateEntry("LoadExamplePack", false, "Load example tattoo pack", "OFF by default. When ON, Inkorporated drops a small bundled example pack into UserData/Inkorporated/Packs/Examples on startup (if not already there) so you get a few ready-made tattoos plus a working folder/manifest template to copy for your own pack. Requires a game restart.", false, false, (ValueValidator)null, (string)null); } } } }