diff --git a/.github/workflows/publish_cli.yml b/.github/workflows/publish_cli.yml index 9e11f33ab..49c173b40 100644 --- a/.github/workflows/publish_cli.yml +++ b/.github/workflows/publish_cli.yml @@ -31,7 +31,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 6.0.x + dotnet-version: 8.0.x - name: Restore dependencies run: dotnet restore UndertaleModCli - name: Build @@ -42,6 +42,7 @@ jobs: run: | cp ./README.md ./CLI-${{ matrix.os }}/ cp ./LICENSE.txt ./CLI-${{ matrix.os }}/ + cp -r ./UndertaleModLib/GameSpecificData/ ./CLI-${{ matrix.os }}/GameSpecificData/ - name: Upload ${{ matrix.os }} CLI uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/publish_gui.yml b/.github/workflows/publish_gui.yml index 908d5e538..36eed51d2 100644 --- a/.github/workflows/publish_gui.yml +++ b/.github/workflows/publish_gui.yml @@ -25,7 +25,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 6.0.x + dotnet-version: 8.0.x - name: Restore dependencies run: dotnet restore - name: Build @@ -41,6 +41,7 @@ jobs: cp ./README.md ./${{ matrix.os }} cp ./SCRIPTS.md ./${{ matrix.os }} cp ./LICENSE.txt ./${{ matrix.os }} + cp -r ./UndertaleModLib/GameSpecificData/ ./${{ matrix.os }}/GameSpecificData/ - name: Upload ${{ matrix.os }} GUI uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/publish_gui_nightly.yml b/.github/workflows/publish_gui_nightly.yml index e590c4838..c61421c8f 100644 --- a/.github/workflows/publish_gui_nightly.yml +++ b/.github/workflows/publish_gui_nightly.yml @@ -16,7 +16,7 @@ jobs: fail-fast: false matrix: os: [windows-latest] - configuration: [Debug, Release] + configuration: [Debug] bundled: [true] singlefile: [true, false] @@ -29,7 +29,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 6.0.x + dotnet-version: 8.0.x - name: Restore dependencies run: dotnet restore - name: Build @@ -37,14 +37,15 @@ jobs: dotnet build UndertaleModTool --no-restore dotnet build UndertaleModToolUpdater --no-restore - name: Publish ${{ matrix.os }} GUI - run: | - dotnet publish UndertaleModTool -c ${{ matrix.configuration }} -r win-x64 -p:DefineConstants=\"SHOW_COMMIT_HASH\" --self-contained ${{ matrix.bundled }} -p:PublishSingleFile=${{ matrix.singlefile }} --output ${{ matrix.os }} + run: | # FIXME: debug constant isn't being applied here, which disables updater, etc., so need to fix that or possibly add a new constant + dotnet publish UndertaleModTool -c ${{ matrix.configuration }} -r win-x64 -p:DefineConstants="SHOW_COMMIT_HASH" --self-contained ${{ matrix.bundled }} -p:PublishSingleFile=${{ matrix.singlefile }} --output ${{ matrix.os }} dotnet publish UndertaleModToolUpdater -c ${{ matrix.configuration }} -r win-x64 --self-contained ${{ matrix.bundled }} -p:PublishSingleFile=false --output ${{ matrix.os }}/Updater - name: Copy external files run: | cp ./README.md ./${{ matrix.os }} cp ./SCRIPTS.md ./${{ matrix.os }} cp ./LICENSE.txt ./${{ matrix.os }} + cp -r ./UndertaleModLib/GameSpecificData/ ./${{ matrix.os }}/GameSpecificData/ - name: Create zip for nightly release Windows GUI run: | 7z a -tzip GUI-${{ matrix.os }}-${{ matrix.configuration }}-isBundled-${{ matrix.bundled }}-isSingleFile-${{ matrix.singlefile }}.zip ./${{ matrix.os }}/* -mx0 @@ -60,7 +61,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macOS-latest, windows-latest] - configuration: [Debug, Release] + configuration: [Debug] bundled: [true] include: - os: ubuntu-latest @@ -79,7 +80,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 6.0.x + dotnet-version: 8.0.x - name: Restore dependencies run: dotnet restore UndertaleModCli - name: Build @@ -90,6 +91,7 @@ jobs: run: | cp ./README.md ./CLI-${{ matrix.os }}/ cp ./LICENSE.txt ./CLI-${{ matrix.os }}/ + cp -r ./UndertaleModLib/GameSpecificData/ ./CLI-${{ matrix.os }}/GameSpecificData/ - name: Create zip for nightly release CLI run: | 7z a -tzip CLI-${{ matrix.os }}-${{ matrix.configuration }}-isBundled-${{ matrix.bundled }}.zip ./CLI-${{ matrix.os }}/* -mx0 @@ -118,7 +120,7 @@ jobs: with: tag_name: bleeding-edge name: Bleeding Edge - prerelease: false + prerelease: true fail_on_unmatched_files: true files: | */* diff --git a/.github/workflows/publish_gui_release.yml b/.github/workflows/publish_gui_release.yml new file mode 100644 index 000000000..598b3d6ee --- /dev/null +++ b/.github/workflows/publish_gui_release.yml @@ -0,0 +1,53 @@ +name: Publish stable release of UndertaleModTool GUI + +on: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + +jobs: + build_gui: + + strategy: + fail-fast: false + matrix: + os: [windows-latest] + configuration: [Release] + bundled: [true] + singlefile: [true, false] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - name: Restore dependencies + run: dotnet restore + - name: Build + run: | + dotnet build UndertaleModTool --no-restore + - name: Publish ${{ matrix.os }} GUI + run: | + dotnet publish UndertaleModTool -c ${{ matrix.configuration }} -r win-x64 --self-contained ${{ matrix.bundled }} -p:PublishSingleFile=${{ matrix.singlefile }} --output ${{ matrix.os }} + - name: Copy external files + run: | + cp ./README.md ./${{ matrix.os }} + cp ./SCRIPTS.md ./${{ matrix.os }} + cp ./LICENSE.txt ./${{ matrix.os }} + cp -r ./UndertaleModLib/GameSpecificData/ ./${{ matrix.os }}/GameSpecificData/ + - name: Create zip for stable release Windows GUI + run: | + 7z a -tzip GUI-${{ matrix.os }}-${{ matrix.configuration }}-isBundled-${{ matrix.bundled }}-isSingleFile-${{ matrix.singlefile }}.zip ./${{ matrix.os }}/* -mx0 + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: GUI-${{ matrix.os }}-${{ matrix.configuration }}-isBundled-${{ matrix.bundled }}-isSingleFile-${{ matrix.singlefile }} + path: GUI-${{ matrix.os }}-${{ matrix.configuration }}-isBundled-${{ matrix.bundled }}-isSingleFile-${{ matrix.singlefile }}.zip + diff --git a/.github/workflows/publish_pr.yml b/.github/workflows/publish_pr.yml index 053725f14..f34433e33 100644 --- a/.github/workflows/publish_pr.yml +++ b/.github/workflows/publish_pr.yml @@ -27,7 +27,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 6.0.x + dotnet-version: 8.0.x - name: Restore dependencies run: dotnet restore - name: Build @@ -43,6 +43,7 @@ jobs: cp ./README.md ./${{ matrix.os }} cp ./SCRIPTS.md ./${{ matrix.os }} cp ./LICENSE.txt ./${{ matrix.os }} + cp -r ./UndertaleModLib/GameSpecificData/ ./${{ matrix.os }}/GameSpecificData/ - name: Upload ${{ matrix.os }} GUI uses: actions/upload-artifact@v4 with: @@ -74,7 +75,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 6.0.x + dotnet-version: 8.0.x - name: Restore dependencies run: dotnet restore UndertaleModCli - name: Build @@ -85,6 +86,7 @@ jobs: run: | cp ./README.md ./CLI-${{ matrix.os }}/ cp ./LICENSE.txt ./CLI-${{ matrix.os }}/ + cp -r ./UndertaleModLib/GameSpecificData/ ./CLI-${{ matrix.os }}/GameSpecificData/ - name: Upload ${{ matrix.os }} CLI uses: actions/upload-artifact@v4 with: diff --git a/README.md b/README.md index d9f46f89b..88017f768 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Note, that you can update to the bleeding edge releases at any time from within | Releases | Status | |:---: |---------- | -| Stable | [![Latest Stable Release](https://img.shields.io/github/downloads/krzys-h/UndertaleModTool/0.5.1.0/total)](https://github.com/krzys-h/UndertaleModTool/releases/tag/0.5.1.0) | +| Stable | [![Latest Stable Release](https://img.shields.io/github/downloads/krzys-h/UndertaleModTool/0.6.1.0/total)](https://github.com/krzys-h/UndertaleModTool/releases/tag/0.6.1.0) | | Bleeding edge | [![Latest Bleeding Edge](https://img.shields.io/github/downloads/krzys-h/UndertaleModTool/bleeding-edge/total)](https://github.com/krzys-h/UndertaleModTool/releases/tag/bleeding-edge) | It's worth noting that UndertaleModTool has different builds per release. The differences are as follows: diff --git a/SCRIPTS.md b/SCRIPTS.md index 6a55fa8a8..4874879f1 100644 --- a/SCRIPTS.md +++ b/SCRIPTS.md @@ -12,7 +12,7 @@ They are relatively self-explanatory, but there are also some helpful general-pu - `EnableDebug.csx`: Enables debug mode in Undertale/Deltarune. - `FindAndReplace.csx`: Tool to find and replace GML code across an entire game. - `GoToRoom.csx`: Enables a hotkey to warp to a supplied room ID in a game. -- `RunSwitchOnPC.csx`: Converts the Switch version of Undertale to run on PC (certain versions). +- `RunSwitchAndXboxOnPC.csx`: Converts the Switch and Xbox versions of Undertale to run on PC (certain versions). - `Search.csx`: Tool to search the GML code across an entire game. - `ShowRoomName.csx`: Enables an overlay to display the current room name and ID. - `TTFFonts.csx`: Marks all fonts in Undertale to be externally loaded. Does not handle Japanese text. diff --git a/Underanalyzer b/Underanalyzer index 426bff30f..31064224d 160000 --- a/Underanalyzer +++ b/Underanalyzer @@ -1 +1 @@ -Subproject commit 426bff30fe179e517b057625cbb284569e72fb95 +Subproject commit 31064224d0d2ab736a0cbd8e56eb13616e2b7bb8 diff --git a/UndertaleModCli/Program.UMTLibInherited.cs b/UndertaleModCli/Program.UMTLibInherited.cs index 64fe7b4c8..b2b1b1342 100644 --- a/UndertaleModCli/Program.UMTLibInherited.cs +++ b/UndertaleModCli/Program.UMTLibInherited.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Runtime.InteropServices; +using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -589,7 +590,7 @@ public bool LintUMTScript(string path) { CancellationTokenSource source = new CancellationTokenSource(100); CancellationToken token = source.Token; - CSharpScript.EvaluateAsync(File.ReadAllText(path), CliScriptOptions, this, typeof(IScriptInterface), token); + CSharpScript.EvaluateAsync(File.ReadAllText(path, Encoding.UTF8), CliScriptOptions.WithFilePath(path).WithFileEncoding(Encoding.UTF8), this, typeof(IScriptInterface), token); } catch (CompilationErrorException exc) { diff --git a/UndertaleModCli/Program.cs b/UndertaleModCli/Program.cs index 105ba89c9..f82724507 100644 --- a/UndertaleModCli/Program.cs +++ b/UndertaleModCli/Program.cs @@ -195,6 +195,7 @@ public Program(FileInfo datafile, FileInfo[] scripts, FileInfo output, bool verb typeof(JsonConvert).GetTypeInfo().Assembly, typeof(System.Text.RegularExpressions.Regex).GetTypeInfo().Assembly, typeof(TextureWorker).GetTypeInfo().Assembly, + typeof(ImageMagick.MagickImage).GetTypeInfo().Assembly, typeof(Underanalyzer.Decompiler.DecompileContext).Assembly) // "WithEmitDebugInformation(true)" not only lets us to see a script line number which threw an exception, // but also provides other useful debug info when we run UMT in "Debug". @@ -658,7 +659,8 @@ private void DumpAllTextures() { if (Verbose) Console.WriteLine($"Dumping {texture.Name}"); - File.WriteAllBytes($"{directory}/{texture.Name.Content}.png", texture.TextureData.TextureBlob); + using FileStream fs = new($"{directory}/{texture.Name.Content}.png", FileMode.Create); + texture.TextureData.Image.SavePng(fs); } } @@ -701,7 +703,7 @@ private void ReplaceTextureWithFile(string textureEntry, FileInfo fileToReplace) if (Verbose) Console.WriteLine("Replacing " + textureEntry); - texture.TextureData.TextureBlob = File.ReadAllBytes(fileToReplace.FullName); + texture.TextureData.Image = GMImage.FromPng(File.ReadAllBytes(fileToReplace.FullName)); } /// @@ -713,7 +715,7 @@ private void RunCSharpFile(string path) string lines; try { - lines = File.ReadAllText(path); + lines = File.ReadAllText(path, Encoding.UTF8); } catch (Exception exc) { @@ -740,7 +742,7 @@ private void RunCSharpCode(string code, string scriptFile = null) try { - CSharpScript.EvaluateAsync(code, CliScriptOptions, this, typeof(IScriptInterface)).GetAwaiter().GetResult(); + CSharpScript.EvaluateAsync(code, CliScriptOptions.WithFilePath(scriptFile ?? "").WithFileEncoding(Encoding.UTF8), this, typeof(IScriptInterface)).GetAwaiter().GetResult(); ScriptExecutionSuccess = true; ScriptErrorMessage = ""; } @@ -870,4 +872,4 @@ private void ProgressUpdater() Thread.Sleep(100); //10 times per second } } -} \ No newline at end of file +} diff --git a/UndertaleModCli/UndertaleModCli.csproj b/UndertaleModCli/UndertaleModCli.csproj index 86c98c05f..0ef164e2f 100644 --- a/UndertaleModCli/UndertaleModCli.csproj +++ b/UndertaleModCli/UndertaleModCli.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net8.0 AnyCPU;x64 disable LatestMajor @@ -15,7 +15,7 @@ - + diff --git a/UndertaleModLib/Compiler/AssemblyWriter.cs b/UndertaleModLib/Compiler/AssemblyWriter.cs index 0cd30d5ca..85263718b 100644 --- a/UndertaleModLib/Compiler/AssemblyWriter.cs +++ b/UndertaleModLib/Compiler/AssemblyWriter.cs @@ -2169,7 +2169,15 @@ private static void AssembleVariablePush(CodeWriter cw, Parser.Statement e, out cw.typeStack.Push(DataType.Variable); if (notLast) { - cw.Emit(Opcode.Conv, cw.typeStack.Pop(), DataType.Int32); + if (CompileContext.GMS2_3) + { + cw.typeStack.Pop(); + cw.Emit(Opcode.PushI, DataType.Int16).Value = (short)-9; // stacktop conversion + } + else + { + cw.Emit(Opcode.Conv, cw.typeStack.Pop(), DataType.Int32); + } } else isArray = true; @@ -2190,7 +2198,15 @@ private static void AssembleVariablePush(CodeWriter cw, Parser.Statement e, out cw.typeStack.Push(DataType.Variable); if (next + 1 < e.Children.Count) { - cw.Emit(Opcode.Conv, cw.typeStack.Pop(), DataType.Int32); + if (CompileContext.GMS2_3) + { + cw.typeStack.Pop(); + cw.Emit(Opcode.PushI, DataType.Int16).Value = (short)-9; // stacktop conversion + } + else + { + cw.Emit(Opcode.Conv, cw.typeStack.Pop(), DataType.Int32); + } } } } @@ -2464,7 +2480,18 @@ private static void AssembleStoreVariable(CodeWriter cw, Parser.Statement s, Dat VarType = s.Children[next].Children.Count != 0 ? VariableType.Array : VariableType.StackTop }); if (next + 1 < s.Children.Count) - cw.Emit(Opcode.Conv, DataType.Variable, DataType.Int32); + { + if (CompileContext.GMS2_3) + { + cw.typeStack.Pop(); + cw.Emit(Opcode.PushI, DataType.Int16).Value = (short)-9; // stacktop conversion + cw.typeStack.Push(DataType.Int32); + } + else + { + cw.Emit(Opcode.Conv, DataType.Variable, DataType.Int32); + } + } } if (!skip) cw.typeStack.Pop(); diff --git a/UndertaleModLib/Compiler/Lexer.cs b/UndertaleModLib/Compiler/Lexer.cs index ba87c350a..74b63bc9e 100644 --- a/UndertaleModLib/Compiler/Lexer.cs +++ b/UndertaleModLib/Compiler/Lexer.cs @@ -521,7 +521,6 @@ private static Token ReadIdentifier(CodeReader cr) "globalvar" => new Token(Token.TokenKind.KeywordGlobalVar, cr.GetPositionInfo(index)), "return" => new Token(Token.TokenKind.KeywordReturn, cr.GetPositionInfo(index)), "default" => new Token(Token.TokenKind.KeywordDefault, cr.GetPositionInfo(index)), - "struct" => new Token(Token.TokenKind.KeywordStruct, cr.GetPositionInfo(index)), "function" when CompileContext.GMS2_3 => new Token(Token.TokenKind.KeywordFunction, cr.GetPositionInfo(index)), "throw" when CompileContext.GMS2_3 => new Token(Token.TokenKind.KeywordThrow, cr.GetPositionInfo(index)), "constructor" when CompileContext.GMS2_3 => new Token(Token.TokenKind.KeywordConstructor, cr.GetPositionInfo(index)), @@ -844,7 +843,6 @@ public enum TokenKind KeywordExit, KeywordBreak, KeywordContinue, - KeywordStruct, // Apparently this exists KeywordFunction, KeywordThrow, KeywordConstructor, diff --git a/UndertaleModLib/Compiler/Parser.cs b/UndertaleModLib/Compiler/Parser.cs index 0d75c87b3..beedb06a9 100644 --- a/UndertaleModLib/Compiler/Parser.cs +++ b/UndertaleModLib/Compiler/Parser.cs @@ -3134,7 +3134,6 @@ private static bool IsKeyword(TokenKind t) TokenKind.KeywordIf, TokenKind.KeywordRepeat, TokenKind.KeywordReturn, - TokenKind.KeywordStruct, TokenKind.KeywordSwitch, TokenKind.KeywordThen, TokenKind.KeywordUntil, diff --git a/UndertaleModLib/Decompiler/GameSpecificResolver.cs b/UndertaleModLib/Decompiler/GameSpecificResolver.cs index e5719c3cf..b5becbb7d 100644 --- a/UndertaleModLib/Decompiler/GameSpecificResolver.cs +++ b/UndertaleModLib/Decompiler/GameSpecificResolver.cs @@ -1,50 +1,181 @@ using System; -using System.ComponentModel; -using System.Diagnostics; -using System.Globalization; +using System.Collections.Generic; using System.IO; -using System.Reflection; -using System.Text; -using UndertaleModLib.Scripting; +using System.Text.Json; +using System.Text.RegularExpressions; namespace UndertaleModLib.Decompiler; +/// +/// Class that helps load game-specific data, which tailor some features (such as the decompiler) to specific games. +/// public class GameSpecificResolver { - // Reads a built-in game-specific data file from the assembly - private static ReadOnlySpan ReadGameSpecificDataFile(string filename) + /// + /// Base app directory used for locating the "GameSpecificData" directory, which should be immediately inside. + /// + public static string BaseDirectory { get; set; } = AppContext.BaseDirectory; + + private enum ConditionResult + { + Ignore, + Accept, + Reject + } + + private static readonly Dictionary> _conditionEvaluators = new() { - Assembly assembly = Assembly.GetExecutingAssembly(); - string resourceName = $"UndertaleModLib.BuiltinGameSpecificData.{filename}"; + ["Always"] = (UndertaleData data, string value) => + { + return ConditionResult.Accept; + }, + + ["DisplayName.Regex"] = (UndertaleData data, string value) => + { + string displayName = data?.GeneralInfo?.DisplayName?.Content; + if (displayName is null) + { + return ConditionResult.Ignore; + } + + Match m = Regex.Match(displayName, value, RegexOptions.CultureInvariant); + if (m.Success) + { + return ConditionResult.Accept; + } + + return ConditionResult.Ignore; + }, + }; + private static readonly List _definitions = new(); + private static bool _loadedDefinitions = false; + + public class GameSpecificCondition + { + /// + /// Represents the kind of condition to be evaluated. + /// + public string ConditionKind { get; set; } + + /// + /// Value to be used during evaluation of condition, if applicable. + /// + public string Value { get; set; } + } + + public class GameSpecificDefinition + { + /// + /// Integer representing the order this definition should be evaluated/loaded in. + /// The lower this number is, the earlier this definition will be evaluated and loaded. + /// + /// + /// Built-in definitions currently use values 0 (for GameMaker builtins) and 1 (for games). + /// + public int LoadOrder { get; set; } = 100; + + /// + /// List of conditions that will be evaluated sequentially, to match this game-specific definition. + /// + public List Conditions { get; set; } + + /// + /// Filename to be loaded as an Underanalyzer game-specific config, when this definition successfully matches. + /// If empty, null, or the file is otherwise nonexistent, this will be ignored. + /// + public string UnderanalyzerFilename { get; set; } + + /// + /// Evaluates this game-specific definition against the given game data, returning whether this definition should be loaded. + /// + /// True if this definition should be loaded; false otherwise. + public bool Evaluate(UndertaleData data) + { + foreach (var condition in Conditions) + { + switch (_conditionEvaluators[condition.ConditionKind](data, condition.Value)) + { + case ConditionResult.Accept: + return true; + case ConditionResult.Reject: + return false; + case ConditionResult.Ignore: + // Pass-through to next condition + break; + } + } + + // Default to reject + return false; + } - using Stream stream = assembly.GetManifestResourceStream(resourceName); - using StreamReader reader = new(stream); - return reader.ReadToEnd(); + /// + /// Loads this game-specific definition. + /// + public void Load(UndertaleData data) + { + if (!string.IsNullOrEmpty(UnderanalyzerFilename)) + { + string underanalyzerPath = Path.Combine(BaseDirectory, "GameSpecificData", "Underanalyzer", UnderanalyzerFilename); + if (File.Exists(underanalyzerPath)) + { + data.GameSpecificRegistry.DeserializeFromJson(File.ReadAllText(underanalyzerPath)); + } + } + } } /// - /// Initializes the registry of game-specific data for the given game. + /// Forces a full reload of all game-specific definition files. /// - public static void Initialize(UndertaleData data) + public static void ReloadDefinitions() { - data.GameSpecificRegistry = new(); + // Mark definitions as loaded, and reset existing definitions + _loadedDefinitions = true; - // TODO: make proper file/manifest for all games to use, not just UT/DR, and also not these specific names + // Scan directory for files, if it exists + string dir = Path.Combine(BaseDirectory, "GameSpecificData", "Definitions"); + if (Directory.Exists(dir)) + { + foreach (string file in Directory.EnumerateFiles(dir, "*.json", SearchOption.TopDirectoryOnly)) + { + _definitions.Add(JsonSerializer.Deserialize(File.ReadAllText(file))); + } + } - var foundGame = false; + // Sort all definitions by their load order + _definitions.Sort((a, b) => a.LoadOrder); + } - // Read registry data files - string lowerName = data?.GeneralInfo?.DisplayName?.Content.ToLower(CultureInfo.InvariantCulture) ?? ""; - data.GameSpecificRegistry.DeserializeFromJson(ReadGameSpecificDataFile("gamemaker.json")); - if (lowerName.StartsWith("undertale", StringComparison.InvariantCulture) || lowerName.StartsWith("under", StringComparison.InvariantCulture)) + /// + /// Loads game-specific definitions, if not loaded already. Call to force a full reload. + /// + public static void LoadDefinitions() + { + if (!_loadedDefinitions) { - data.GameSpecificRegistry.DeserializeFromJson(ReadGameSpecificDataFile("undertale.json")); - foundGame = true; + ReloadDefinitions(); } - if (lowerName == "survey_program" || lowerName.StartsWith("deltarune", StringComparison.InvariantCulture) || lowerName.StartsWith("delta", StringComparison.InvariantCulture)) + } + + /// + /// Initializes the registry of game-specific data for the given game. + /// + public static void Initialize(UndertaleData data) + { + // Ensure all definitions are loaded + LoadDefinitions(); + + // Initialize empty game-specific registry for decompiler + data.GameSpecificRegistry = new(); + + // Evaluate all definitions, and load all successful ones + foreach (var definition in _definitions) { - data.GameSpecificRegistry.DeserializeFromJson(ReadGameSpecificDataFile("deltarune.json")); - foundGame = true; + if (definition.Evaluate(data)) + { + definition.Load(data); + } } if (!foundGame && File.Exists(lowerName + ".json")) { diff --git a/UndertaleModLib/GameSpecificData/Definitions/deltarune.json b/UndertaleModLib/GameSpecificData/Definitions/deltarune.json new file mode 100644 index 000000000..ab08e0218 --- /dev/null +++ b/UndertaleModLib/GameSpecificData/Definitions/deltarune.json @@ -0,0 +1,14 @@ +{ + "LoadOrder": 1, + "Conditions": [ + { + "ConditionKind": "DisplayName.Regex", + "Value": "(?i)^SURVEY_PROGRAM$" + }, + { + "ConditionKind": "DisplayName.Regex", + "Value": "(?i)^deltarune" + } + ], + "UnderanalyzerFilename": "deltarune.json" +} \ No newline at end of file diff --git a/UndertaleModLib/GameSpecificData/Definitions/gamemaker.json b/UndertaleModLib/GameSpecificData/Definitions/gamemaker.json new file mode 100644 index 000000000..dcf9cae1e --- /dev/null +++ b/UndertaleModLib/GameSpecificData/Definitions/gamemaker.json @@ -0,0 +1,9 @@ +{ + "LoadOrder": 0, + "Conditions": [ + { + "ConditionKind": "Always" + } + ], + "UnderanalyzerFilename": "gamemaker.json" +} \ No newline at end of file diff --git a/UndertaleModLib/GameSpecificData/Definitions/undertale.json b/UndertaleModLib/GameSpecificData/Definitions/undertale.json new file mode 100644 index 000000000..3f63150ee --- /dev/null +++ b/UndertaleModLib/GameSpecificData/Definitions/undertale.json @@ -0,0 +1,10 @@ +{ + "LoadOrder": 1, + "Conditions": [ + { + "ConditionKind": "DisplayName.Regex", + "Value": "(?i)^undertale" + } + ], + "UnderanalyzerFilename": "undertale.json" +} \ No newline at end of file diff --git a/UndertaleModLib/GameSpecificData/README.txt b/UndertaleModLib/GameSpecificData/README.txt new file mode 100644 index 000000000..d29b42d13 --- /dev/null +++ b/UndertaleModLib/GameSpecificData/README.txt @@ -0,0 +1,5 @@ +This folder contains data for specific games, to improve the modding experience when using certain features. + +Games are defined in the "Definitions" sub-folder, where they are given conditions to match the game(s) they target. Any JSON files from this folder are loaded automatically, if available. + +GML decompiler configs are contained within the "Underanalyzer" sub-folder, referenced from the original definition files. (These are loaded on-demand.) diff --git a/UndertaleModLib/BuiltinGameSpecificData/deltaruined.json b/UndertaleModLib/GameSpecificData/Underanalyzer/deltaruined.json similarity index 100% rename from UndertaleModLib/BuiltinGameSpecificData/deltaruined.json rename to UndertaleModLib/GameSpecificData/Underanalyzer/deltaruined.json diff --git a/UndertaleModLib/BuiltinGameSpecificData/deltarune.json b/UndertaleModLib/GameSpecificData/Underanalyzer/deltarune.json similarity index 100% rename from UndertaleModLib/BuiltinGameSpecificData/deltarune.json rename to UndertaleModLib/GameSpecificData/Underanalyzer/deltarune.json diff --git a/UndertaleModLib/BuiltinGameSpecificData/gamemaker.json b/UndertaleModLib/GameSpecificData/Underanalyzer/gamemaker.json similarity index 100% rename from UndertaleModLib/BuiltinGameSpecificData/gamemaker.json rename to UndertaleModLib/GameSpecificData/Underanalyzer/gamemaker.json diff --git a/UndertaleModLib/BuiltinGameSpecificData/empty.json b/UndertaleModLib/GameSpecificData/Underanalyzer/template.json similarity index 100% rename from UndertaleModLib/BuiltinGameSpecificData/empty.json rename to UndertaleModLib/GameSpecificData/Underanalyzer/template.json diff --git a/UndertaleModLib/BuiltinGameSpecificData/undertale.json b/UndertaleModLib/GameSpecificData/Underanalyzer/undertale.json similarity index 100% rename from UndertaleModLib/BuiltinGameSpecificData/undertale.json rename to UndertaleModLib/GameSpecificData/Underanalyzer/undertale.json diff --git a/UndertaleModLib/Models/UndertaleCode.cs b/UndertaleModLib/Models/UndertaleCode.cs index 5d8f76d51..9568f7f34 100644 --- a/UndertaleModLib/Models/UndertaleCode.cs +++ b/UndertaleModLib/Models/UndertaleCode.cs @@ -111,6 +111,10 @@ Opcode.PushBltn or Opcode.PushI _ => throw new IOException("Unknown opcode " + op.ToString().ToUpper(CultureInfo.InvariantCulture)), }; } + + /// + /// Converts from bytecode 14 instruction opcodes to modern opcodes. + /// private static byte ConvertInstructionKind(byte kind) { kind = kind switch @@ -383,102 +387,131 @@ public Reference GetReference(bool allowResolve = false) where T : class, /// public void Serialize(UndertaleWriter writer) { + // Flag tracking whether we're writing bytecode 14 (old) instructions + bool bytecode14 = writer.Bytecode14OrLower; + + // Switch on the basic format of instruction to encode switch (GetInstructionType(Kind)) { case InstructionType.SingleTypeInstruction: case InstructionType.DoubleTypeInstruction: case InstructionType.ComparisonInstruction: + { + // Write "extra" byte, used on some instructions + writer.Write(Extra); + + // Write comparison kind, if present + if (bytecode14 && Kind == Opcode.Cmp) { - writer.Write(Extra); - if (writer.Bytecode14OrLower && Kind == Opcode.Cmp) - writer.Write((byte)0); - else - writer.Write((byte)ComparisonKind); - byte TypePair = (byte)((byte)Type2 << 4 | (byte)Type1); - writer.Write(TypePair); + // Bytecode 14 encodes its comparison in the opcode itself, not here + writer.Write((byte)0); + } + else + { + // Bytecode 15 and above encode a comparison kind outside of the opcode + writer.Write((byte)ComparisonKind); + } - if (writer.Bytecode14OrLower) + // Write types + byte typePair = (byte)((byte)Type2 << 4 | (byte)Type1); + writer.Write(typePair); + + // Write opcode + if (bytecode14) + { + // Translate relevant opcodes to their old bytecode 14 equivalents + byte k = Kind switch { - byte k = Kind switch - { - Opcode.Conv => 0x03, - Opcode.Mul => 0x04, - Opcode.Div => 0x05, - Opcode.Rem => 0x06, - Opcode.Mod => 0x07, - Opcode.Add => 0x08, - Opcode.Sub => 0x09, - Opcode.And => 0x0A, - Opcode.Or => 0x0B, - Opcode.Xor => 0x0C, - Opcode.Neg => 0x0D, - Opcode.Not => 0x0E, - Opcode.Shl => 0x0F, - Opcode.Shr => 0x10, - Opcode.Dup => 0x82, - Opcode.Cmp => (byte)(ComparisonKind + 0x10), - Opcode.Ret => 0x9D, - Opcode.Exit => 0x9E, - Opcode.Popz => 0x9F, - _ => (byte)Kind, - }; - writer.Write(k); - } - else - writer.Write((byte)Kind); + Opcode.Conv => 0x03, + Opcode.Mul => 0x04, + Opcode.Div => 0x05, + Opcode.Rem => 0x06, + Opcode.Mod => 0x07, + Opcode.Add => 0x08, + Opcode.Sub => 0x09, + Opcode.And => 0x0A, + Opcode.Or => 0x0B, + Opcode.Xor => 0x0C, + Opcode.Neg => 0x0D, + Opcode.Not => 0x0E, + Opcode.Shl => 0x0F, + Opcode.Shr => 0x10, + Opcode.Dup => 0x82, + Opcode.Cmp => (byte)(ComparisonKind + 0x10), // Comparison kind is encoded into opcode + Opcode.Ret => 0x9D, + Opcode.Exit => 0x9E, + Opcode.Popz => 0x9F, + _ => (byte)Kind, + }; + writer.Write(k); } + else + { + // Write opcode verbatim on modern bytecode versions + writer.Write((byte)Kind); + } + break; + } case InstructionType.GotoInstruction: + { + // Write jump offset + if (bytecode14) { - // See unserialize - if (writer.Bytecode14OrLower) - writer.WriteInt24(JumpOffset); - else if (JumpOffsetPopenvExitMagic) - { - writer.WriteInt24(0xF00000); - } - else - { - uint JumpOffsetFixed = (uint)JumpOffset; - JumpOffsetFixed &= ~0xFF800000; - writer.WriteInt24((int)JumpOffsetFixed); - } + // Bytecode 14 writes the offset verbatim + writer.WriteInt24(JumpOffset); + } + else if (JumpOffsetPopenvExitMagic) + { + // If popenv exit magic is used, write that specifically + writer.WriteInt24(0xF00000); + } + else + { + // If not using popenv exit magic, encode jump offset as 23-bit signed integer + uint jumpOffsetFixed = (uint)JumpOffset; + jumpOffsetFixed &= ~0xFF800000; + writer.WriteInt24((int)jumpOffsetFixed); + } - if (writer.Bytecode14OrLower) + // Write opcode + if (bytecode14) + { + // Translate relevant opcodes to their old bytecode 14 equivalents + byte k = Kind switch { - if (Kind == Opcode.B) - writer.Write((byte)0xB7); - else if (Kind == Opcode.Bt) - writer.Write((byte)0xB8); - else if (Kind == Opcode.Bf) - writer.Write((byte)0xB9); - else if (Kind == Opcode.PushEnv) - writer.Write((byte)0xBB); - else if (Kind == Opcode.PopEnv) - writer.Write((byte)0xBC); - else - writer.Write((byte)Kind); - } - else - writer.Write((byte)Kind); + Opcode.B => 0xB7, + Opcode.Bt => 0xB8, + Opcode.Bf => 0xB9, + Opcode.PushEnv => 0xBB, + Opcode.PopEnv => 0xBC, + _ => (byte)Kind + }; + writer.Write(k); + } + else + { + // Write opcode verbatim on modern bytecode versions + writer.Write((byte)Kind); } + break; + } case InstructionType.PopInstruction: { - if (Type1 == DataType.Int16) - { - // Special scenario - the swap instruction - // TODO: Figure out the proper syntax, see #129 - writer.Write(SwapExtra); - byte TypePair = (byte)((byte)Type2 << 4 | (byte)Type1); - writer.Write(TypePair); - if (writer.Bytecode14OrLower && Kind == Opcode.Pop) - writer.Write((byte)0x41); - else - writer.Write((byte)Kind); - } + // Special scenario - the swap instruction (see #129) + // Write swap value + writer.Write(SwapExtra); + + // Write types + byte typePair = (byte)((byte)Type2 << 4 | (byte)Type1); + writer.Write(typePair); + + // Write opcode (if writing bytecode 14, translate to the old opcode) + if (bytecode14 && Kind == Opcode.Pop) + writer.Write((byte)0x41); else { writer.Write((short)TypeInst); @@ -491,87 +524,124 @@ public void Serialize(UndertaleWriter writer) writer.WriteUndertaleObject(Destination); } } + else + { + // Write instance type + writer.Write((short)TypeInst); + + // Write types + byte typePair = (byte)((byte)Type2 << 4 | (byte)Type1); + writer.Write(typePair); + + // Write opcode (if writing bytecode 14, translate to the old opcode) + if (bytecode14 && Kind == Opcode.Pop) + writer.Write((byte)0x41); + else + writer.Write((byte)Kind); + + // Write actual variable being stored to + writer.WriteUndertaleObject(Destination); + } + break; + } case InstructionType.PushInstruction: + { + // Write 16-bit integer, instance type, or empty data + writer.Write(Type1 switch { - if (Type1 == DataType.Int16) - { - //Debug.Assert(Value.GetType() == typeof(short)); - writer.Write((short)Value); - } - else if (Type1 == DataType.Variable) - { - writer.Write((short)TypeInst); - } - else - { - writer.Write((short)0); - } - writer.Write((byte)Type1); - if (writer.Bytecode14OrLower) - writer.Write((byte)0xC0); - else - writer.Write((byte)Kind); - switch (Type1) - { - case DataType.Double: - //Debug.Assert(Value.GetType() == typeof(double)); - writer.Write((double)Value); - break; - case DataType.Float: - //Debug.Assert(Value.GetType() == typeof(float)); - writer.Write((float)Value); - break; - case DataType.Int32: - if (Value.GetType() == typeof(Reference)) - { - writer.WriteUndertaleObject((Reference)Value); - break; - } - if (Value.GetType() == typeof(Reference)) - { - writer.WriteUndertaleObject((Reference)Value); - break; - } - //Debug.Assert(Value.GetType() == typeof(int)); - writer.Write((int)Value); - break; - case DataType.Int64: - //Debug.Assert(Value.GetType() == typeof(long)); - writer.Write((long)Value); - break; - case DataType.Boolean: - //Debug.Assert(Value.GetType() == typeof(bool)); - writer.Write((bool)Value ? 1 : 0); + DataType.Int16 => (short)Value, + DataType.Variable => (short)TypeInst, + _ => (short)0 + }); + + // Write type (no second type is used) + writer.Write((byte)Type1); + + // Write opcode (if writing bytecode 14, translate to the old opcode) + if (bytecode14) + writer.Write((byte)0xC0); + else + writer.Write((byte)Kind); + + // Write value being pushed + switch (Type1) + { + case DataType.Double: + writer.Write((double)Value); + break; + case DataType.Float: + writer.Write((float)Value); + break; + case DataType.Int32: + if (Value.GetType() == typeof(Reference)) + { + // Write function reference, rather than integer + writer.WriteUndertaleObject((Reference)Value); break; - case DataType.Variable: - //Debug.Assert(Value.GetType() == typeof(Reference)); + } + if (Value.GetType() == typeof(Reference)) + { + // Write variable reference, rather than integer writer.WriteUndertaleObject((Reference)Value); break; - case DataType.String: - //Debug.Assert(Value.GetType() == typeof(UndertaleResourceById)); - writer.WriteUndertaleObject((UndertaleResourceById)Value); - break; - case DataType.Int16: - break; - } + } + writer.Write((int)Value); + break; + case DataType.Int64: + writer.Write((long)Value); + break; + case DataType.Boolean: + writer.Write((bool)Value ? 1 : 0); + break; + case DataType.Variable: + writer.WriteUndertaleObject((Reference)Value); + break; + case DataType.String: + writer.WriteUndertaleObject((UndertaleResourceById)Value); + break; + case DataType.Int16: + // Data is encoded in the first two bytes of the instruction (was already written above) + break; } + break; + } case InstructionType.CallInstruction: - { - writer.Write(ArgumentsCount); - writer.Write((byte)Type1); - if (writer.Bytecode14OrLower && Kind == Opcode.Call) - writer.Write((byte)0xDA); - else - writer.Write((byte)Kind); - writer.WriteUndertaleObject(Function); - } + { + // Write number of arguments being used in call + writer.Write(ArgumentsCount); + + // Write type (no second type is used) + writer.Write((byte)Type1); + + // Write opcode (if writing bytecode 14, translate to the old opcode) + if (bytecode14 && Kind == Opcode.Call) + writer.Write((byte)0xDA); + else + writer.Write((byte)Kind); + + // Write reference to the function being called + writer.WriteUndertaleObject(Function); + break; + } case InstructionType.BreakInstruction: + { + // Write type of break instruction (encoded in Value) + writer.Write((short)Value); + + // Write type (no second type is used) + writer.Write((byte)Type1); + + // Write opcode + writer.Write((byte)Kind); + + // Write integer argument, or function, if either is present + if (Type1 == DataType.Int32) { //Debug.Assert(Value.GetType() == typeof(short)); writer.Write((short)Value); @@ -586,190 +656,240 @@ public void Serialize(UndertaleWriter writer) } } break; + } default: - throw new IOException("Unknown opcode " + Kind.ToString().ToUpper(CultureInfo.InvariantCulture)); + throw new IOException($"Unknown opcode {Kind.ToString().ToUpper(CultureInfo.InvariantCulture)}"); } } /// public void Unserialize(UndertaleReader reader) { - long instructionStartAddress = reader.Position; - reader.Position += 3; // skip for now, we'll read them later - byte kind = reader.ReadByte(); - if (reader.Bytecode14OrLower) + // Flag tracking whether we're parsing bytecode 14 (old) instructions + bool bytecode14 = reader.Bytecode14OrLower; + + // Read first word from instruction + uint firstWord = reader.ReadUInt32(); + + // Read opcode from most significant byte + byte kindByte = (byte)((firstWord & 0xFF000000) >> 24); + Opcode kind = (Opcode)kindByte; + if (bytecode14) { - // Convert opcode to our enum - kind = ConvertInstructionKind(kind); + // Convert opcode from old format to new format + kind = (Opcode)ConvertInstructionKind(kindByte); } - Kind = (Opcode)kind; - reader.Position = instructionStartAddress; - switch (GetInstructionType(Kind)) + + // Extract first three bytes from first word + byte b0 = (byte)(firstWord & 0x000000FF); + byte b1 = (byte)((firstWord & 0x0000FF00) >> 8); + byte b2 = (byte)((firstWord & 0x00FF0000) >> 16); + + // Parse instruction contents + InstructionType instructionType = GetInstructionType(kind); + switch (instructionType) { case InstructionType.SingleTypeInstruction: case InstructionType.DoubleTypeInstruction: case InstructionType.ComparisonInstruction: + { + // Parse instruction components from bytes + byte extra = b0; + ComparisonType comparisonKind = (ComparisonType)b1; + DataType type1 = (DataType)(b2 & 0xf); + DataType type2 = (DataType)(b2 >> 4); + + // Ensure basic conditions hold + if (extra != 0 && kind != Opcode.Dup && kind != Opcode.CallV) { - Extra = reader.ReadByte(); -#if DEBUG - if (Extra != 0 && Kind != Opcode.Dup && Kind != Opcode.CallV) - throw new IOException("Invalid padding in " + Kind.ToString().ToUpper(CultureInfo.InvariantCulture)); -#endif - ComparisonKind = (ComparisonType)reader.ReadByte(); - //if (!bytecode14 && (Kind == Opcode.Cmp) != ((byte)ComparisonKind != 0)) - // throw new IOException("Got unexpected comparison type in " + Kind.ToString().ToUpper(CultureInfo.InvariantCulture) + " (should be only in CMP)"); - byte TypePair = reader.ReadByte(); - Type1 = (DataType)(TypePair & 0xf); - Type2 = (DataType)(TypePair >> 4); -#if DEBUG - if (GetInstructionType(Kind) == InstructionType.SingleTypeInstruction && Type2 != (byte)0) - throw new IOException("Second type should be 0 in " + Kind.ToString().ToUpper(CultureInfo.InvariantCulture)); -#endif - //if(reader.ReadByte() != (byte)Kind) throw new Exception("really shouldn't happen"); - if (reader.Bytecode14OrLower && Kind == Opcode.Cmp) - ComparisonKind = (ComparisonType)(reader.ReadByte() - 0x10); - else - reader.Position++; + throw new IOException($"Invalid padding in {kind.ToString().ToUpper(CultureInfo.InvariantCulture)}"); + } - if (Kind == Opcode.And || Kind == Opcode.Or) - { - if (Type1 == DataType.Boolean && Type2 == DataType.Boolean) - reader.undertaleData.ShortCircuit = false; - } + if (instructionType == InstructionType.SingleTypeInstruction && type2 != 0) + { + throw new IOException($"Second type should be 0 in {kind.ToString().ToUpper(CultureInfo.InvariantCulture)}"); + } + + + // In bytecode 14, the comparison kind is encoded in the opcode itself + if (bytecode14 && kind == Opcode.Cmp) + { + comparisonKind = (ComparisonType)(kindByte - 0x10); } + + // Check for "and.b.b" or "or.b.b", which imply the code was compiled without short-circuiting + if ((kind is Opcode.And or Opcode.Or) && type1 == DataType.Boolean && type2 == DataType.Boolean) + { + reader.undertaleData.ShortCircuit = false; + } + + // Assign to instruction + Extra = extra; + ComparisonKind = comparisonKind; + Type1 = type1; + Type2 = type2; + break; + } case InstructionType.GotoInstruction: + { + if (bytecode14) { - if (reader.Bytecode14OrLower) - { - JumpOffset = reader.ReadInt24(); - if (JumpOffset == -1048576) // magic? encoded in little endian as 00 00 F0, which is like below - JumpOffsetPopenvExitMagic = true; - reader.Position++; - break; - } + // Bytecode 14 has slightly different parsing + int jumpOffset = b0 | (b1 << 8) | ((sbyte)b2 << 16); + JumpOffset = jumpOffset; + JumpOffsetPopenvExitMagic = (jumpOffset == -1048576); // encoded in little endian as 00 00 F0 (same as below) + break; + } - uint v = reader.ReadUInt24(); + // Parse normally + uint v = (uint)(b0 | (b1 << 8) | (b2 << 16)); + bool popenvExitMagic = (v & 0x800000) != 0; + if (popenvExitMagic && v != 0xF00000) + { + throw new Exception("Popenv magic doesn't work, call issue #90 again"); + } - JumpOffsetPopenvExitMagic = (v & 0x800000) != 0; + // The rest is int23 signed value, so make sure + uint r = v & 0x003FFFFF; + if ((v & 0x00C00000) != 0) + { + r |= 0xFFC00000; + } - // The rest is int23 signed value, so make sure - uint r = v & 0x003FFFFF; -#if DEBUG - if (JumpOffsetPopenvExitMagic && v != 0xF00000) - throw new Exception("Popenv magic doesn't work, call issue #90 again"); - else -#endif - { - if ((v & 0x00C00000) != 0) - r |= 0xFFC00000; - JumpOffset = (int)r; - } + // Assign to instruction + JumpOffset = (int)r; + JumpOffsetPopenvExitMagic = popenvExitMagic; - //if(reader.ReadByte() != (byte)Kind) throw new Exception("really shouldn't happen"); - reader.Position++; - } break; + } case InstructionType.PopInstruction: + { + // Parse instruction components from bytes + InstanceType typeInst = (InstanceType)(b0 | (b1 << 8)); + DataType type1 = (DataType)(b2 & 0xf); + DataType type2 = (DataType)(b2 >> 4); + + if (type1 == DataType.Int16) { - TypeInst = (InstanceType)reader.ReadInt16(); - byte TypePair = reader.ReadByte(); - Type1 = (DataType)(TypePair & 0xf); - Type2 = (DataType)(TypePair >> 4); - //if(reader.ReadByte() != (byte)Kind) throw new Exception("really shouldn't happen"); - reader.Position++; - if (Type1 == DataType.Int16) - { - // Special scenario - the swap instruction - // TODO: Figure out the proper syntax, see #129 - SwapExtra = (ushort)TypeInst; - TypeInst = 0; - } - else - { - Destination = reader.ReadUndertaleObject>(); - } + // Special scenario - the swap instruction (see #129) + SwapExtra = (ushort)typeInst; + typeInst = 0; } + else + { + // Destination is an actual variable + Destination = reader.ReadUndertaleObject>(); + } + + // Assign remaining values to instruction + TypeInst = typeInst; + Type1 = type1; + Type2 = type2; + break; + } case InstructionType.PushInstruction: + { + // Parse instruction components from bytes + short val = (short)(b0 | (b1 << 8)); + DataType type1 = (DataType)b2; + + // Modify opcode of instruction, if in bytecode 14 + if (bytecode14) { - short val = reader.ReadInt16(); - Type1 = (DataType)reader.ReadByte(); - if (reader.Bytecode14OrLower) + if (type1 == DataType.Variable) { if (Type1 == DataType.Variable) { - switch (val) - { - case -5: - Kind = Opcode.PushGlb; - break; - case -6: // builtin - Kind = Opcode.PushBltn; - break; - case -7: - Kind = Opcode.PushLoc; - break; - } - } - else if (Type1 == DataType.Int16) - { - Kind = Opcode.PushI; + case -5: + kind = Opcode.PushGlb; + break; + case -6: // builtin + kind = Opcode.PushBltn; + break; + case -7: + kind = Opcode.PushLoc; + break; } } - //if(reader.ReadByte() != (byte)Kind) throw new Exception("really shouldn't happen"); - reader.Position++; - switch (Type1) + else if (type1 == DataType.Int16) { - case DataType.Double: - Value = reader.ReadDouble(); - break; - case DataType.Float: - Value = reader.ReadSingle(); - break; - case DataType.Int32: - Value = reader.ReadInt32(); - break; - case DataType.Int64: - Value = reader.ReadInt64(); - break; - case DataType.Boolean: - Value = (reader.ReadUInt32() == 1); // TODO: double check - break; - case DataType.Variable: - TypeInst = (InstanceType)val; - Value = reader.ReadUndertaleObject>(); - break; - case DataType.String: - Value = reader.ReadUndertaleObject>(); - break; - case DataType.Int16: - Value = val; - break; + kind = Opcode.PushI; } } - break; - case InstructionType.CallInstruction: + // Parse data being pushed + switch (type1) { - ArgumentsCount = reader.ReadUInt16(); - Type1 = (DataType)reader.ReadByte(); - //if(reader.ReadByte() != (byte)Kind) throw new Exception("really shouldn't happen"); - reader.Position++; - Function = reader.ReadUndertaleObject>(); + case DataType.Double: + Value = reader.ReadDouble(); + break; + case DataType.Float: + Value = reader.ReadSingle(); + break; + case DataType.Int32: + Value = reader.ReadInt32(); + break; + case DataType.Int64: + Value = reader.ReadInt64(); + break; + case DataType.Boolean: + Value = reader.ReadBoolean(); + break; + case DataType.Variable: + TypeInst = (InstanceType)val; + Value = reader.ReadUndertaleObject>(); + break; + case DataType.String: + Value = reader.ReadUndertaleObject>(); + break; + case DataType.Int16: + Value = val; + break; } + + // Assign remaining values to instruction + Type1 = type1; + + break; + } + + case InstructionType.CallInstruction: + { + // Parse instruction components from bytes + ArgumentsCount = (ushort)(b0 | (b1 << 8)); + Type1 = (DataType)b2; + + // Parse function being called + Function = reader.ReadUndertaleObject>(); + break; + } case InstructionType.BreakInstruction: + { + // Parse instruction components from bytes + short value = (short)(b0 | (b1 << 8)); + DataType type1 = (DataType)b2; + + // Parse integer argument, if provided + if (type1 == DataType.Int32) { - Value = reader.ReadInt16(); - Type1 = (DataType)reader.ReadByte(); - if (reader.ReadByte() != (byte)Kind) throw new Exception("really shouldn't happen"); - if (Type1 == DataType.Int32) + IntArgument = reader.ReadInt32(); + + // Existence of this argument implies GameMaker 2023.8 or above + if (!reader.undertaleData.IsVersionAtLeast(2023, 8)) + { + reader.undertaleData.SetGMS2Version(2023, 8); + } + + // If this is an asset type found in GameMaker 2024.4 or above, track that as well + if (!reader.undertaleData.IsVersionAtLeast(2024, 4)) { IntArgument = reader.ReadInt32(); if (!reader.undertaleData.IsVersionAtLeast(2023, 8)) @@ -789,89 +909,116 @@ public void Unserialize(UndertaleReader reader) } } } + + // If this is a chknullish instruction (ID -10), then this implies GameMaker 2.3.7 or above + if (value == -10 && reader.undertaleData.IsVersionAtLeast(2, 3)) + { + if (!reader.undertaleData.IsVersionAtLeast(2, 3, 7)) + { + reader.undertaleData.SetGMS2Version(2, 3, 7); + } + } + + // Assign remaining values to instruction + Value = value; + Type1 = type1; + break; + } default: - throw new IOException("Unknown opcode " + Kind.ToString().ToUpper(CultureInfo.InvariantCulture)); + throw new IOException($"Unknown opcode {Kind.ToString().ToUpper(CultureInfo.InvariantCulture)}"); } + + // Assign final opcode to instruction + Kind = kind; } + /// public static uint UnserializeChildObjectCount(UndertaleReader reader) { - long instructionStartAddress = reader.Position; - reader.Position += 3; // skip for now, we'll read them later - byte kind = reader.ReadByte(); - if (reader.Bytecode14OrLower) + // Flag tracking whether we're parsing bytecode 14 (old) instructions + bool bytecode14 = reader.Bytecode14OrLower; + + // Read first word from instruction + uint firstWord = reader.ReadUInt32(); + + // Read opcode from most significant byte + Opcode kind = (Opcode)((firstWord & 0xFF000000) >> 24); + if (bytecode14) { - // Convert opcode to our enum - kind = ConvertInstructionKind(kind); + // Convert opcode from old format to new format + kind = (Opcode)ConvertInstructionKind((byte)kind); } - Opcode Kind = (Opcode)kind; - reader.Position = instructionStartAddress; - switch (GetInstructionType(Kind)) + + // Extract third byte from first word + byte b2 = (byte)((firstWord & 0x00FF0000) >> 16); + + // Parse instruction contents + InstructionType instructionType = GetInstructionType(kind); + switch (instructionType) { case InstructionType.SingleTypeInstruction: case InstructionType.DoubleTypeInstruction: case InstructionType.ComparisonInstruction: case InstructionType.GotoInstruction: - reader.Position += 4; + // No special handling required break; case InstructionType.PopInstruction: - reader.Position += 2; // "TypeInst" - int type1 = reader.ReadByte() & 0xf; - if (type1 != 0x0f) + { + // Skip destination of pop instruction, if present + DataType type1 = (DataType)(b2 & 0xf); + if (type1 != DataType.Int16) { - reader.Position += 1 + 4; + reader.Position += 4; return 1; // "Destination" } - else - reader.Position++; break; + } case InstructionType.PushInstruction: + { + // Skip value being pushed, if present + DataType type1 = (DataType)(b2 & 0xf); + switch (type1) { - reader.Position += 2; - DataType Type1 = (DataType)reader.ReadByte(); - reader.Position++; - switch (Type1) - { - case DataType.Double: - case DataType.Int64: - reader.Position += 8; - break; + case DataType.Double: + case DataType.Int64: + reader.Position += 8; + break; - case DataType.Float: - case DataType.Int32: - case DataType.Boolean: - reader.Position += 4; - break; + case DataType.Float: + case DataType.Int32: + case DataType.Boolean: + reader.Position += 4; + break; - case DataType.Variable: - case DataType.String: - reader.Position += 4; - return 1; - } + case DataType.Variable: + case DataType.String: + reader.Position += 4; + return 1; } break; + } case InstructionType.CallInstruction: - reader.Position += 8; + reader.Position += 4; return 1; // "Function" case InstructionType.BreakInstruction: + { + // Skip past integer argument, if present + DataType type1 = (DataType)(b2 & 0xf); + if (type1 == DataType.Int32) { - reader.Position += 2; - DataType Type1 = (DataType)reader.ReadByte(); - if (Type1 == DataType.Int32) - reader.Position += 5; - else - reader.Position += 1; - break; + reader.Position += 4; } + break; + } default: - throw new IOException("Unknown opcode " + Kind.ToString().ToUpper(CultureInfo.InvariantCulture)); + throw new IOException($"Unknown opcode {kind.ToString().ToUpper(CultureInfo.InvariantCulture)}"); } return 0; @@ -991,7 +1138,7 @@ public void ToString(StringBuilder stringBuilder, UndertaleCode code, List sbh.Append(stringBuilder, ' '); if (Type1 == DataType.Int16) { - // Special scenario - the swap instruction + // Special scenario - the swap instruction (see #129) sbh.Append(stringBuilder, SwapExtra); } else @@ -1646,4 +1793,4 @@ public void Dispose() int IGMCode.LocalCount => (int)LocalsCount; public IGMInstruction GetInstruction(int index) => Instructions[index]; public IGMCode GetChild(int index) => ChildEntries[index]; -} \ No newline at end of file +} diff --git a/UndertaleModLib/Models/UndertaleEmbeddedAudio.cs b/UndertaleModLib/Models/UndertaleEmbeddedAudio.cs index 94c30d47f..991c5ca59 100644 --- a/UndertaleModLib/Models/UndertaleEmbeddedAudio.cs +++ b/UndertaleModLib/Models/UndertaleEmbeddedAudio.cs @@ -12,7 +12,7 @@ public class UndertaleEmbeddedAudio : UndertaleNamedResource, PaddedObject, IDis /// /// The name of the embedded audio entry. /// - /// This is an UTMT only attribute. GameMaker does not store names for them. + /// This is a UTMT only attribute. GameMaker does not store names for them. public UndertaleString Name { get; set; } /// @@ -56,13 +56,13 @@ public override string ToString() try { // TODO: Does only the GUI set this? - return Name.Content + " (" + GetType().Name + ")"; + return $"{Name.Content} ({GetType().Name})"; } catch { Name = new UndertaleString("EmbeddedSound Unknown Index"); } - return Name.Content + " (" + GetType().Name + ")"; + return $"{Name.Content} ({GetType().Name})"; } /// diff --git a/UndertaleModLib/Models/UndertaleEmbeddedTexture.cs b/UndertaleModLib/Models/UndertaleEmbeddedTexture.cs index c5624e846..b95cb76bb 100644 --- a/UndertaleModLib/Models/UndertaleEmbeddedTexture.cs +++ b/UndertaleModLib/Models/UndertaleEmbeddedTexture.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.ComponentModel; using System.Drawing; -using System.Drawing.Imaging; using System.IO; using System.Linq; using System.Runtime.CompilerServices; @@ -22,6 +21,9 @@ public class UndertaleEmbeddedTexture : UndertaleNamedResource, IDisposable /// /// The name of the embedded texture entry. /// + /// + /// This is UTMT specific. The data file does not contain names for embedded textures. + /// public UndertaleString Name { get; set; } /// @@ -55,19 +57,18 @@ public TexData TextureData get => _textureData ??= LoadExternalTexture(); set => _textureData = value; } - private TexData _textureData = new TexData(); - + private TexData _textureData = new(); /// /// Helper variable for whether or not this texture is to be stored externally or not. /// - public bool TextureExternal { get; set; } = false; + public bool TextureExternal { get; set; } /// /// Helper variable for whether or not a texture was loaded yet. /// - public bool TextureLoaded { get; set; } = false; + public bool TextureLoaded { get; set; } /// /// Width of the texture. 2022.9+ only. @@ -229,20 +230,9 @@ public static void FindAllTextureInfo(UndertaleData data) } } - // 1x1 black pixel in PNG format - private static TexData _placeholderTexture = new() - { - TextureBlob = new byte[] - { - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, - 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x01, 0x73, 0x52, 0x47, - 0x42, 0x00, 0xAE, 0xCE, 0x1C, 0xE9, 0x00, 0x00, 0x00, 0x04, 0x67, 0x41, 0x4D, 0x41, 0x00, 0x00, 0xB1, 0x8F, 0x0B, 0xFC, - 0x61, 0x05, 0x00, 0x00, 0x00, 0x09, 0x70, 0x48, 0x59, 0x73, 0x00, 0x00, 0x0E, 0xC3, 0x00, 0x00, 0x0E, 0xC3, 0x01, 0xC7, - 0x6F, 0xA8, 0x64, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x18, 0x57, 0x63, 0x60, 0x60, 0x60, 0x00, 0x00, 0x00, - 0x04, 0x00, 0x01, 0x5C, 0xCD, 0xFF, 0x69, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82 - } - }; - private static object _textureLoadLock = new(); + // 1x1 blank image + private static readonly TexData _placeholderTexture = new() { Image = new GMImage(1, 1) }; + private static readonly object _textureLoadLock = new(); /// /// Attempts to load the corresponding external texture. Should only happen in 2022.9 and above. @@ -272,7 +262,7 @@ public TexData LoadExternalTexture() using FileStream fs = new(path, FileMode.Open); using FileBinaryReader fbr = new(fs); texData = new TexData(); - texData.Unserialize(fbr, true); + texData.Unserialize(fbr, fs.Length, true); TextureLoaded = true; } catch (IOException) @@ -310,62 +300,60 @@ public void Dispose() /// public class TexData : UndertaleObject, INotifyPropertyChanged, IDisposable { - private byte[] _textureBlob; - private static MemoryStream sharedStream; + private GMImage _image; /// - /// The image data of the texture. + /// The underlying image of the texture. /// - public byte[] TextureBlob - { - get => _textureBlob; + public GMImage Image + { + get => _image; set { - _textureBlob = value; + _image = value; OnPropertyChanged(); } } /// /// The width of the texture. - /// In case of an invalid texture data, this will be -1. /// - public int Width - { - get - { - if (_textureBlob is null || _textureBlob.Length < 24) - return -1; + /// + /// In the case of an invalid or missing image, this will always be -1. + /// + public int Width => _image?.Width ?? -1; - ReadOnlySpan span = _textureBlob.AsSpan(); - return BinaryPrimitives.ReadInt32BigEndian(span[16..20]); - } - } /// /// The height of the texture. - /// In case of an invalid texture data, this will be -1. /// - public int Height - { - get - { - if (_textureBlob is null || _textureBlob.Length < 24) - return -1; - - ReadOnlySpan span = _textureBlob.AsSpan(); - return BinaryPrimitives.ReadInt32BigEndian(span[20..24]); - } - } + /// + /// In the case of an invalid or missing image, this will always be -1. + /// + public int Height => _image?.Height ?? -1; /// /// Whether this texture uses the QOI format. /// - public bool FormatQOI { get; set; } = false; + /// + /// In the case of an invalid or missing image, this will always be . + /// + public bool FormatQOI => _image.Format is GMImage.ImageFormat.Qoi or GMImage.ImageFormat.Bz2Qoi; /// /// Whether this texture uses the BZ2 format. (Always used in combination with QOI.) /// - public bool FormatBZ2 { get; set; } = false; + /// + /// In the case of an invalid or missing image, this will always be . + /// + public bool FormatBZ2 => _image.Format is GMImage.ImageFormat.Bz2Qoi; + + /// + /// If located within a data file, this is the upper bound on the end position of the image data (or start of the next texture blob). + /// + /// + /// All data between the actual end position and this maximum end position should be 0x00 byte padding. + /// + private int _maxEndOfStreamPosition { get; set; } = -1; /// public event PropertyChangedEventHandler PropertyChanged; @@ -378,152 +366,56 @@ protected void OnPropertyChanged([CallerMemberName] string name = null) PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); } - /// - /// Header used for PNG files. - /// - public static readonly byte[] PNGHeader = { 137, 80, 78, 71, 13, 10, 26, 10 }; - - /// - /// Header used for GameMaker QOI + BZ2 files. - /// - public static readonly byte[] QOIAndBZip2Header = { 50, 122, 111, 113 }; - - /// - /// Header used for GameMaker QOI files. - /// - public static readonly byte[] QOIHeader = { 102, 105, 111, 113 }; - - /// - /// Frees up from memory. - /// - public static void ClearSharedStream() - { - sharedStream?.Dispose(); - sharedStream = null; - } - - /// - /// Initializes with a specified initial size. - /// - /// Initial size of in bytes - public static void InitSharedStream(int size) => sharedStream = new(size); - /// public void Serialize(UndertaleWriter writer) { - Serialize(writer, writer.undertaleData.IsVersionAtLeast(2022, 3), writer.undertaleData.IsVersionAtLeast(2022, 5)); + Serialize(writer, writer.undertaleData.IsVersionAtLeast(2022, 5)); } /// /// Serializes the texture to any type of writer (can be any destination file). /// - public void Serialize(FileBinaryWriter writer, bool gm2022_3, bool gm2022_5) + public void Serialize(FileBinaryWriter writer, bool gm2022_5) { - if (FormatQOI) + if (Image.Format == GMImage.ImageFormat.RawBgra) { - if (FormatBZ2) - { - writer.Write(QOIAndBZip2Header); - - // Encode the PNG data back to QOI+BZip2 - using Bitmap bmp = TextureWorker.GetImageFromByteArray(TextureBlob); - writer.Write((short)bmp.Width); - writer.Write((short)bmp.Height); - byte[] qoiData = QoiConverter.GetArrayFromImage(bmp, gm2022_3 ? 0 : 4); - using MemoryStream input = new MemoryStream(qoiData); - if (sharedStream.Length != 0) - sharedStream.Seek(0, SeekOrigin.Begin); - BZip2.Compress(input, sharedStream, false, 9); - if (gm2022_5) - writer.Write((uint)qoiData.Length); - writer.Write(sharedStream.GetBuffer().AsSpan()[..(int)sharedStream.Position]); - } - else - { - // Encode the PNG data back to QOI - using Bitmap bmp = TextureWorker.GetImageFromByteArray(TextureBlob); - writer.Write(QoiConverter.GetSpanFromImage(bmp, gm2022_3 ? 0 : 4)); - } + throw new Exception("Unexpected raw RGBA image"); } - else - writer.Write(TextureBlob); + + Image.WriteToBinaryWriter(writer, gm2022_5); } /// public void Unserialize(UndertaleReader reader) { - Unserialize(reader, reader.undertaleData.IsVersionAtLeast(2022, 5)); + Unserialize(reader, _maxEndOfStreamPosition, reader.undertaleData.IsVersionAtLeast(2022, 5)); } /// /// Unserializes the texture from any type of reader (can be from any source). /// - public void Unserialize(IBinaryReader reader, bool gm2022_5) + /// to read the texture's image from. + /// Upper bound on the end of the texture's image data (e.g., for padding). + /// Whether to unserialize the image data using GameMaker 2022.5+ format. + public void Unserialize(IBinaryReader reader, long maxEndOfStreamPosition, bool gm2022_5) { - sharedStream ??= new(); - - long startAddress = reader.Position; - - byte[] header = reader.ReadBytes(8); - if (!header.SequenceEqual(PNGHeader)) + if (maxEndOfStreamPosition == -1) { - reader.Position = startAddress; - - if (header.Take(4).SequenceEqual(QOIAndBZip2Header)) - { - FormatQOI = true; - FormatBZ2 = true; - - // Don't really care about the width/height, so skip them, as well as header - reader.Position += (uint)(gm2022_5 ? 12 : 8); - - // Need to fully decompress and convert the QOI data to PNG for compatibility purposes (at least for now) - if (sharedStream.Length != 0) - sharedStream.Seek(0, SeekOrigin.Begin); - BZip2.Decompress(reader.Stream, sharedStream, false); - ReadOnlySpan decompressed = sharedStream.GetBuffer().AsSpan()[..(int)sharedStream.Position]; - using Bitmap bmp = QoiConverter.GetImageFromSpan(decompressed); - sharedStream.Seek(0, SeekOrigin.Begin); - bmp.Save(sharedStream, ImageFormat.Png); - TextureBlob = new byte[(int)sharedStream.Position]; - sharedStream.Seek(0, SeekOrigin.Begin); - sharedStream.Read(TextureBlob, 0, TextureBlob.Length); - return; - } - else if (header.Take(4).SequenceEqual(QOIHeader)) - { - FormatQOI = true; - FormatBZ2 = false; - - // Need to convert the QOI data to PNG for compatibility purposes (at least for now) - using Bitmap bmp = QoiConverter.GetImageFromStream(reader.Stream); - if (sharedStream.Length != 0) - sharedStream.Seek(0, SeekOrigin.Begin); - bmp.Save(sharedStream, ImageFormat.Png); - TextureBlob = new byte[(int)sharedStream.Position]; - sharedStream.Seek(0, SeekOrigin.Begin); - sharedStream.Read(TextureBlob, 0, TextureBlob.Length); - return; - } - else - throw new IOException("Didn't find PNG or QOI+BZip2 header"); + throw new Exception("Expected max end of stream position to be set before unserializing"); } - // There is no length for the PNG anywhere as far as I can see - // The only thing we can do is parse the image to find the end - while (true) - { - // PNG is big endian and BinaryRead can't handle that (damn) - uint len = (uint)reader.ReadByte() << 24 | (uint)reader.ReadByte() << 16 | (uint)reader.ReadByte() << 8 | (uint)reader.ReadByte(); - uint type = reader.ReadUInt32(); - reader.Position += len + 4; - if (type == 0x444e4549) // 0x444e4549 -> "IEND" - break; - } + Image = GMImage.FromBinaryReader(reader, maxEndOfStreamPosition, gm2022_5); + } - long length = reader.Position - startAddress; - reader.Position = startAddress; - TextureBlob = reader.ReadBytes((int)length); + /// + /// Sets the upper bound on the position of the end of the image stream, for use when loading a full data file. + /// + /// + /// All data between the actual end position and this maximum end position should be padding (zero bytes). + /// + public void SetMaxEndOfStreamPosition(int position) + { + _maxEndOfStreamPosition = position; } @@ -532,8 +424,7 @@ public void Dispose() { GC.SuppressFinalize(this); - _textureBlob = null; - ClearSharedStream(); + _image = null; } } } \ No newline at end of file diff --git a/UndertaleModLib/Models/UndertaleFont.cs b/UndertaleModLib/Models/UndertaleFont.cs index 34a495aff..e8d5a57d2 100644 --- a/UndertaleModLib/Models/UndertaleFont.cs +++ b/UndertaleModLib/Models/UndertaleFont.cs @@ -313,7 +313,7 @@ public void Unserialize(UndertaleReader reader) // since the float is always written negated, it has the first bit set. if ((readEmSize & (1 << 31)) != 0) { - float fsize = -BitConverter.ToSingle(BitConverter.GetBytes(EmSize), 0); + float fsize = -BitConverter.ToSingle(BitConverter.GetBytes(readEmSize), 0); EmSize = fsize; EmSizeIsFloat = true; } diff --git a/UndertaleModLib/Models/UndertaleGeneralInfo.cs b/UndertaleModLib/Models/UndertaleGeneralInfo.cs index abd16c877..edab0b354 100644 --- a/UndertaleModLib/Models/UndertaleGeneralInfo.cs +++ b/UndertaleModLib/Models/UndertaleGeneralInfo.cs @@ -479,24 +479,13 @@ public void Unserialize(UndertaleReader reader) if (reader.ReadOnlyGEN8) return; - var detectedVer = TestForCommonGMSVersions(reader, (Major, Minor, Release, Build, Branch)); - (Major, Minor, Release, Build, Branch) = detectedVer; - - if (reader.undertaleData.GeneralInfo is not null) - { - var prevGenInfo = reader.undertaleData.GeneralInfo; - // If previous version is greater than current - if (prevGenInfo.Major > Major - || prevGenInfo.Major == Major && prevGenInfo.Minor > Minor - || prevGenInfo.Major == Major && prevGenInfo.Minor == Minor && prevGenInfo.Release > Release - || prevGenInfo.Major == Major && prevGenInfo.Minor == Minor && prevGenInfo.Release == Release && prevGenInfo.Build > Build) - { - Major = prevGenInfo.Major; - Minor = prevGenInfo.Minor; - Release = prevGenInfo.Release; - Build = prevGenInfo.Build; - } - } + // TestForCommonGMSVersions is run during the object counting phase, so the previous general info is always accurate. + var prevGenInfo = reader.undertaleData.GeneralInfo; + Major = prevGenInfo.Major; + Minor = prevGenInfo.Minor; + Release = prevGenInfo.Release; + Build = prevGenInfo.Build; + Branch = prevGenInfo.Branch; DefaultWindowWidth = reader.ReadUInt32(); DefaultWindowHeight = reader.ReadUInt32(); @@ -712,7 +701,8 @@ public enum OptionsFlags : ulong UseRearTouch = 0x2000000, UseFastCollision = 0x4000000, FastCollisionCompatibility = 0x8000000, - DisableSandbox = 0x10000000 + DisableSandbox = 0x10000000, + EnableCopyOnWrite = 0x20000000 } /// diff --git a/UndertaleModLib/Models/UndertaleGlobalInit.cs b/UndertaleModLib/Models/UndertaleGlobalInit.cs index 8f36c3884..3c833ab88 100644 --- a/UndertaleModLib/Models/UndertaleGlobalInit.cs +++ b/UndertaleModLib/Models/UndertaleGlobalInit.cs @@ -6,7 +6,8 @@ namespace UndertaleModLib.Models; /// /// A global initialization entry in a data file. /// -/// Never seen in GMS1.4 so uncertain if the structure was the same. +/// +// TODO: Never seen in GMS1.4 so uncertain if the structure was the same. public class UndertaleGlobalInit : UndertaleObject, INotifyPropertyChanged, IDisposable { private UndertaleResourceById _code = new(); @@ -14,6 +15,7 @@ public class UndertaleGlobalInit : UndertaleObject, INotifyPropertyChanged, IDis /// /// The object which contains the code. /// + /// This code is executed at a global scope, before the first room of the game executes. public UndertaleCode Code { get => _code.Resource; set { _code.Resource = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Code))); } } /// @@ -29,7 +31,7 @@ public void Serialize(UndertaleWriter writer) public void Unserialize(UndertaleReader reader) { _code = new UndertaleResourceById(); - _code.Unserialize(reader); // TODO: reader.ReadUndertaleObject if one object starts with another one + _code.Unserialize(reader); // Cannot use ReadUndertaleObject, as that messes things up. } /// @@ -39,4 +41,4 @@ public void Dispose() _code.Dispose(); } -} \ No newline at end of file +} diff --git a/UndertaleModLib/Models/UndertaleSprite.cs b/UndertaleModLib/Models/UndertaleSprite.cs index 3eedfe981..e1f584e94 100644 --- a/UndertaleModLib/Models/UndertaleSprite.cs +++ b/UndertaleModLib/Models/UndertaleSprite.cs @@ -279,9 +279,8 @@ public void Dispose() public MaskEntry NewMaskEntry() { - MaskEntry newEntry = new MaskEntry(); uint len = (Width + 7) / 8 * Height; - newEntry.Data = new byte[len]; + MaskEntry newEntry = new MaskEntry(new byte[len], Width, Height); return newEntry; } @@ -348,13 +347,24 @@ public class MaskEntry : IDisposable { public byte[] Data { get; set; } + /// + /// Width of this sprite mask. UTMT only. + /// + public uint Width { get; set; } + /// + /// Height of this sprite mask. UTMT only. + /// + public uint Height { get; set; } + public MaskEntry() { } - public MaskEntry(byte[] data) + public MaskEntry(byte[] data, uint width, uint height) { this.Data = data; + this.Width = width; + this.Height = height; } /// @@ -514,7 +524,9 @@ private void WriteMaskData(UndertaleWriter writer) writer.Write((byte)0); total++; } - Util.DebugUtil.Assert(total == CalculateMaskDataSize(Width, Height, (uint)CollisionMasks.Count), "Invalid mask data for sprite"); + + (uint width, uint height) = CalculateMaskDimensions(writer.undertaleData); + Util.DebugUtil.Assert(total == CalculateMaskDataSize(width, height, (uint)CollisionMasks.Count), "Invalid mask data for sprite"); } private static byte[] DecodeSpineBlob(byte[] blob) @@ -744,7 +756,9 @@ public static uint UnserializeChildObjectCount(UndertaleReader reader) case SpriteType.Spine: { - reader.Align(4); + case 1: + reader.Position += 8 + (uint)jsonLength + (uint)atlasLength + (uint)textures; + break; if (reader.undertaleData.IsVersionAtLeast(2023, 1)) count += 1 + UndertaleSimpleList.UnserializeChildObjectCount(reader); @@ -761,9 +775,7 @@ public static uint UnserializeChildObjectCount(UndertaleReader reader) switch (spineVersion) { - case 1: - reader.Position += 8 + jsonLength + atlasLength + textures; - break; + reader.Position += (uint)jsonLength + (uint)atlasLength; case 2: case 3: @@ -851,7 +863,7 @@ private void ReadMaskData(UndertaleReader reader) uint total = 0; for (uint i = 0; i < maskCount; i++) { - newMasks.Add(new MaskEntry(reader.ReadBytes((int)len))); + newMasks.Add(new MaskEntry(reader.ReadBytes((int)len), width, height)); total += len; } diff --git a/UndertaleModLib/Models/UndertaleTexturePageItem.cs b/UndertaleModLib/Models/UndertaleTexturePageItem.cs index 612041e1e..7cfcc3e78 100644 --- a/UndertaleModLib/Models/UndertaleTexturePageItem.cs +++ b/UndertaleModLib/Models/UndertaleTexturePageItem.cs @@ -1,4 +1,5 @@ -using System; +using ImageMagick; +using System; using System.ComponentModel; using System.Drawing; using UndertaleModLib.Util; @@ -145,33 +146,25 @@ public void Dispose() /// Replaces the current image of this texture page item to hold a new image. /// /// The new image that shall be applied to this texture page item. - /// Whether to dispose afterwards. - public void ReplaceTexture(Image replaceImage, bool disposeImage = true) + public void ReplaceTexture(MagickImage replaceImage) { - Image finalImage = TextureWorker.ResizeImage(replaceImage, SourceWidth, SourceHeight); + // Resize image to bounds on texture page + using IMagickImage finalImage = TextureWorker.ResizeImage(replaceImage, SourceWidth, SourceHeight); - // Apply the image to the TexturePage. + // Apply the image to the texture page lock (TexturePage.TextureData) { - TextureWorker worker = new TextureWorker(); - Bitmap embImage = worker.GetEmbeddedTexture(TexturePage); // Use SetPixel if needed. + using TextureWorker worker = new(); + MagickImage embImage = worker.GetEmbeddedTexture(TexturePage); - Graphics g = Graphics.FromImage(embImage); - g.CompositingMode = System.Drawing.Drawing2D.CompositingMode.SourceCopy; - g.DrawImage(finalImage, SourceX, SourceY); - g.Dispose(); + embImage.Composite(finalImage, SourceX, SourceY, CompositeOperator.Copy); - TexturePage.TextureData.TextureBlob = TextureWorker.GetImageBytes(embImage); - - worker.Cleanup(); + // Replace original texture with the new version, in the original texture format + TexturePage.TextureData.Image = GMImage.FromMagickImage(embImage) + .ConvertToFormat(TexturePage.TextureData.Image.Format); } TargetWidth = (ushort)replaceImage.Width; TargetHeight = (ushort)replaceImage.Height; - - // Cleanup. - finalImage.Dispose(); - if (disposeImage) - replaceImage.Dispose(); } } \ No newline at end of file diff --git a/UndertaleModLib/UndertaleChunks.cs b/UndertaleModLib/UndertaleChunks.cs index 319067262..d58b80dd0 100644 --- a/UndertaleModLib/UndertaleChunks.cs +++ b/UndertaleModLib/UndertaleChunks.cs @@ -1168,7 +1168,7 @@ private void CheckForTileCompression(UndertaleReader reader) reader.Position += 32; int effectCount = reader.ReadInt32(); - reader.Position += effectCount * 12 + 4; + reader.Position += (uint)effectCount * 12 + 4; int tileMapWidth = reader.ReadInt32(); int tileMapHeight = reader.ReadInt32(); @@ -1503,8 +1503,8 @@ private void CheckFor2022_3And5(UndertaleReader reader) reader.Position = positionToReturn + 4 + (i * 4); reader.AbsPosition = reader.ReadUInt32() + 12; // Go to texture, at an offset reader.AbsPosition = reader.ReadUInt32(); // Go to texture data - byte[] header = reader.ReadBytes(4); - if (!header.SequenceEqual(UndertaleEmbeddedTexture.TexData.QOIAndBZip2Header)) + ReadOnlySpan header = reader.ReadBytes(4); + if (!header.SequenceEqual(GMImage.MagicBz2Qoi)) { // Nothing useful, check the next texture continue; @@ -1547,10 +1547,6 @@ internal override void SerializeChunk(UndertaleWriter writer) // texture blobs if (List.Count > 0) { - // Compressed size can't be bigger than maximum decompressed size - int maxSize = List.Select(x => x.TextureData.TextureBlob?.Length ?? 0).Max(); - UndertaleEmbeddedTexture.TexData.InitSharedStream(maxSize); - bool anythingUsesQoi = false; foreach (var tex in List) { @@ -1565,8 +1561,7 @@ internal override void SerializeChunk(UndertaleWriter writer) if (anythingUsesQoi) { // Calculate maximum size of QOI converter buffer - maxSize = List.Select(x => x.TextureData.Width * x.TextureData.Height).Max() - * QoiConverter.MaxChunkSize + QoiConverter.HeaderSize + (writer.undertaleData.IsVersionAtLeast(2022, 3) ? 0 : 4); + int maxSize = (List.Max(x => x.TextureData.Width * x.TextureData.Height) * QoiConverter.MaxChunkSize) + QoiConverter.HeaderSize; QoiConverter.InitSharedBuffer(maxSize); } } @@ -1631,6 +1626,8 @@ private void CheckForGMS2_0_6(UndertaleReader reader) internal override void UnserializeChunk(UndertaleReader reader) { + long startPosition = reader.AbsPosition; + if (!checkedFor2022_3) CheckFor2022_3And5(reader); @@ -1645,6 +1642,35 @@ internal override void UnserializeChunk(UndertaleReader reader) { UndertaleEmbeddedTexture obj = List[index]; + if (!obj.TextureExternal) + { + // Calculate maximum end stream position for this blob + int searchIndex = index + 1; + int maxEndOfStreamPosition = -1; + while (searchIndex < List.Count) + { + UndertaleEmbeddedTexture searchObj = List[searchIndex]; + + if (searchObj.TextureExternal) + { + // Skip this texture, as it's external + searchIndex++; + continue; + } + + // Use start address of this blob + maxEndOfStreamPosition = (int)reader.GetOffsetMapRev()[searchObj.TextureData]; + break; + } + + if (maxEndOfStreamPosition == -1) + { + // At end of list, so just use the end of the chunk + maxEndOfStreamPosition = (int)(startPosition + Length); + } + obj.TextureData.SetMaxEndOfStreamPosition(maxEndOfStreamPosition); + } + obj.UnserializeBlob(reader); obj.Name = new UndertaleString("Texture " + index.ToString()); } diff --git a/UndertaleModLib/UndertaleModLib.csproj b/UndertaleModLib/UndertaleModLib.csproj index acb87b3cf..f2cb79074 100644 --- a/UndertaleModLib/UndertaleModLib.csproj +++ b/UndertaleModLib/UndertaleModLib.csproj @@ -1,13 +1,14 @@  - net6.0 + net8.0 + 11 Library UndertaleModLib UndertaleModLib - Copyright © 2018-2023, licensed under GPLv3 - 0.5.1.0 - 0.5.1.0 + Copyright © 2018-2024, licensed under GPLv3 + 0.6.1.0 + 0.6.1.0 embedded AnyCPU;x64 win-x64;win-x86 @@ -24,10 +25,11 @@ True - + all runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -35,19 +37,43 @@ - - - - + + + + + + + + - - - - + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + diff --git a/UndertaleModLib/Util/AdaptiveBinaryReader.cs b/UndertaleModLib/Util/AdaptiveBinaryReader.cs index 5522678f0..42392484d 100644 --- a/UndertaleModLib/Util/AdaptiveBinaryReader.cs +++ b/UndertaleModLib/Util/AdaptiveBinaryReader.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -86,10 +86,8 @@ public long AbsPosition { if (isUsingBufferReader) { -#if DEBUG - if (value > Length) + if (value < 0 || value > Length) throw new IOException("Reading out of bounds."); -#endif bufferBinaryReader.Position = value - bufferBinaryReader.ChunkStartPosition + 8; } else diff --git a/UndertaleModLib/Util/AssetReferenceTypes.cs b/UndertaleModLib/Util/AssetReferenceTypes.cs index f3686c07a..15f11c43b 100644 --- a/UndertaleModLib/Util/AssetReferenceTypes.cs +++ b/UndertaleModLib/Util/AssetReferenceTypes.cs @@ -4,6 +4,9 @@ namespace UndertaleModLib.Util; public static class AssetReferenceTypes { + /// + /// Asset types as used in GML code references. + /// public enum RefType { Object, @@ -22,6 +25,9 @@ public enum RefType RoomInstance } + /// + /// Converts an integer asset type to its equivalent, depending on GameMaker version. + /// public static RefType ConvertToRefType(UndertaleData data, int type) { if (data.IsVersionAtLeast(2024, 4)) @@ -66,6 +72,9 @@ public static RefType ConvertToRefType(UndertaleData data, int type) }; } + /// + /// Converts a to its integer equivalent, depending on GameMaker version. + /// public static int ConvertFromRefType(UndertaleData data, RefType type) { if (data.IsVersionAtLeast(2024, 4)) diff --git a/UndertaleModLib/Util/BufferBinaryReader.cs b/UndertaleModLib/Util/BufferBinaryReader.cs index 4fded571d..f133739e5 100644 --- a/UndertaleModLib/Util/BufferBinaryReader.cs +++ b/UndertaleModLib/Util/BufferBinaryReader.cs @@ -27,6 +27,11 @@ public ChunkBuffer(int capacity) public int Read(byte[] buffer, int count) { + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + int n = _length - _position; if (n > count) n = count; @@ -75,6 +80,9 @@ public byte ReadByte() public void Write(byte[] buffer, int count) { + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count)); + int i = _position + count; if (i < 0) throw new IOException("Writing out of the chunk buffer bounds."); @@ -174,6 +182,10 @@ public virtual bool ReadBoolean() public string ReadChars(int count) { + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } if (chunkBuffer.Position + count > _length) { throw new IOException("Reading out of chunk bounds"); @@ -198,6 +210,10 @@ public string ReadChars(int count) public byte[] ReadBytes(int count) { + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } if (chunkBuffer.Position + count > _length) { throw new IOException("Reading out of chunk bounds"); @@ -268,7 +284,9 @@ public string ReadGMString() int length = BinaryPrimitives.ReadInt32LittleEndian(ReadToBuffer(4)); - if (chunkBuffer.Position + length + 1 >= _length) + if (length < 0) + throw new IOException("Invalid string length"); + if (chunkBuffer.Position + length + 1 > _length) throw new IOException("Reading out of chunk bounds"); string res; @@ -293,9 +311,12 @@ public string ReadGMString() return res; } + public void SkipGMString() { int length = BinaryPrimitives.ReadInt32LittleEndian(ReadToBuffer(4)); + if (length < 0) + throw new IOException("Invalid string length"); Position += (uint)length + 1; } diff --git a/UndertaleModLib/Util/FileBinaryReader.cs b/UndertaleModLib/Util/FileBinaryReader.cs index 87525d226..419ae9551 100644 --- a/UndertaleModLib/Util/FileBinaryReader.cs +++ b/UndertaleModLib/Util/FileBinaryReader.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Buffers.Binary; using System.IO; using System.Text; @@ -21,10 +21,8 @@ public long Position get => Stream.Position; set { -#if DEBUG if (value > Length) throw new IOException("Reading out of bounds."); -#endif Stream.Position = value; } } @@ -49,10 +47,11 @@ private ReadOnlySpan ReadToBuffer(int count) public byte ReadByte() { -#if DEBUG if (Stream.Position + 1 > _length) + { throw new IOException("Reading out of bounds"); -#endif + } + return (byte)Stream.ReadByte(); } @@ -63,10 +62,15 @@ public virtual bool ReadBoolean() public string ReadChars(int count) { -#if DEBUG + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } if (Stream.Position + count > _length) + { throw new IOException("Reading out of bounds"); -#endif + } + if (count > 1024) { byte[] buf = new byte[count]; @@ -85,10 +89,15 @@ public string ReadChars(int count) public byte[] ReadBytes(int count) { -#if DEBUG + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } if (Stream.Position + count > _length) + { throw new IOException("Reading out of bounds"); -#endif + } + byte[] val = new byte[count]; Stream.Read(val, 0, count); return val; @@ -96,107 +105,118 @@ public byte[] ReadBytes(int count) public short ReadInt16() { -#if DEBUG if (Stream.Position + 2 > _length) + { throw new IOException("Reading out of bounds"); -#endif + } + return BinaryPrimitives.ReadInt16LittleEndian(ReadToBuffer(2)); } public ushort ReadUInt16() { -#if DEBUG if (Stream.Position + 2 > _length) + { throw new IOException("Reading out of bounds"); -#endif + } + return BinaryPrimitives.ReadUInt16LittleEndian(ReadToBuffer(2)); } public int ReadInt24() { -#if DEBUG if (Stream.Position + 3 > _length) + { throw new IOException("Reading out of bounds"); -#endif + } + ReadToBuffer(3); return buffer[0] | buffer[1] << 8 | (sbyte)buffer[2] << 16; } public uint ReadUInt24() { -#if DEBUG if (Stream.Position + 3 > _length) + { throw new IOException("Reading out of bounds"); -#endif + } + ReadToBuffer(3); return (uint)(buffer[0] | buffer[1] << 8 | buffer[2] << 16); } public int ReadInt32() { -#if DEBUG if (Stream.Position + 4 > _length) + { throw new IOException("Reading out of bounds"); -#endif + } + return BinaryPrimitives.ReadInt32LittleEndian(ReadToBuffer(4)); } public uint ReadUInt32() { -#if DEBUG if (Stream.Position + 4 > _length) + { throw new IOException("Reading out of bounds"); -#endif + } + return BinaryPrimitives.ReadUInt32LittleEndian(ReadToBuffer(4)); } public float ReadSingle() { -#if DEBUG if (Stream.Position + 4 > _length) + { throw new IOException("Reading out of bounds"); -#endif + } + return BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32LittleEndian(ReadToBuffer(4))); } public double ReadDouble() { -#if DEBUG if (Stream.Position + 8 > _length) + { throw new IOException("Reading out of bounds"); -#endif + } + return BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64LittleEndian(ReadToBuffer(8))); } public long ReadInt64() { -#if DEBUG if (Stream.Position + 8 > _length) + { throw new IOException("Reading out of bounds"); -#endif + } + return BinaryPrimitives.ReadInt64LittleEndian(ReadToBuffer(8)); } public ulong ReadUInt64() { -#if DEBUG if (Stream.Position + 8 > _length) + { throw new IOException("Reading out of bounds"); -#endif + } + return BinaryPrimitives.ReadUInt64LittleEndian(ReadToBuffer(8)); } public string ReadGMString() { -#if DEBUG if (Stream.Position + 5 > _length) throw new IOException("Reading out of bounds"); -#endif + int length = BinaryPrimitives.ReadInt32LittleEndian(ReadToBuffer(4)); -#if DEBUG - if (Stream.Position + length + 1 >= _length) + + if (length < 0) + throw new IOException("Invalid string length"); + if (Stream.Position + length + 1 > _length) throw new IOException("Reading out of bounds"); -#endif + string res; if (length > 1024) { @@ -210,18 +230,20 @@ public string ReadGMString() Stream.Read(buf); res = encoding.GetString(buf); } - -#if DEBUG + if (Stream.ReadByte() != 0) + { throw new IOException("String not null terminated!"); -#else - Position++; -#endif + } + return res; } + public void SkipGMString() { int length = BinaryPrimitives.ReadInt32LittleEndian(ReadToBuffer(4)); + if (length < 0) + throw new IOException("Invalid string length"); Position += (uint)length + 1; } diff --git a/UndertaleModLib/Util/GMImage.cs b/UndertaleModLib/Util/GMImage.cs new file mode 100644 index 000000000..3ce06e930 --- /dev/null +++ b/UndertaleModLib/Util/GMImage.cs @@ -0,0 +1,863 @@ +using ICSharpCode.SharpZipLib.BZip2; +using ImageMagick; +using System; +using System.Buffers.Binary; +using System.IO; + +namespace UndertaleModLib.Util; + +/// +/// Immutable wrapper around GameMaker texture images. +/// +public class GMImage +{ + /// + /// Supported formats of GameMaker textures. + /// + public enum ImageFormat + { + /// + /// Raw BGRA color format, with 8 bits per channel (32 bits per pixel). + /// + RawBgra, + + /// + /// PNG file format. + /// + Png, + + /// + /// GameMaker's custom variant of the QOI image file format. + /// + Qoi, + + /// + /// BZip2 compression applied on top of GameMaker's custom variant of the QOI image file format. + /// + Bz2Qoi + } + + /// + /// Format of this image. + /// + public ImageFormat Format { get; init; } + + /// + /// Width of this image, in pixels. + /// + public int Width { get; init; } + + /// + /// Height of this image, in pixels. + /// + public int Height { get; init; } + + /// + /// Maximum supported image width or height. + /// + public const int MaxImageDimension = 16384; + + /// + /// PNG file format magic. + /// + public static ReadOnlySpan MagicPng => new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }; + + /// + /// QOI file format magic. + /// + public static ReadOnlySpan MagicQoi => "fioq"u8; + + /// + /// BZip2 + QOI file format magic. + /// + public static ReadOnlySpan MagicBz2Qoi => "2zoq"u8; + + /// + /// Magic value found near the end of a BZip2 stream (square root of pi). + /// + private static ReadOnlySpan MagicBz2Footer => new byte[] { 0x17, 0x72, 0x45, 0x38, 0x50, 0x90 }; + + /// + /// Backing data for the image, whether compressed or not. + /// + private readonly byte[] _data = null; + + /// + /// If this is a Bz2Qoi image in GameMaker 2022.5 and above, then this is + /// the size of the BZip2 data when entirely uncompressed. + /// + private int _bz2UncompressedSize { get; init; } = -1; + + /// + /// Initializes an image with raw format, of the desired width and height. + /// + /// + /// Creates a completely blank image (black, fully transparent). + /// + public GMImage(int width, int height) + { + if (width is < 0 or > MaxImageDimension) + { + throw new ArgumentOutOfRangeException(nameof(width)); + } + if (height is < 0 or > MaxImageDimension) + { + throw new ArgumentOutOfRangeException(nameof(height)); + } + + Format = ImageFormat.RawBgra; + Width = width; + Height = height; + _data = new byte[width * height * 4]; + } + + /// + /// Basic private constructor for use by other creation methods; just initializes the given fields. + /// + private GMImage(ImageFormat format, int width, int height, byte[] data) + { + Format = format; + Width = width; + Height = height; + _data = data; + } + + /// + /// Searches for the BZ2 footer magic, when around the end of a BZ2 stream, + /// and returns the exact end position of the stream. + /// + private static long FindEndOfBZ2Search(IBinaryReader reader, long endDataPosition) + { + // Read 16 bytes from the end of the BZ2 stream + Span data = stackalloc byte[16]; + reader.Position = endDataPosition - data.Length; + int numBytesRead = reader.Stream.Read(data); + + // Start searching for magic, bit by bit (it is not always byte-aligned) + ReadOnlySpan footerMagic = MagicBz2Footer; + int searchStartPosition = numBytesRead - 1; + int searchStartBitPosition = 0; + while (searchStartPosition >= 0) + { + // Perform search starting from the current search start position + bool foundMatch = false; + int bitPosition = searchStartBitPosition; + int searchPosition = searchStartPosition; + int magicBitPosition = 0; + int magicPosition = footerMagic.Length - 1; + while (searchPosition >= 0) + { + // Get bits at search position and corresponding magic position + bool currentBit = (data[searchPosition] & (1 << bitPosition)) != 0; + bool magicCurrentBit = (footerMagic[magicPosition] & (1 << magicBitPosition)) != 0; + + // If bits mismatch, terminate the current search + if (currentBit != magicCurrentBit) + { + break; + } + + // Found a matching bit! + // Progress magic position to next bit + magicBitPosition++; + if (magicBitPosition >= 8) + { + magicBitPosition = 0; + magicPosition--; + } + + // If we reached the end of the magic, then we successfully found a full match! + if (magicPosition < 0) + { + foundMatch = true; + break; + } + + // We didn't find a full match yet, so we also need to progress our search position to the next bit + bitPosition++; + if (bitPosition >= 8) + { + bitPosition = 0; + searchPosition--; + } + } + + if (foundMatch) + { + // We found a full match, so calculate end of stream position + const int footerByteLength = 10; + long endOfBZ2StreamPosition = searchPosition + footerByteLength; + if (bitPosition != 7) + { + // BZip2 footer started partway through a byte, and so it will end partway through the last byte. + // By the BZip2 specification, the unused bits of the last byte are essentially padding. + endOfBZ2StreamPosition++; + } + + // Return position relative to the start of the data we read + return (endDataPosition - data.Length) + endOfBZ2StreamPosition; + } + + // Current search failed to make a full match, so progress to next bit, to search starting from there + searchStartBitPosition++; + if (searchStartBitPosition >= 8) + { + searchStartBitPosition = 0; + searchStartPosition--; + } + } + + throw new IOException("Failed to find BZip2 footer magic"); + } + + /// + /// Finds the end position of a BZ2 stream exactly, given the start and end bounds of the data. + /// + private static long FindEndOfBZ2Stream(IBinaryReader reader, long startOfStreamPosition, long maxEndOfStreamPosition) + { + if (startOfStreamPosition >= maxEndOfStreamPosition) + { + throw new ArgumentOutOfRangeException(nameof(startOfStreamPosition)); + } + + // Read backwards from the max end of stream position, in up to 256-byte chunks. + // We want to find the end of nonzero data. + const int maxChunkSize = 256; + Span chunkData = stackalloc byte[maxChunkSize]; + long chunkStartPosition = Math.Max(startOfStreamPosition, maxEndOfStreamPosition - maxChunkSize); + int chunkSize = (int)(maxEndOfStreamPosition - chunkStartPosition); + do + { + // Read chunk from stream + reader.Position = chunkStartPosition; + reader.Stream.Read(chunkData[..chunkSize]); + + // Find first nonzero byte at end of stream + int position = chunkSize - 1; + while (position >= 0 && chunkData[position] == 0) + { + position--; + } + + // If we're at nonzero data, then invoke search for footer magic + if (position >= 0 && chunkData[position] != 0) + { + return FindEndOfBZ2Search(reader, chunkStartPosition + position + 1); + } + + // Move backwards to next chunk + chunkStartPosition = Math.Max(startOfStreamPosition, chunkStartPosition - maxChunkSize); + } + while (chunkStartPosition > startOfStreamPosition); + + throw new IOException("Failed to find nonzero data"); + } + + /// + /// Creates a from the image contents stored at the current position of the provided . + /// + /// Binary reader to read the image data from. + /// + /// Location where the image stream must end at or before, from within the . + /// There should only be 0x00 bytes (AKA padding), between the end of the image data and this position. + /// + /// Whether using GameMaker version 2022.5 or above. Relevant only for BZ2 + QOI format images. + /// If no supported texture format is found + /// Image data fails to parse + public static GMImage FromBinaryReader(IBinaryReader reader, long maxEndOfStreamPosition, bool gm2022_5) + { + ArgumentNullException.ThrowIfNull(reader); + + // Determine type of image by reading the first 8 bytes + long startAddress = reader.Position; + ReadOnlySpan header = reader.ReadBytes(8); + + // PNG + if (header.SequenceEqual(MagicPng)) + { + // There's no overall PNG image length, so we parse image chunks, + // which do have their own length, until we find the end + while (true) + { + // PNG is big endian, so swap endianness here manually + uint len = reader.ReadUInt32(); + len = (len >> 16) | (len << 16); + len = ((len & 0xFF00FF00) >> 8) | ((len & 0x00FF00FF) << 8); + + uint type = reader.ReadUInt32(); + reader.Position += len + 4; + if (type == 0x444e4549) // 0x444e4549 -> "IEND" + break; + } + + // Calculate length, read entire image to byte array + long length = reader.Position - startAddress; + reader.Position = startAddress; + return FromPng(reader.ReadBytes((int)length)); + } + + // QOI + BZip2 + if (header.StartsWith(MagicBz2Qoi)) + { + // Skip past (start of) header + reader.Position = startAddress + 8; + + // Read uncompressed data size, if it exists + int serializedUncompressedLength = -1; + int headerSize = 8; + if (gm2022_5) + { + serializedUncompressedLength = reader.ReadInt32(); + headerSize = 12; + } + + // Find compressed data length, by finding end of BZip2 stream + long endOfBZ2Stream = FindEndOfBZ2Stream(reader, reader.Position, maxEndOfStreamPosition); + int compressedLength = (int)(endOfBZ2Stream - (startAddress + headerSize)); + + // Get width/height of image from BZ2 header + int width = header[4] | (header[5] << 8); + int height = header[6] | (header[7] << 8); + + // Read entire image, *EXCLUDING BZ2 HEADER*, to byte array + reader.Position = startAddress + headerSize; + return FromBz2Qoi(reader.ReadBytes(compressedLength), width, height, serializedUncompressedLength); + } + + // QOI + if (header.StartsWith(MagicQoi)) + { + // Read length of data + uint compressedLength = reader.ReadUInt32(); + + // Read entire image to byte array + reader.Position = startAddress; + return FromQoi(reader.ReadBytes(12 + (int)compressedLength)); + } + + throw new IOException("Failed to recognize any known image header"); + } + + // Either retrieves the known uncompressed data size, or makes a lowball guess as to what it could be + private int GetInitialUncompressedBufferCapacity() + { + if (_bz2UncompressedSize != -1) + { + // We already know the uncompressed size, so use it + return _bz2UncompressedSize; + } + else + { + // Make a guess - it's probably at LEAST 2 times larger + return _data.Length * 2; + } + } + + /// + /// Creates a of PNG format, wrapping around the provided byte array containing PNG data. + /// + /// Byte array of PNG data. + /// Whether to check that the PNG magic exists or not. + /// Invalid PNG data, or image is too large + public static GMImage FromPng(byte[] data, bool verifyHeader = false) + { + ArgumentNullException.ThrowIfNull(data); + if (data.Length < 24) + { + throw new InvalidDataException("PNG data is too short"); + } + ReadOnlySpan span = data.AsSpan(); + + // Verify header, if requested + if (verifyHeader && !span[0..8].SequenceEqual(MagicPng)) + { + throw new InvalidDataException("PNG header mismatch (not a PNG file)"); + } + + // Calculate width/height from data + int width = BinaryPrimitives.ReadInt32BigEndian(span[16..20]); + int height = BinaryPrimitives.ReadInt32BigEndian(span[20..24]); + + // Ensure dimensions are valid + if (width is < 0 or > MaxImageDimension) + { + throw new InvalidDataException($"Width out of range ({width})"); + } + if (height is < 0 or > MaxImageDimension) + { + throw new InvalidDataException($"Height out of range ({height})"); + } + + // Create wrapper image + return new GMImage(ImageFormat.Png, width, height, data); + } + + /// + /// Creates a of BZ2 + QOI format, wrapping around the provided byte array containing BZ2-compressed data (no header). + /// + /// Compressed BZ2 data, excluding the header. + /// Width of the image, as provided in BZ2 + QOI header. + /// Height of the image, as provideed in BZ2 + QOI header. + /// Length of BZ2 data when fully uncompressed. + /// Invalid BZ2 + QOI data, or image is too large + public static GMImage FromBz2Qoi(byte[] compressedData, int width, int height, int uncompressedLength) + { + ArgumentNullException.ThrowIfNull(compressedData); + + // Ensure dimensions are valid + if (width is < 0 or > MaxImageDimension) + { + throw new InvalidDataException($"Width out of range ({width})"); + } + if (height is < 0 or > MaxImageDimension) + { + throw new InvalidDataException($"Height out of range ({height})"); + } + + // Create wrapper image + return new GMImage(ImageFormat.Bz2Qoi, width, height, compressedData) + { + _bz2UncompressedSize = uncompressedLength + }; + } + + /// + /// Creates a of QOI format, wrapping around the provided byte array containing QOI data (GameMaker's custom version). + /// + /// Invalid QOI data, or image is too large + public static GMImage FromQoi(byte[] data) + { + ArgumentNullException.ThrowIfNull(data); + if (data.Length < 12) + { + throw new InvalidDataException("QOI data is too short"); + } + + // Calculate width/height from data + ReadOnlySpan span = data.AsSpan(); + int width = BinaryPrimitives.ReadInt16LittleEndian(span[4..6]); + int height = BinaryPrimitives.ReadInt16LittleEndian(span[6..8]); + + // Ensure dimensions are valid + if (width is < 0 or > MaxImageDimension) + { + throw new InvalidDataException($"Width out of range ({width})"); + } + if (height is < 0 or > MaxImageDimension) + { + throw new InvalidDataException($"Height out of range ({height})"); + } + + // Create wrapper image + return new GMImage(ImageFormat.Qoi, width, height, data); + } + + // Settings to be used for raw data, and when encoding a PNG + private MagickReadSettings GetMagickRawToPngSettings() + { + var settings = new MagickReadSettings() + { + Width = Width, + Height = Height, + Format = MagickFormat.Bgra, + Compression = CompressionMethod.NoCompression + }; + settings.SetDefine(MagickFormat.Png32, "compression-level", 4); + settings.SetDefine(MagickFormat.Png32, "compression-filter", 5); + settings.SetDefine(MagickFormat.Png32, "compression-strategy", 2); + return settings; + } + + /// + /// Saves this image as a PNG file, writing the data to the provided . + /// + public void SavePng(Stream stream) + { + switch (Format) + { + case ImageFormat.RawBgra: + { + // Create image using ImageMagick, and save it as PNG format + using var image = new MagickImage(_data, GetMagickRawToPngSettings()); + image.Alpha(AlphaOption.Set); + image.Format = MagickFormat.Png32; + image.Write(stream); + break; + } + case ImageFormat.Png: + { + // Data is already encoded as PNG; just use that + stream.Write(_data); + break; + } + case ImageFormat.Qoi: + { + // Convert to raw image data, and then save that to a PNG + GMImage rawImage = QoiConverter.GetImageFromSpan(_data); + rawImage.SavePng(stream); + break; + } + case ImageFormat.Bz2Qoi: + { + GMImage rawImage; + + using (MemoryStream uncompressedData = new(GetInitialUncompressedBufferCapacity())) + { + // Decompress BZ2 data + using (MemoryStream compressedData = new(_data)) + { + BZip2.Decompress(compressedData, uncompressedData, false); + } + + // Convert to raw image data + uncompressedData.Seek(0, SeekOrigin.Begin); + rawImage = QoiConverter.GetImageFromStream(uncompressedData); + } + + // Save raw image to PNG + rawImage.SavePng(stream); + break; + } + default: + throw new InvalidOperationException($"Unknown format {Format}"); + } + } + + /// + /// Returns the same or a new ; the result of converting this image to the specified . + /// + /// Format to convert to + /// Reusable shared to be used when compressing with BZ2, as required. + public GMImage ConvertToFormat(ImageFormat format, MemoryStream sharedStream = null) + { + return format switch + { + ImageFormat.RawBgra => ConvertToRawBgra(), + ImageFormat.Png => ConvertToPng(), + ImageFormat.Qoi => ConvertToQoi(), + ImageFormat.Bz2Qoi => ConvertToBz2Qoi(sharedStream), + _ => throw new ArgumentOutOfRangeException(nameof(format)), + }; + } + + /// + /// Returns the same or a new ; the result of converting this image to format. + /// + public GMImage ConvertToRawBgra() + { + switch (Format) + { + case ImageFormat.RawBgra: + { + // Already in correct format; no conversion to be done + return this; + } + case ImageFormat.Png: + { + // Convert image to raw byte array + var image = new MagickImage(_data); + image.Alpha(AlphaOption.Set); + image.Format = MagickFormat.Bgra; + image.SetCompression(CompressionMethod.NoCompression); + return new GMImage(ImageFormat.RawBgra, Width, Height, image.ToByteArray()); + } + case ImageFormat.Qoi: + { + // Convert to raw image data + return QoiConverter.GetImageFromSpan(_data); + } + case ImageFormat.Bz2Qoi: + { + using (MemoryStream uncompressedData = new(GetInitialUncompressedBufferCapacity())) + { + // Decompress BZ2 data + using (MemoryStream compressedData = new(_data)) + { + BZip2.Decompress(compressedData, uncompressedData, false); + } + + // Convert to raw image data + uncompressedData.Seek(0, SeekOrigin.Begin); + return QoiConverter.GetImageFromStream(uncompressedData); + } + } + } + + throw new InvalidOperationException($"Unknown source format {Format}"); + } + + /// + /// Returns the same or a new ; the result of converting this image to format. + /// + public GMImage ConvertToPng() + { + switch (Format) + { + case ImageFormat.RawBgra: + { + // Create image using ImageMagick, and convert it to PNG format + using var image = new MagickImage(_data, GetMagickRawToPngSettings()); + image.Alpha(AlphaOption.Set); + image.Format = MagickFormat.Png32; + return new GMImage(ImageFormat.Png, Width, Height, image.ToByteArray()); + } + case ImageFormat.Png: + { + // Already in correct format; no conversion to be done + return this; + } + case ImageFormat.Qoi: + { + // Convert to raw image data, and then convert that to a PNG + GMImage rawImage = QoiConverter.GetImageFromSpan(_data); + return rawImage.ConvertToPng(); + } + case ImageFormat.Bz2Qoi: + { + GMImage rawImage; + + using (MemoryStream uncompressedData = new(GetInitialUncompressedBufferCapacity())) + { + // Decompress BZ2 data + using (MemoryStream compressedData = new(_data)) + { + BZip2.Decompress(compressedData, uncompressedData, false); + } + + // Convert to raw image data + uncompressedData.Seek(0, SeekOrigin.Begin); + rawImage = QoiConverter.GetImageFromStream(uncompressedData); + } + + // Convert raw image to PNG + return rawImage.ConvertToPng(); + } + } + + throw new InvalidOperationException($"Unknown source format {Format}"); + } + + /// + /// Returns the same or a new ; the result of converting this image to format. + /// + public GMImage ConvertToQoi() + { + switch (Format) + { + case ImageFormat.RawBgra: + case ImageFormat.Png: + case ImageFormat.Bz2Qoi: + { + // Encode image as QOI + return new GMImage(ImageFormat.Qoi, Width, Height, QoiConverter.GetArrayFromImage(this, false)); + } + case ImageFormat.Qoi: + { + // Already in correct format; no conversion to be done + return this; + } + } + + throw new InvalidOperationException($"Unknown source format {Format}"); + } + + /// + /// Compresses the provided QOI data using BZ2, and using the shared , if not null. + /// + /// A new BZ2 + QOI image with the compressed data. + private static GMImage CompressQoiData(int width, int height, byte[] qoiData, MemoryStream sharedStream) + { + // Compress into new byte array + byte[] compressed; + if (sharedStream is not null) + { + // Use existing shared stream to compress the data + using var input = new MemoryStream(qoiData); + if (sharedStream.Length != 0) + { + // Ensure shared stream is at the beginning + sharedStream.Seek(0, SeekOrigin.Begin); + } + BZip2.Compress(input, sharedStream, false, 9); + compressed = sharedStream.GetBuffer().AsSpan()[..(int)sharedStream.Position].ToArray(); + } + else + { + // Use a new memory stream to compress the data + using var input = new MemoryStream(qoiData); + using var output = new MemoryStream(); + BZip2.Compress(input, output, false, 9); + compressed = output.GetBuffer().AsSpan()[..(int)output.Position].ToArray(); + } + + return new GMImage(ImageFormat.Bz2Qoi, width, height, compressed) + { + _bz2UncompressedSize = qoiData.Length + }; + } + + /// + /// Returns the same or a new ; the result of converting this image to format. + /// + /// Shared to be reused for BZ2 compression, if required. + public GMImage ConvertToBz2Qoi(MemoryStream sharedStream = null) + { + switch (Format) + { + case ImageFormat.RawBgra: + case ImageFormat.Png: + { + // Encode image as QOI, first + byte[] data = QoiConverter.GetArrayFromImage(this, false); + return CompressQoiData(Width, Height, data, sharedStream); + } + case ImageFormat.Qoi: + { + // Already have QOI data, so just compress it + return CompressQoiData(Width, Height, _data, sharedStream); + } + case ImageFormat.Bz2Qoi: + { + // Already in correct format; no conversion to be done + return this; + } + } + + throw new InvalidOperationException($"Unknown source format {Format}"); + } + + /// + /// Returns the raw BGRA32 pixel data of this image, which can be modified. + /// + /// + /// Only works if the image format is ; otherwise, you must first convert to that format using . + /// + /// Image format is not . + public Span GetRawImageData() + { + if (Format != ImageFormat.RawBgra) + { + throw new InvalidOperationException("Image is not in raw format"); + } + + return _data.AsSpan(); + } + + /// + /// Writes this image, in its current format (as seen on disk), to the current position of the specified . + /// + /// The gm2022_5 parameter is only relevant for images of BZ2 + QOI format. + /// instance to write to. + /// True if using GameMaker 2022.5 format or above; false otherwise. + public void WriteToBinaryWriter(BinaryWriter writer, bool gm2022_5) + { + switch (Format) + { + case ImageFormat.RawBgra: + case ImageFormat.Png: + case ImageFormat.Qoi: + // Data is stored identically to file format, so write it verbatim + writer.Write(_data); + break; + case ImageFormat.Bz2Qoi: + // Header is missing in this case, so we need to generate it first + writer.Write(MagicBz2Qoi); + writer.Write((short)Width); + writer.Write((short)Height); + if (gm2022_5) + { + if (_bz2UncompressedSize == -1) + { + throw new InvalidOperationException("BZ2 uncompressed data size was not set"); + } + writer.Write(_bz2UncompressedSize); + } + writer.Write(_data); + break; + default: + throw new InvalidOperationException($"Unknown format {Format}"); + } + } + + /// + /// Converts the image to its byte array/span representation (as seen on disk). + /// + /// True if using GameMaker 2022.5 format or above; false otherwise. + /// The gm2022_5 parameter is only relevant for images of BZ2 + QOI format. + public ReadOnlySpan ToSpan(bool gm2022_5 = false) + { + if (Format != ImageFormat.Bz2Qoi) + { + // All formats except BZ2 + QOI are stored verbatim, so just return them + return _data.AsSpan(); + } + + // We need to perform a full write with a BinaryWriter + using (MemoryStream ms = new(_data.Length + 16)) + { + using (BinaryWriter bw = new(ms)) + { + WriteToBinaryWriter(bw, gm2022_5); + } + + return ms.GetBuffer()[..(int)ms.Position].AsSpan(); + } + } + + /// + /// Returns a new with the contents of this image. + /// + public MagickImage GetMagickImage() + { + switch (Format) + { + case ImageFormat.Png: + { + // Parse the PNG data + MagickReadSettings settings = new() + { + ColorSpace = ColorSpace.sRGB, + Format = MagickFormat.Png + }; + MagickImage image = new(_data, settings); + image.Alpha(AlphaOption.Set); + image.Format = MagickFormat.Bgra; + image.SetCompression(CompressionMethod.NoCompression); + return image; + } + case ImageFormat.RawBgra: + { + // Parse the raw data + MagickReadSettings settings = new() + { + Width = Width, + Height = Height, + Format = MagickFormat.Bgra, + Compression = CompressionMethod.NoCompression + }; + MagickImage image = new(_data, settings); + image.Alpha(AlphaOption.Set); + return image; + } + case ImageFormat.Qoi: + case ImageFormat.Bz2Qoi: + // Convert to raw data, then parse that + return ConvertToRawBgra().GetMagickImage(); + } + + throw new InvalidOperationException($"Unknown format {Format}"); + } + + /// + /// Creates a new raw format with the contents of the provided . + /// + /// + /// This modifies the image format of the provided to avoid unnecessary copies. + /// + public static GMImage FromMagickImage(IMagickImage image) + { + image.Format = MagickFormat.Bgra; + image.SetCompression(CompressionMethod.NoCompression); + return new GMImage(ImageFormat.RawBgra, image.Width, image.Height, image.ToByteArray()); + } +} diff --git a/UndertaleModLib/Util/QoiConverter.cs b/UndertaleModLib/Util/QoiConverter.cs index 19ceecc78..bbf48d375 100644 --- a/UndertaleModLib/Util/QoiConverter.cs +++ b/UndertaleModLib/Util/QoiConverter.cs @@ -1,7 +1,4 @@ using System; -using System.Collections.Generic; -using System.Drawing; -using System.Drawing.Imaging; using System.IO; namespace UndertaleModLib.Util @@ -46,12 +43,12 @@ public static void InitSharedBuffer(int size) } /// - /// Creates a from a . + /// Creates a raw format from a . /// /// The stream to create the PNG image from. - /// The QOI image as a PNG. + /// The QOI image as a raw format image. /// If there is an invalid QOIF magic header or there was an error with stride width. - public static Bitmap GetImageFromStream(Stream s) + public static GMImage GetImageFromStream(Stream s) { Span header = stackalloc byte[12]; s.Read(header); @@ -63,19 +60,19 @@ public static Bitmap GetImageFromStream(Stream s) } /// - /// Creates a from a of s. + /// Creates a raw format from a of s. /// - /// The of s to create the PNG image from. - /// The QOI image as a PNG. + /// The of s to create the raw image from. + /// The QOI image as a raw format image. /// If there is an invalid QOIF magic header or there was an error with stride width. - public static Bitmap GetImageFromSpan(ReadOnlySpan bytes) => GetImageFromSpan(bytes, out _); + public static GMImage GetImageFromSpan(ReadOnlySpan bytes) => GetImageFromSpan(bytes, out _); /// /// /// The total amount of data read from the . /// /// - public unsafe static Bitmap GetImageFromSpan(ReadOnlySpan bytes, out int length) + public unsafe static GMImage GetImageFromSpan(ReadOnlySpan bytes, out int length) { ReadOnlySpan header = bytes[..12]; if (header[0] != (byte)'f' || header[1] != (byte)'i' || header[2] != (byte)'o' || header[3] != (byte)'q') @@ -87,245 +84,262 @@ public unsafe static Bitmap GetImageFromSpan(ReadOnlySpan bytes, out int l ReadOnlySpan pixelData = bytes.Slice(12, length); - Bitmap bmp = new Bitmap(width, height, PixelFormat.Format32bppArgb); - bmp.SetResolution(96.0f, 96.0f); - - BitmapData data = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height), ImageLockMode.ReadWrite, PixelFormat.Format32bppArgb); - if (data.Stride != width * 4) - throw new Exception("Need to reimplement QOI conversions to account for stride, apparently"); - - byte* bmpPtr = (byte*)data.Scan0; - byte* bmpEnd = bmpPtr + (4 * width * height); - - int pos = 0; - int run = 0; - byte r = 0, g = 0, b = 0, a = 255; - Span index = stackalloc byte[64 * 4]; - while (bmpPtr < bmpEnd) + GMImage img = new(width, height); + Span rawData = img.GetRawImageData(); + fixed (byte* imgData = rawData) { - if (run > 0) - { - run--; - } - else if (pos < pixelData.Length) + byte* bmpPtr = imgData; + byte* bmpEnd = bmpPtr + rawData.Length; + + int pos = 0; + int run = 0; + byte r = 0, g = 0, b = 0, a = 255; + Span index = stackalloc byte[64 * 4]; + while (bmpPtr < bmpEnd) { - int b1 = pixelData[pos++]; - - if ((b1 & QOI_MASK_2) == QOI_INDEX) - { - int indexPos = (b1 ^ QOI_INDEX) << 2; - r = index[indexPos]; - g = index[indexPos + 1]; - b = index[indexPos + 2]; - a = index[indexPos + 3]; - } - else if ((b1 & QOI_MASK_3) == QOI_RUN_8) - { - run = b1 & 0x1f; - } - else if ((b1 & QOI_MASK_3) == QOI_RUN_16) + if (run > 0) { - int b2 = pixelData[pos++]; - run = (((b1 & 0x1f) << 8) | b2) + 32; + run--; } - else if ((b1 & QOI_MASK_2) == QOI_DIFF_8) + else if (pos < pixelData.Length) { - r += (byte)(((b1 & 48) << 26 >> 30) & 0xff); - g += (byte)(((b1 & 12) << 28 >> 22 >> 8) & 0xff); - b += (byte)(((b1 & 3) << 30 >> 14 >> 16) & 0xff); - } - else if ((b1 & QOI_MASK_3) == QOI_DIFF_16) - { - int b2 = pixelData[pos++]; - int merged = b1 << 8 | b2; - r += (byte)(((merged & 7936) << 19 >> 27) & 0xff); - g += (byte)(((merged & 240) << 24 >> 20 >> 8) & 0xff); - b += (byte)(((merged & 15) << 28 >> 12 >> 16) & 0xff); - } - else if ((b1 & QOI_MASK_4) == QOI_DIFF_24) - { - int b2 = pixelData[pos++]; - int b3 = pixelData[pos++]; - int merged = b1 << 16 | b2 << 8 | b3; - r += (byte)(((merged & 1015808) << 12 >> 27) & 0xff); - g += (byte)(((merged & 31744) << 17 >> 19 >> 8) & 0xff); - b += (byte)(((merged & 992) << 22 >> 11 >> 16) & 0xff); - a += (byte)(((merged & 31) << 27 >> 3 >> 24) & 0xff); - } - else if ((b1 & QOI_MASK_4) == QOI_COLOR) - { - if ((b1 & 8) != 0) - r = pixelData[pos++]; - if ((b1 & 4) != 0) - g = pixelData[pos++]; - if ((b1 & 2) != 0) - b = pixelData[pos++]; - if ((b1 & 1) != 0) - a = pixelData[pos++]; + int b1 = pixelData[pos++]; + + if ((b1 & QOI_MASK_2) == QOI_INDEX) + { + int indexPos = (b1 ^ QOI_INDEX) << 2; + r = index[indexPos]; + g = index[indexPos + 1]; + b = index[indexPos + 2]; + a = index[indexPos + 3]; + } + else if ((b1 & QOI_MASK_3) == QOI_RUN_8) + { + run = b1 & 0x1f; + } + else if ((b1 & QOI_MASK_3) == QOI_RUN_16) + { + int b2 = pixelData[pos++]; + run = (((b1 & 0x1f) << 8) | b2) + 32; + } + else if ((b1 & QOI_MASK_2) == QOI_DIFF_8) + { + r += (byte)(((b1 & 48) << 26 >> 30) & 0xff); + g += (byte)(((b1 & 12) << 28 >> 22 >> 8) & 0xff); + b += (byte)(((b1 & 3) << 30 >> 14 >> 16) & 0xff); + } + else if ((b1 & QOI_MASK_3) == QOI_DIFF_16) + { + int b2 = pixelData[pos++]; + int merged = b1 << 8 | b2; + r += (byte)(((merged & 7936) << 19 >> 27) & 0xff); + g += (byte)(((merged & 240) << 24 >> 20 >> 8) & 0xff); + b += (byte)(((merged & 15) << 28 >> 12 >> 16) & 0xff); + } + else if ((b1 & QOI_MASK_4) == QOI_DIFF_24) + { + int b2 = pixelData[pos++]; + int b3 = pixelData[pos++]; + int merged = b1 << 16 | b2 << 8 | b3; + r += (byte)(((merged & 1015808) << 12 >> 27) & 0xff); + g += (byte)(((merged & 31744) << 17 >> 19 >> 8) & 0xff); + b += (byte)(((merged & 992) << 22 >> 11 >> 16) & 0xff); + a += (byte)(((merged & 31) << 27 >> 3 >> 24) & 0xff); + } + else if ((b1 & QOI_MASK_4) == QOI_COLOR) + { + if ((b1 & 8) != 0) + r = pixelData[pos++]; + if ((b1 & 4) != 0) + g = pixelData[pos++]; + if ((b1 & 2) != 0) + b = pixelData[pos++]; + if ((b1 & 1) != 0) + a = pixelData[pos++]; + } + + int indexPos2 = ((r ^ g ^ b ^ a) & 63) << 2; + index[indexPos2] = r; + index[indexPos2 + 1] = g; + index[indexPos2 + 2] = b; + index[indexPos2 + 3] = a; } - int indexPos2 = ((r ^ g ^ b ^ a) & 63) << 2; - index[indexPos2] = r; - index[indexPos2 + 1] = g; - index[indexPos2 + 2] = b; - index[indexPos2 + 3] = a; + *bmpPtr++ = b; + *bmpPtr++ = g; + *bmpPtr++ = r; + *bmpPtr++ = a; } - - *bmpPtr++ = b; - *bmpPtr++ = g; - *bmpPtr++ = r; - *bmpPtr++ = a; } - bmp.UnlockBits(data); - length += header.Length; - return bmp; + return img; } /// - /// Creates a QOI image as a byte array from a . + /// Creates a QOI image as a byte array from a . /// - /// The to create the QOI image from. - /// The amount of bytes of padding that should be used. + /// The to create the QOI image from. + /// True if the QOI shared buffer should be used; false if a newly-allocated buffer should be used. /// A QOI Image as a byte array. /// If there was an error with stride width. - public static byte[] GetArrayFromImage(Bitmap bmp, int padding = 4) => GetSpanFromImage(bmp, padding).ToArray(); + public static byte[] GetArrayFromImage(GMImage img, bool useSharedBuffer = true) => GetSpanFromImage(img, useSharedBuffer).ToArray(); /// - /// Creates a QOI image as a from a . + /// Creates a QOI image as a from a . /// - /// The to create the QOI image from. - /// The amount of bytes of padding that should be used. + /// The to create the QOI image from. + /// True if the QOI shared buffer should be used; false if a newly-allocated buffer should be used. /// A QOI Image as a byte array. /// If there was an error with stride width. - public static unsafe Span GetSpanFromImage(Bitmap bmp, int padding = 4) + public static unsafe Span GetSpanFromImage(GMImage img, bool useSharedBuffer = true) { - if (!isBufferEmpty) - Array.Clear(sharedBuffer); - - // Little-endian QOIF image magic - sharedBuffer[0] = (byte)'f'; - sharedBuffer[1] = (byte)'i'; - sharedBuffer[2] = (byte)'o'; - sharedBuffer[3] = (byte)'q'; - sharedBuffer[4] = (byte)(bmp.Width & 0xff); - sharedBuffer[5] = (byte)((bmp.Width >> 8) & 0xff); - sharedBuffer[6] = (byte)(bmp.Height & 0xff); - sharedBuffer[7] = (byte)((bmp.Height >> 8) & 0xff); - - BitmapData data = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height), ImageLockMode.ReadWrite, PixelFormat.Format32bppArgb); - if (data.Stride != bmp.Width * 4) - throw new Exception("Need to reimplement QOI conversions to account for stride, apparently"); + ArgumentNullException.ThrowIfNull(img); - byte* bmpPtr = (byte*)data.Scan0; - byte* bmpEnd = bmpPtr + (4 * bmp.Width * bmp.Height); - - int resPos = HeaderSize; - byte r = 0, g = 0, b = 0, a = 255; - int run = 0; - int v = 0, vPrev = 0xff; - Span index = stackalloc int[64]; - while (bmpPtr < bmpEnd) + // Prepare buffer + byte[] buffer; + int requiredSize = (img.Width * img.Height * MaxChunkSize) + HeaderSize; + if (useSharedBuffer) { - b = *bmpPtr; - g = *(bmpPtr + 1); - r = *(bmpPtr + 2); - a = *(bmpPtr + 3); - - v = (r << 24) | (g << 16) | (b << 8) | a; - if (v == vPrev) - run++; - if (run > 0 && (run == 0x2020 || v != vPrev || bmpPtr == bmpEnd - 4)) + // Use shared buffer (ensure it has enough space) + if (sharedBuffer is null || sharedBuffer.Length < requiredSize) { - if (run < 33) - { - run -= 1; - sharedBuffer[resPos++] = (byte)(QOI_RUN_8 | run); - } - else - { - run -= 33; - sharedBuffer[resPos++] = (byte)(QOI_RUN_16 | (run >> 8)); - sharedBuffer[resPos++] = (byte)run; - } - run = 0; + InitSharedBuffer(requiredSize); + } + if (!isBufferEmpty) + { + Array.Clear(sharedBuffer); } - if (v != vPrev) + buffer = sharedBuffer; + } + else + { + // Allocate a new buffer + buffer = new byte[requiredSize]; + } + + // Little-endian QOIF image magic + buffer[0] = (byte)'f'; + buffer[1] = (byte)'i'; + buffer[2] = (byte)'o'; + buffer[3] = (byte)'q'; + buffer[4] = (byte)(img.Width & 0xff); + buffer[5] = (byte)((img.Width >> 8) & 0xff); + buffer[6] = (byte)(img.Height & 0xff); + buffer[7] = (byte)((img.Height >> 8) & 0xff); + + // Get raw image data, and encode the compressed data as per custom GameMaker format + GMImage rawImage = img.ConvertToRawBgra(); + Span rawData = rawImage.GetRawImageData(); + int resPos = HeaderSize; + fixed (byte* bmpStart = rawData) + { + byte* bmpPtr = bmpStart; + byte* bmpEnd = bmpPtr + (4 * img.Width * img.Height); + + byte r = 0, g = 0, b = 0, a = 255; + int run = 0; + int v = 0, vPrev = 0xff; + Span index = stackalloc int[64]; + while (bmpPtr < bmpEnd) { - int indexPos = (r ^ g ^ b ^ a) & 63; - if (index[indexPos] == v) + b = *bmpPtr; + g = *(bmpPtr + 1); + r = *(bmpPtr + 2); + a = *(bmpPtr + 3); + + v = (r << 24) | (g << 16) | (b << 8) | a; + if (v == vPrev) + run++; + if (run > 0 && (run == 0x2020 || v != vPrev || bmpPtr == bmpEnd - 4)) { - sharedBuffer[resPos++] = (byte)(QOI_INDEX | indexPos); + if (run < 33) + { + run -= 1; + buffer[resPos++] = (byte)(QOI_RUN_8 | run); + } + else + { + run -= 33; + buffer[resPos++] = (byte)(QOI_RUN_16 | (run >> 8)); + buffer[resPos++] = (byte)run; + } + run = 0; } - else + if (v != vPrev) { - index[indexPos] = v; - - int vr = r - ((vPrev >> 24) & 0xff); - int vg = g - ((vPrev >> 16) & 0xff); - int vb = b - ((vPrev >> 8) & 0xff); - int va = a - (vPrev & 0xff); - if (vr > -17 && vr < 16 && - vg > -17 && vg < 16 && - vb > -17 && vb < 16 && - va > -17 && va < 16) + int indexPos = (r ^ g ^ b ^ a) & 63; + if (index[indexPos] == v) { - if (va == 0 && - vr > -3 && vr < 2 && - vg > -3 && vg < 2 && - vb > -3 && vb < 2) - { - sharedBuffer[resPos++] = (byte)(QOI_DIFF_8 | (vr << 4 & 48) | (vg << 2 & 12) | (vb & 3)); - } - else if (va == 0 && - vg > -9 && vg < 8 && - vb > -9 && vb < 8) + buffer[resPos++] = (byte)(QOI_INDEX | indexPos); + } + else + { + index[indexPos] = v; + + int vr = r - ((vPrev >> 24) & 0xff); + int vg = g - ((vPrev >> 16) & 0xff); + int vb = b - ((vPrev >> 8) & 0xff); + int va = a - (vPrev & 0xff); + if (vr > -17 && vr < 16 && + vg > -17 && vg < 16 && + vb > -17 && vb < 16 && + va > -17 && va < 16) { - sharedBuffer[resPos++] = (byte)(QOI_DIFF_16 | (vr & 31)); - sharedBuffer[resPos++] = (byte)((vg << 4 & 240) | (vb & 15)); + if (va == 0 && + vr > -3 && vr < 2 && + vg > -3 && vg < 2 && + vb > -3 && vb < 2) + { + buffer[resPos++] = (byte)(QOI_DIFF_8 | (vr << 4 & 48) | (vg << 2 & 12) | (vb & 3)); + } + else if (va == 0 && + vg > -9 && vg < 8 && + vb > -9 && vb < 8) + { + buffer[resPos++] = (byte)(QOI_DIFF_16 | (vr & 31)); + buffer[resPos++] = (byte)((vg << 4 & 240) | (vb & 15)); + } + else + { + buffer[resPos++] = (byte)(QOI_DIFF_24 | (vr >> 1 & 15)); + buffer[resPos++] = (byte)((vr << 7 & 128) | (vg << 2 & 124) | (vb >> 3 & 3)); + buffer[resPos++] = (byte)((vb << 5 & 224) | (va & 31)); + } } else { - sharedBuffer[resPos++] = (byte)(QOI_DIFF_24 | (vr >> 1 & 15)); - sharedBuffer[resPos++] = (byte)((vr << 7 & 128) | (vg << 2 & 124) | (vb >> 3 & 3)); - sharedBuffer[resPos++] = (byte)((vb << 5 & 224) | (va & 31)); + buffer[resPos++] = (byte)(QOI_COLOR | (vr != 0 ? 8 : 0) | (vg != 0 ? 4 : 0) | (vb != 0 ? 2 : 0) | (va != 0 ? 1 : 0)); + if (vr != 0) + buffer[resPos++] = r; + if (vg != 0) + buffer[resPos++] = g; + if (vb != 0) + buffer[resPos++] = b; + if (va != 0) + buffer[resPos++] = a; } } - else - { - sharedBuffer[resPos++] = (byte)(QOI_COLOR | (vr != 0 ? 8 : 0) | (vg != 0 ? 4 : 0) | (vb != 0 ? 2 : 0) | (va != 0 ? 1 : 0)); - if (vr != 0) - sharedBuffer[resPos++] = r; - if (vg != 0) - sharedBuffer[resPos++] = g; - if (vb != 0) - sharedBuffer[resPos++] = b; - if (va != 0) - sharedBuffer[resPos++] = a; - } } - } - vPrev = v; - bmpPtr += 4; + vPrev = v; + bmpPtr += 4; + } } - bmp.UnlockBits(data); - - // Add padding - resPos += padding; - // Write final length int length = resPos - HeaderSize; - sharedBuffer[8] = (byte)(length & 0xff); - sharedBuffer[9] = (byte)((length >> 8) & 0xff); - sharedBuffer[10] = (byte)((length >> 16) & 0xff); - sharedBuffer[11] = (byte)((length >> 24) & 0xff); + buffer[8] = (byte)(length & 0xff); + buffer[9] = (byte)((length >> 8) & 0xff); + buffer[10] = (byte)((length >> 16) & 0xff); + buffer[11] = (byte)((length >> 24) & 0xff); - isBufferEmpty = false; + if (useSharedBuffer) + { + isBufferEmpty = false; + } - return sharedBuffer.AsSpan()[..resPos]; + return buffer.AsSpan()[..resPos]; } } } \ No newline at end of file diff --git a/UndertaleModLib/Util/TextureWorker.cs b/UndertaleModLib/Util/TextureWorker.cs index aef3189bc..30c712c4e 100644 --- a/UndertaleModLib/Util/TextureWorker.cs +++ b/UndertaleModLib/Util/TextureWorker.cs @@ -1,217 +1,291 @@ -using System; +using ImageMagick; +using System; using System.Collections.Generic; -using System.Drawing; -using System.Drawing.Drawing2D; -using System.Drawing.Imaging; using System.IO; using UndertaleModLib.Models; namespace UndertaleModLib.Util { - public class TextureWorker + /// + /// Helper class used to manage and cache textures. + /// + public class TextureWorker : IDisposable { - private Dictionary embeddedDictionary = new Dictionary(); - private static readonly ImageConverter _imageConverter = new ImageConverter(); - - // Cleans up all the images when usage of this worker is finished. - // Should be called when a TextureWorker will never be used again. - public void Cleanup() - { - foreach (Bitmap img in embeddedDictionary.Values) - img.Dispose(); - embeddedDictionary.Clear(); - } - - public Bitmap GetEmbeddedTexture(UndertaleEmbeddedTexture embeddedTexture) + private Dictionary embeddedDictionary = new(); + private readonly object embeddedDictionaryLock = new(); + + /// + /// Retrieves an image representing the supplied texture page. + /// + /// + /// The returned image will be cached for this instance. + /// + /// Texture to get an image from. + /// with the contents of the given texture's image. + public MagickImage GetEmbeddedTexture(UndertaleEmbeddedTexture embeddedTexture) { - lock (embeddedDictionary) + lock (embeddedDictionaryLock) { - if (!embeddedDictionary.ContainsKey(embeddedTexture)) - embeddedDictionary[embeddedTexture] = GetImageFromByteArray(embeddedTexture.TextureData.TextureBlob); - return embeddedDictionary[embeddedTexture]; + // Try to find cached image + if (embeddedDictionary.TryGetValue(embeddedTexture, out MagickImage image)) + { + return image; + } + + // Otherwise, create new image + MagickImage newImage = embeddedTexture.TextureData.Image.GetMagickImage(); + embeddedDictionary[embeddedTexture] = newImage; + + return newImage; } } - public void ExportAsPNG(UndertaleTexturePageItem texPageItem, string FullPath, string imageName = null, bool includePadding = false) + /// + /// Exports the given texture page item to disk, as a PNG, to the supplied path. (With or without padding.) + /// + /// Texture page item to export. + /// File path to export to. + /// Image name to be used when throwing exceptions, or null to use the filename from the path. + /// True if padding should be exported; false otherwise. + public void ExportAsPNG(UndertaleTexturePageItem texPageItem, string filePath, string imageName = null, bool includePadding = false) { - SaveImageToFile(FullPath, GetTextureFor(texPageItem, imageName != null ? imageName : Path.GetFileNameWithoutExtension(FullPath), includePadding)); + using var image = GetTextureFor(texPageItem, imageName ?? Path.GetFileNameWithoutExtension(filePath), includePadding); + SaveImageToFile(image, filePath); } - public Bitmap GetTextureFor(UndertaleTexturePageItem texPageItem, string imageName, bool includePadding = false) + /// + /// Creates an image representing the sole texture page item supplied, with or without padding. + /// + /// Texture page item to get the image of. + /// Image name to be used when throwing exceptions. + /// True if padding should be used in the returned image; false otherwise. + /// An image with the contents of the given texture page item's portion of its texture page. + public IMagickImage GetTextureFor(UndertaleTexturePageItem texPageItem, string imageName, bool includePadding = false) { + // Get texture page that the item lives on + MagickImage embeddedImage = GetEmbeddedTexture(texPageItem.TexturePage); + + // Ensure texture is no larger than its bounding box int exportWidth = texPageItem.BoundingWidth; // sprite.Width int exportHeight = texPageItem.BoundingHeight; // sprite.Height - Bitmap embeddedImage = GetEmbeddedTexture(texPageItem.TexturePage); - - // Sanity checks. - if (includePadding && ((texPageItem.TargetWidth > exportWidth) || (texPageItem.TargetHeight > exportHeight))) - throw new InvalidDataException(imageName + "'s texture is larger than its bounding box!"); + if (includePadding && (texPageItem.TargetWidth > exportWidth || texPageItem.TargetHeight > exportHeight)) + { + throw new InvalidDataException($"{imageName}'s texture is larger than its bounding box!"); + } - // Create a bitmap representing that part of the texture page. - Bitmap resultImage = null; + // Create an image cropped from the item's part of the texture page + IMagickImage croppedImage = null; lock (embeddedImage) { - try - { - resultImage = embeddedImage.Clone(new Rectangle(texPageItem.SourceX, texPageItem.SourceY, texPageItem.SourceWidth, texPageItem.SourceHeight), PixelFormat.DontCare); - } - catch (OutOfMemoryException) - { - throw new OutOfMemoryException(imageName + "'s texture is abnormal. 'Source Position/Size' boxes 3 & 4 on texture page may be bigger than the sprite itself or it's set to '0'."); - } + croppedImage = embeddedImage.Clone(texPageItem.SourceX, texPageItem.SourceY, texPageItem.SourceWidth, texPageItem.SourceHeight); } - // Resize the image, if necessary. - if ((texPageItem.SourceWidth != texPageItem.TargetWidth) || (texPageItem.SourceHeight != texPageItem.TargetHeight)) - resultImage = ResizeImage(resultImage, texPageItem.TargetWidth, texPageItem.TargetHeight); + // Resize the image, if necessary + if (texPageItem.SourceWidth != texPageItem.TargetWidth || texPageItem.SourceHeight != texPageItem.TargetHeight) + { + IMagickImage original = croppedImage; + croppedImage = ResizeImage(croppedImage, texPageItem.TargetWidth, texPageItem.TargetHeight); + original.Dispose(); + } - // Put it in the final holder image. - Bitmap returnImage = resultImage; + // Put it in the final holder image, if necessary + IMagickImage returnImage = croppedImage; if (includePadding) { - returnImage = new Bitmap(exportWidth, exportHeight); - Graphics g = Graphics.FromImage(returnImage); - g.DrawImage(resultImage, new Rectangle(texPageItem.TargetX, texPageItem.TargetY, resultImage.Width, resultImage.Height), new Rectangle(0, 0, resultImage.Width, resultImage.Height), GraphicsUnit.Pixel); - g.Dispose(); + returnImage = new MagickImage(MagickColors.Transparent, exportWidth, exportHeight); + returnImage.Composite(croppedImage, texPageItem.TargetX, texPageItem.TargetY, CompositeOperator.Copy); + croppedImage.Dispose(); } return returnImage; } - public static Bitmap ReadImageFromFile(string filePath) + /// + /// Reads an image from the given file path (of arbitrary format, as supported by ). + /// + /// + /// Image color format will always be converted to BGRA, with no compression. + /// + /// File path to read the image from. + /// An image, in uncompressed BGRA format, containing the contents of the image file at the given path. + public static MagickImage ReadBGRAImageFromFile(string filePath) { - return GetImageFromByteArray(File.ReadAllBytes(filePath)); + MagickReadSettings settings = new() + { + ColorSpace = ColorSpace.sRGB, + }; + MagickImage image = new(filePath, settings); + image.Alpha(AlphaOption.Set); + image.Format = MagickFormat.Bgra; + image.SetCompression(CompressionMethod.NoCompression); + return image; } - // Grabbed from https://stackoverflow.com/questions/3801275/how-to-convert-image-to-byte-array/16576471#16576471 - public static Bitmap GetImageFromByteArray(byte[] byteArray) + /// + /// Performs a resize of the given image, if required, using the specified interpolation (bilinear by default). Always returns a new image. + /// + /// Image to be resized (without being modified). + /// Desired width to resize to. + /// Desired height to resize to. + /// Pixel interpolation method to use, or specify none to use bilinear interpolation. + /// A copy of the provided image, which is resized to the given dimensions when required. + public static IMagickImage ResizeImage(IMagickImage image, int width, int height, PixelInterpolateMethod interpolateMethod = PixelInterpolateMethod.Bilinear) { - Bitmap bm = (Bitmap)_imageConverter.ConvertFrom(byteArray); + // Clone image + IMagickImage newImage = image.Clone(); - if (bm != null && (bm.HorizontalResolution != (int)bm.HorizontalResolution || - bm.VerticalResolution != (int)bm.VerticalResolution)) + // If the image already has the correct dimensions, skip resizing + if (image.Width == width && image.Height == height) { - // Correct a strange glitch that has been observed in the test program when converting - // from a PNG file image created by CopyImageToByteArray() - the dpi value "drifts" - // slightly away from the nominal integer value - bm.SetResolution((int)(bm.HorizontalResolution + 0.5f), - (int)(bm.VerticalResolution + 0.5f)); + return newImage; } - return bm; + // Resize using bilinear interpolation + newImage.InterpolativeResize(width, height, interpolateMethod); + return newImage; } - // This should perform a high quality resize. - // Grabbed from https://stackoverflow.com/questions/1922040/how-to-resize-an-image-c-sharp - public static Bitmap ResizeImage(Image image, int width, int height) + /// + /// Reads collision mask data from the given file path, and required width/height. + /// + /// Image file to read the mask data from (usually a black-and-white PNG). + /// The width that the collision mask must be (e.g., sprite width or bbox width, depending on version). + /// The height that the collision mask must be (e.g., sprite height or bbox height, depending on version). + /// A byte array, encoding the collision mask as a 1-bit-per-pixel image. + /// If the loaded image dimensions do not match the required width/height + public static byte[] ReadMaskData(string filePath, int requiredWidth, int requiredHeight) { - var destRect = new Rectangle(0, 0, width, height); - var destImage = new Bitmap(width, height); - - destImage.SetResolution(image.HorizontalResolution, image.VerticalResolution); - - using (var graphics = Graphics.FromImage(destImage)) + List bytes; + using (MagickImage image = ReadBGRAImageFromFile(filePath)) { - graphics.CompositingMode = CompositingMode.SourceCopy; - graphics.CompositingQuality = CompositingQuality.HighQuality; - graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; - graphics.SmoothingMode = SmoothingMode.HighQuality; - graphics.PixelOffsetMode = PixelOffsetMode.HighQuality; - - using (var wrapMode = new ImageAttributes()) + // Verify width/height match required width/height + if (image.Width != requiredWidth || image.Height != requiredHeight) { - wrapMode.SetWrapMode(WrapMode.TileFlipXY); - graphics.DrawImage(image, destRect, 0, 0, image.Width, image.Height, GraphicsUnit.Pixel, wrapMode); + throw new Exception($"{filePath} is not the proper size to be imported! The proper dimensions are width: {requiredWidth} px, height: {requiredHeight} px."); } - } - return destImage; - } + // Get image pixels, and allocate enough capacity for mask + IPixelCollection pixels = image.GetPixels(); + bytes = new((requiredWidth + 7) / 8 * requiredHeight); - public static byte[] ReadMaskData(string filePath) - { - Bitmap image = ReadImageFromFile(filePath); - List bytes = new List(); + // Get white color, used to represent bits that are set + IMagickColor white = MagickColors.White; - int enableColor = Color.White.ToArgb(); - for (int y = 0; y < image.Height; y++) - { - for (int xByte = 0; xByte < (image.Width + 7) / 8; xByte++) + // Read all pixels of image, and set a bit on the mask if a given pixel matches the white color + for (int y = 0; y < image.Height; y++) { - byte fullByte = 0x00; - int pxStart = (xByte * 8); - int pxEnd = Math.Min(pxStart + 8, (int) image.Width); - - for (int x = pxStart; x < pxEnd; x++) - if (image.GetPixel(x, y).ToArgb() == enableColor) // Don't use Color == OtherColor, it doesn't seem to give us the type of equals comparison we want here. - fullByte |= (byte)(0b1 << (7 - (x - pxStart))); - - bytes.Add(fullByte); + for (int xByte = 0; xByte < (image.Width + 7) / 8; xByte++) + { + byte fullByte = 0x00; + int pxStart = (xByte * 8); + int pxEnd = Math.Min(pxStart + 8, image.Width); + + for (int x = pxStart; x < pxEnd; x++) + { + if (pixels.GetPixel(x, y).ToColor().Equals(white)) + { + fullByte |= (byte)(0b1 << (7 - (x - pxStart))); + } + } + + bytes.Add(fullByte); + } } } - image.Dispose(); return bytes.ToArray(); } - public static byte[] ReadTextureBlob(string filePath) + /// + /// Generates and returns a black-and-white image representing a given sprite's collision mask, + /// and with the given width/height. + /// + /// Mask entry to generate the image from. + /// Width of the image to generate (and to interpret the collision mask with). + /// Height of the image to generate (and to interpret the collision mask with). + /// A new black-and-white image representing the specified collision mask. + public static IMagickImage GetCollisionMaskImage(UndertaleSprite.MaskEntry mask, int maskWidth, int maskHeight) { - Image.FromFile(filePath).Dispose(); // Make sure the file is valid image. - return File.ReadAllBytes(filePath); - } + // Create image to draw on + MagickImage image = new(MagickColor.FromRgba(0, 0, 0, 255), maskWidth, maskHeight); + IPixelCollection pixels = image.GetPixels(); - public static void SaveEmptyPNG(string FullPath, int width, int height) - { - var blackImage = new Bitmap(width, height); - for (int x = 0; x < width; x++) - for (int y = 0; y < height; y++) - blackImage.SetPixel(x, y, Color.Black); - SaveImageToFile(FullPath, blackImage); - } + // Get black/white colors to use for drawing + ReadOnlySpan black = MagickColors.Black.ToByteArray().AsSpan(); + ReadOnlySpan white = MagickColors.White.ToByteArray().AsSpan(); - public static Bitmap GetCollisionMaskImage(UndertaleSprite sprite, UndertaleSprite.MaskEntry mask) - { + // Draw white pixels if a given bit is set; black pixels otherwise byte[] maskData = mask.Data; - Bitmap bitmap = new Bitmap((int)sprite.Width, (int)sprite.Height, PixelFormat.Format32bppArgb); // Ugh. I want to use 1bpp, but for some BS reason C# doesn't allow SetPixel in that mode. - - for (int y = 0; y < sprite.Height; y++) + for (int y = 0; y < maskHeight; y++) { - int rowStart = y * (int)((sprite.Width + 7) / 8); - for (int x = 0; x < sprite.Width; x++) + int rowStart = y * ((maskWidth + 7) / 8); + for (int x = 0; x < maskWidth; x++) { byte temp = maskData[rowStart + (x / 8)]; bool pixelBit = (temp & (0b1 << (7 - (x % 8)))) != 0b0; - bitmap.SetPixel(x, y, pixelBit ? Color.White : Color.Black); + pixels.SetPixel(x, y, pixelBit ? white : black); } } - return bitmap; + return image; } - public static void ExportCollisionMaskPNG(UndertaleSprite sprite, UndertaleSprite.MaskEntry mask, string fullPath) + /// + /// Exports a collision mask entry from a given sprite's collision mask, as a PNG file, at the specified path, and with the given width/height. + /// + /// Mask entry to export the image from. + /// File path to export to. + /// Width of the image to export (and to interpret the collision mask with). + /// Height of the image to export (and to interpret the collision mask with). + public static void ExportCollisionMaskPNG(UndertaleSprite.MaskEntry mask, string filePath, int maskWidth, int maskHeight) { - SaveImageToFile(fullPath, GetCollisionMaskImage(sprite, mask)); + using var image = GetCollisionMaskImage(mask, maskWidth, maskHeight); + SaveImageToFile(image, filePath); } - public static byte[] GetImageBytes(Image image, bool disposeImage = true) + /// + /// Saves the provided image as a PNG file, at the specified path. + /// + /// Image to save. + /// File path to save the image to. + public static void SaveImageToFile(IMagickImage image, string filePath) { - using (var ms = new MemoryStream()) + using var stream = new FileStream(filePath, FileMode.Create); + image.Write(stream, MagickFormat.Png32); + } + + /// + /// Returns the width and height of the image stored at the given path, or -1 width/height if the file fails to parse as an image. + /// + /// File path to get the image size from. + /// Width and height of the image stored at the file path, or -1 for both values if invalid. + public static (int width, int height) GetImageSizeFromFile(string filePath) + { + try + { + MagickImageInfo info = new(filePath); + return (info.Width, info.Height); + } + catch (Exception) { - image.Save(ms, image.RawFormat); - byte[] result = ms.ToArray(); - if (disposeImage) - image.Dispose(); - return result; + return (-1, -1); } } - public static void SaveImageToFile(string FullPath, Image image, Boolean disposeImage = true) + /// + public void Dispose() { - var stream = new FileStream(FullPath, FileMode.Create); - image.Save(stream, ImageFormat.Png); - stream.Close(); - if (disposeImage) - image.Dispose(); + if (embeddedDictionary is not null) + { + foreach (MagickImage img in embeddedDictionary.Values) + { + img.Dispose(); + } + embeddedDictionary.Clear(); + embeddedDictionary = null; + } + + GC.SuppressFinalize(this); } } } diff --git a/UndertaleModLibTests/UndertaleModLibTests.csproj b/UndertaleModLibTests/UndertaleModLibTests.csproj index 505955bd2..40faddd00 100644 --- a/UndertaleModLibTests/UndertaleModLibTests.csproj +++ b/UndertaleModLibTests/UndertaleModLibTests.csproj @@ -1,7 +1,7 @@ - net6.0 + net8.0 enable enable @@ -9,8 +9,8 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/UndertaleModTests/UndertaleModTests.csproj b/UndertaleModTests/UndertaleModTests.csproj index 9ed0d547f..3c3f886ac 100644 --- a/UndertaleModTests/UndertaleModTests.csproj +++ b/UndertaleModTests/UndertaleModTests.csproj @@ -1,6 +1,6 @@  - net6.0 + net8.0 Library false AnyCPU;x64 @@ -11,12 +11,12 @@ - 4.10.0 + 4.11.0 - 3.5.0 + 3.6.3 - - + + - \ No newline at end of file + diff --git a/UndertaleModTool/Controls/UndertaleObjectReference.xaml b/UndertaleModTool/Controls/UndertaleObjectReference.xaml index 43db49af2..1b5a72305 100644 --- a/UndertaleModTool/Controls/UndertaleObjectReference.xaml +++ b/UndertaleModTool/Controls/UndertaleObjectReference.xaml @@ -22,13 +22,15 @@