Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rewrite texture handling #1870

Merged
merged 16 commits into from
Aug 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions UndertaleModCli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,8 @@
{
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);
}
}

Expand Down Expand Up @@ -700,7 +701,7 @@
if (Verbose)
Console.WriteLine("Replacing " + textureEntry);

texture.TextureData.TextureBlob = File.ReadAllBytes(fileToReplace.FullName);
texture.TextureData.Image = GMImage.FromPng(File.ReadAllBytes(fileToReplace.FullName));
}

/// <summary>
Expand Down Expand Up @@ -730,7 +731,7 @@
/// Evaluates and executes given C# code.
/// </summary>
/// <param name="code">The C# string to execute</param>
/// <param name="scriptFile">The path to the script file where <see cref="code"/> was executed from.

Check warning on line 734 in UndertaleModCli/Program.cs

View workflow job for this annotation

GitHub Actions / publish_cli (ubuntu-latest, Debug, false)

XML comment has cref attribute 'code' that could not be resolved

Check warning on line 734 in UndertaleModCli/Program.cs

View workflow job for this annotation

GitHub Actions / publish_cli (ubuntu-latest, Debug, false)

XML comment has cref attribute 'code' that could not be resolved

Check warning on line 734 in UndertaleModCli/Program.cs

View workflow job for this annotation

GitHub Actions / publish_cli (macOS-latest, Debug, false)

XML comment has cref attribute 'code' that could not be resolved

Check warning on line 734 in UndertaleModCli/Program.cs

View workflow job for this annotation

GitHub Actions / publish_cli (macOS-latest, Debug, false)

XML comment has cref attribute 'code' that could not be resolved

Check warning on line 734 in UndertaleModCli/Program.cs

View workflow job for this annotation

GitHub Actions / publish_cli (windows-latest, Debug, false)

XML comment has cref attribute 'code' that could not be resolved

Check warning on line 734 in UndertaleModCli/Program.cs

View workflow job for this annotation

GitHub Actions / publish_cli (windows-latest, Debug, false)

XML comment has cref attribute 'code' that could not be resolved
/// Leave as null, if it wasn't executed from a script file</param>
private void RunCSharpCode(string code, string scriptFile = null)
{
Expand Down
220 changes: 48 additions & 172 deletions UndertaleModLib/Models/UndertaleEmbeddedTexture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
using System.Buffers.Binary;
using System.ComponentModel;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
Expand Down Expand Up @@ -56,8 +55,7 @@ public TexData TextureData
get => _textureData ??= LoadExternalTexture();
set => _textureData = value;
}
private TexData _textureData = new TexData();

private TexData _textureData = new();

/// <summary>
/// Helper variable for whether or not this texture is to be stored externally or not.
Expand Down Expand Up @@ -230,20 +228,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) };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any specific reason this has to be 1x1 instead of 0x0?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really, but this is just what shows in lieu of an unloaded (or failed to load) texture

private static readonly object _textureLoadLock = new();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

afaik MS recommended to use a System.Threading.Lock for locks. It's only available in c# 13+, cant remember what version utmt is in right now. Probably good idea to a make mental note for this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I don't think it's new enough yet, that's a .NET 9 thing.


/// <summary>
/// Attempts to load the corresponding external texture. Should only happen in 2022.9 and above.
Expand Down Expand Up @@ -273,7 +260,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)
Expand Down Expand Up @@ -311,62 +298,48 @@ public void Dispose()
/// </summary>
public class TexData : UndertaleObject, INotifyPropertyChanged, IDisposable
{
private byte[] _textureBlob;
private static MemoryStream sharedStream;
private GMImage _image;

/// <summary>
/// The image data of the texture.
/// The underlying image of the texture.
/// </summary>
public byte[] TextureBlob
{
get => _textureBlob;
public GMImage Image
{
get => _image;
set
{
_textureBlob = value;
_image = value;
OnPropertyChanged();
}
}

/// <summary>
/// The width of the texture.
/// In case of an invalid texture data, this will be <c>-1</c>.
/// </summary>
public int Width
{
get
{
if (_textureBlob is null || _textureBlob.Length < 24)
return -1;
public int Width => _image.Width;

ReadOnlySpan<byte> span = _textureBlob.AsSpan();
return BinaryPrimitives.ReadInt32BigEndian(span[16..20]);
}
}
/// <summary>
/// The height of the texture.
/// In case of an invalid texture data, this will be <c>-1</c>.
/// </summary>
public int Height
{
get
{
if (_textureBlob is null || _textureBlob.Length < 24)
return -1;

ReadOnlySpan<byte> span = _textureBlob.AsSpan();
return BinaryPrimitives.ReadInt32BigEndian(span[20..24]);
}
}
public int Height => _image.Height;

/// <summary>
/// Whether this texture uses the QOI format.
/// </summary>
public bool FormatQOI { get; set; } = false;
public bool FormatQOI => _image.Format is GMImage.ImageFormat.Qoi or GMImage.ImageFormat.Bz2Qoi;

/// <summary>
/// Whether this texture uses the BZ2 format. (Always used in combination with QOI.)
/// </summary>
public bool FormatBZ2 { get; set; } = false;
public bool FormatBZ2 => _image.Format is GMImage.ImageFormat.Bz2Qoi;

/// <summary>
/// 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).
/// </summary>
/// <remarks>
/// All data between the actual end position and this maximum end position should be 0x00 byte padding.
/// </remarks>
private int _maxEndOfStreamPosition { get; set; } = -1;

/// <inheritdoc />
public event PropertyChangedEventHandler PropertyChanged;
Expand All @@ -379,152 +352,56 @@ protected void OnPropertyChanged([CallerMemberName] string name = null)
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

/// <summary>
/// Header used for PNG files.
/// </summary>
public static readonly byte[] PNGHeader = { 137, 80, 78, 71, 13, 10, 26, 10 };

/// <summary>
/// Header used for GameMaker QOI + BZ2 files.
/// </summary>
public static readonly byte[] QOIAndBZip2Header = { 50, 122, 111, 113 };

/// <summary>
/// Header used for GameMaker QOI files.
/// </summary>
public static readonly byte[] QOIHeader = { 102, 105, 111, 113 };

/// <summary>
/// Frees up <see cref="sharedStream"/> from memory.
/// </summary>
public static void ClearSharedStream()
{
sharedStream?.Dispose();
sharedStream = null;
}

/// <summary>
/// Initializes <see cref="sharedStream"/> with a specified initial size.
/// </summary>
/// <param name="size">Initial size of <see cref="sharedStream"/> in bytes</param>
public static void InitSharedStream(int size) => sharedStream = new(size);

/// <inheritdoc />
public void Serialize(UndertaleWriter writer)
{
Serialize(writer, writer.undertaleData.IsVersionAtLeast(2022, 3), writer.undertaleData.IsVersionAtLeast(2022, 5));
Serialize(writer, writer.undertaleData.IsVersionAtLeast(2022, 5));
}

/// <summary>
/// Serializes the texture to any type of writer (can be any destination file).
/// </summary>
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);
}

/// <inheritdoc />
public void Unserialize(UndertaleReader reader)
{
Unserialize(reader, reader.undertaleData.IsVersionAtLeast(2022, 5));
Unserialize(reader, _maxEndOfStreamPosition, reader.undertaleData.IsVersionAtLeast(2022, 5));
}

/// <summary>
/// Unserializes the texture from any type of reader (can be from any source).
/// </summary>
public void Unserialize(IBinaryReader reader, bool gm2022_5)
/// <param name="reader"><see cref="IBinaryReader"/> to read the texture's image from.</param>
/// <param name="maxEndOfStreamPosition">Upper bound on the end of the texture's image data (e.g., for padding).</param>
/// <param name="gm2022_5">Whether to unserialize the image data using GameMaker 2022.5+ format.</param>
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<byte> 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);
/// <summary>
/// Sets the upper bound on the position of the end of the image stream, for use when loading a full data file.
/// </summary>
/// <remarks>
/// All data between the actual end position and this maximum end position should be padding (zero bytes).
/// </remarks>
public void SetMaxEndOfStreamPosition(int position)
{
_maxEndOfStreamPosition = position;
}


Expand All @@ -533,8 +410,7 @@ public void Dispose()
{
GC.SuppressFinalize(this);

_textureBlob = null;
ClearSharedStream();
_image = null;
}
}
}
31 changes: 12 additions & 19 deletions UndertaleModLib/Models/UndertaleTexturePageItem.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using ImageMagick;
using System;
using System.ComponentModel;
using System.Drawing;
using UndertaleModLib.Util;
Expand Down Expand Up @@ -145,33 +146,25 @@ public void Dispose()
/// Replaces the current image of this texture page item to hold a new image.
/// </summary>
/// <param name="replaceImage">The new image that shall be applied to this texture page item.</param>
/// <param name="disposeImage">Whether to dispose <paramref name="replaceImage"/> afterwards.</param>
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<byte> finalImage = TextureWorker.ResizeImage(replaceImage, SourceWidth, SourceHeight);
Miepee marked this conversation as resolved.
Show resolved Hide resolved

// 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();
}
}
Loading
Loading