From ec52a3dfd54477503886ff7a27671692cf201880 Mon Sep 17 00:00:00 2001 From: addallno Date: Fri, 5 Jun 2026 16:44:15 +0800 Subject: [PATCH 1/5] fix: use UTF-8 byte count in WriteStringI32Size The previous code wrote input.Length (character count) instead of Encoding.UTF8.GetBytes(input).Length (byte count). This caused path strings containing non-ASCII characters (e.g., Chinese) to be written with incorrect length prefix, corrupting the PKG header. --- RePKG.Application/Extensions.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/RePKG.Application/Extensions.cs b/RePKG.Application/Extensions.cs index 5a8924f..d96a53f 100644 --- a/RePKG.Application/Extensions.cs +++ b/RePKG.Application/Extensions.cs @@ -53,8 +53,9 @@ public static void WriteStringI32Size(this BinaryWriter writer, string input) if (writer == null) throw new ArgumentNullException(nameof(writer)); if (input == null) throw new ArgumentNullException(nameof(input)); - writer.Write(input.Length); - writer.Write(Encoding.UTF8.GetBytes(input)); + var bytes = Encoding.UTF8.GetBytes(input); + writer.Write(bytes.Length); + writer.Write(bytes); } } } \ No newline at end of file From d6557cc77cf4ccc35f131a38ae7e2a6662e02611 Mon Sep 17 00:00:00 2001 From: addallno Date: Fri, 5 Jun 2026 16:44:25 +0800 Subject: [PATCH 2/5] build: add net8.0 target framework - Add net8.0 alongside net472 for cross-platform support - Conditional Microsoft.CSharp reference for net472 only --- RePKG/RePKG.csproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/RePKG/RePKG.csproj b/RePKG/RePKG.csproj index 892f7ac..4d4c27d 100644 --- a/RePKG/RePKG.csproj +++ b/RePKG/RePKG.csproj @@ -2,7 +2,7 @@ RePKG Exe - net472 + net472;net8.0 0.4.0 Copyright © NotScuffed 2025 @@ -16,11 +16,11 @@ - - + + - \ No newline at end of file + From 0e4d650b9be77eb8d678a565664e3eee33a75a1b Mon Sep 17 00:00:00 2001 From: addallno Date: Fri, 5 Jun 2026 16:45:02 +0800 Subject: [PATCH 3/5] fix: improve TexToImageConverter crop/resize logic When source mipmap dimensions differ from header dimensions, resize instead of only cropping. Also handle the case where source is smaller than header dimensions. --- RePKG.Application/Texture/TexToImageConverter.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/RePKG.Application/Texture/TexToImageConverter.cs b/RePKG.Application/Texture/TexToImageConverter.cs index 2839e25..f38bfd4 100644 --- a/RePKG.Application/Texture/TexToImageConverter.cs +++ b/RePKG.Application/Texture/TexToImageConverter.cs @@ -53,9 +53,12 @@ public ImageResult ConvertToImage(ITex tex) { var image = ImageFromRawFormat(format, sourceMipmap.Bytes, sourceMipmap.Width, sourceMipmap.Height); - if (sourceMipmap.Width != tex.Header.ImageWidth || - sourceMipmap.Height != tex.Header.ImageHeight) + if (sourceMipmap.Width > tex.Header.ImageWidth || + sourceMipmap.Height > tex.Header.ImageHeight) image.Mutate(x => x.Crop(tex.Header.ImageWidth, tex.Header.ImageHeight)); + else if (sourceMipmap.Width < tex.Header.ImageWidth || + sourceMipmap.Height < tex.Header.ImageHeight) + image.Mutate(x => x.Resize(tex.Header.ImageWidth, tex.Header.ImageHeight)); using (var memoryStream = new MemoryStream()) { From df5889d149cf297f7a796931ba0475159549dac3 Mon Sep 17 00:00:00 2001 From: addallno Date: Fri, 5 Jun 2026 16:45:15 +0800 Subject: [PATCH 4/5] fix: support .mpkg extension in info and extract commands - Info.cs: recognize .mpkg files when processing single file input - Info.cs: merge .pkg and .mpkg directory enumeration - Extract.cs: recognize .mpkg files in single file mode - Extract.cs: merge recursive and non-recursive .pkg/.mpkg enumeration --- RePKG/Command/Extract.cs | 9 ++++++--- RePKG/Command/Info.cs | 6 ++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/RePKG/Command/Extract.cs b/RePKG/Command/Extract.cs index 8e4efce..e5e26f1 100644 --- a/RePKG/Command/Extract.cs +++ b/RePKG/Command/Extract.cs @@ -130,7 +130,8 @@ private static void ExtractPkgDirectory(DirectoryInfo directoryInfo) if (_options.Recursive) { - foreach (var file in directoryInfo.EnumerateFiles("*.pkg", SearchOption.AllDirectories)) + foreach (var file in directoryInfo.EnumerateFiles("*.pkg", SearchOption.AllDirectories) + .Concat(directoryInfo.EnumerateFiles("*.mpkg", SearchOption.AllDirectories))) { if (file.Directory == null || file.Directory.FullName.Length < rootDirectoryLength) ExtractPkg(file); @@ -143,7 +144,8 @@ private static void ExtractPkgDirectory(DirectoryInfo directoryInfo) foreach (var directory in directoryInfo.EnumerateDirectories()) { - foreach (var file in directory.EnumerateFiles("*.pkg")) + foreach (var file in directory.EnumerateFiles("*.pkg") + .Concat(directory.EnumerateFiles("*.mpkg"))) { ExtractPkg(file, true, directory.FullName.Substring(rootDirectoryLength)); } @@ -154,7 +156,8 @@ private static void ExtractFile(FileInfo fileInfo) { Directory.CreateDirectory(_options.OutputDirectory); - if (fileInfo.Extension.Equals(".pkg", StringComparison.OrdinalIgnoreCase)) + if (fileInfo.Extension.Equals(".pkg", StringComparison.OrdinalIgnoreCase) || + fileInfo.Extension.Equals(".mpkg", StringComparison.OrdinalIgnoreCase)) ExtractPkg(fileInfo); else if (fileInfo.Extension.Equals(".tex", StringComparison.OrdinalIgnoreCase)) { diff --git a/RePKG/Command/Info.cs b/RePKG/Command/Info.cs index 2a105a2..fc8ec59 100644 --- a/RePKG/Command/Info.cs +++ b/RePKG/Command/Info.cs @@ -62,7 +62,8 @@ private static void InfoPkgDirectory(DirectoryInfo directoryInfo) foreach (var directory in directoryInfo.EnumerateDirectories()) { - foreach (var file in directory.EnumerateFiles("*.pkg")) + foreach (var file in directory.EnumerateFiles("*.pkg") + .Concat(directory.EnumerateFiles("*.mpkg"))) { InfoPkg(file, file.FullName.Substring(rootDirectoryLength)); } @@ -75,7 +76,8 @@ private static void InfoTexDirectory(DirectoryInfo directoryInfo) private static void InfoFile(FileInfo file) { - if (file.Extension.Equals(".pkg", StringComparison.OrdinalIgnoreCase)) + if (file.Extension.Equals(".pkg", StringComparison.OrdinalIgnoreCase) || + file.Extension.Equals(".mpkg", StringComparison.OrdinalIgnoreCase)) InfoPkg(file, Path.GetFullPath(file.Name)); else if (file.Extension.Equals(".tex", StringComparison.OrdinalIgnoreCase)) InfoTex(file); From 49214a87adbd82396defcb0fe79458d79c31f673 Mon Sep 17 00:00:00 2001 From: addallno Date: Fri, 5 Jun 2026 16:45:44 +0800 Subject: [PATCH 5/5] feat: add pack command and video TEX support Pack command (repkg pack): - Directory mode: pack directory to .pkg or .mpkg - File mode: convert image/gif/video to .tex texture - Auto-detect magic: .pkg -> PKGV0005, .mpkg -> PKGM0019 - Options: --format, --lz4, --no-gif, --video-width/height ImageToTexConverter: - Convert PNG, JPEG, GIF to TEX textures (RGBA8888/R8/RG88) - Convert GIF animations to multi-frame TEX with frame info - Convert MP4/WebM/MOV/AVI to video TEX (MP4 wrapped in TEX container) V4 texture container support: - TexImageContainerWriter: write V4 header (Format + isVideoMp4 flag) - TexImageWriter: add WriteMipmapV4 with extra metadata fields - For non-MP4 V4 containers, fall back to V3 mipmap format (matching official Wallpaper Engine TEX structure) - ITexImageWriter interface updated to pass FreeImageFormat Program.cs: register PackOptions verb for CLI dispatch --- .../Texture/Helpers/ImageToTexConverter.cs | 340 ++++++++++++++++++ .../Texture/Writer/TexImageContainerWriter.cs | 7 +- .../Texture/Writer/TexImageWriter.cs | 30 +- .../Interfaces/Writer/ITexImageWriter.cs | 2 +- RePKG/Command/Pack.cs | 183 ++++++++++ RePKG/Program.cs | 10 +- 6 files changed, 563 insertions(+), 9 deletions(-) create mode 100644 RePKG.Application/Texture/Helpers/ImageToTexConverter.cs create mode 100644 RePKG/Command/Pack.cs diff --git a/RePKG.Application/Texture/Helpers/ImageToTexConverter.cs b/RePKG.Application/Texture/Helpers/ImageToTexConverter.cs new file mode 100644 index 0000000..9acf4f2 --- /dev/null +++ b/RePKG.Application/Texture/Helpers/ImageToTexConverter.cs @@ -0,0 +1,340 @@ +using System; +using System.IO; +using RePKG.Core.Texture; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace RePKG.Application.Texture.Helpers +{ + public static class ImageToTexConverter + { + public static Tex Convert(string imagePath, TexFormat format = TexFormat.RGBA8888, bool lz4 = false) + { + using (var image = Image.Load(imagePath)) + { + return Convert(image, format, lz4); + } + } + + public static Tex Convert(Image image, TexFormat format = TexFormat.RGBA8888, bool lz4 = false) + { + var texWidth = NextPowerOfTwo(image.Width); + var texHeight = NextPowerOfTwo(image.Height); + + if (image.Width != texWidth || image.Height != texHeight) + image.Mutate(x => x.Resize(texWidth, texHeight)); + + var pixels = new Rgba32[texWidth * texHeight]; + image.CopyPixelDataTo(pixels); + + byte[] pixelData; + var mipmapFormat = GetPixelData(pixels, texWidth, texHeight, format, out pixelData); + + var mipmap = new TexMipmap + { + Width = texWidth, + Height = texHeight, + Bytes = pixelData, + Format = mipmapFormat, + IsLZ4Compressed = false, + DecompressedBytesCount = pixelData.Length + }; + + var texImage = new TexImage(); + texImage.Mipmaps.Add(mipmap); + + var imageContainer = new TexImageContainer + { + Magic = "TEXB0002", + ImageContainerVersion = TexImageContainerVersion.Version2, + ImageFormat = FreeImageFormat.FIF_UNKNOWN + }; + imageContainer.Images.Add(texImage); + + var header = new TexHeader + { + Format = format, + Flags = TexFlags.None, + TextureWidth = texWidth, + TextureHeight = texHeight, + ImageWidth = image.Width, + ImageHeight = image.Height, + UnkInt0 = 0 + }; + + var tex = new Tex + { + Magic1 = "TEXV0005", + Magic2 = "TEXI0001", + Header = header, + ImagesContainer = imageContainer + }; + + if (lz4) + { + var compressor = new TexMipmapCompressor(); + compressor.CompressMipmap(mipmap, mipmapFormat, true); + } + + return tex; + } + + public static Tex ConvertFromGif(string gifPath, bool lz4 = false) + { + using (var gif = Image.Load(gifPath)) + { + var frames = gif.Frames.Count; + if (frames == 0) + return Convert(gif, TexFormat.RGBA8888, lz4); + + var texWidth = NextPowerOfTwo(gif.Width); + var texHeight = NextPowerOfTwo(gif.Height); + + var header = new TexHeader + { + Format = TexFormat.RGBA8888, + Flags = TexFlags.IsGif, + TextureWidth = texWidth, + TextureHeight = texHeight, + ImageWidth = gif.Width, + ImageHeight = gif.Height, + UnkInt0 = 0 + }; + + var imageContainer = new TexImageContainer + { + Magic = "TEXB0003", + ImageContainerVersion = TexImageContainerVersion.Version3, + ImageFormat = FreeImageFormat.FIF_GIF + }; + + var frameInfos = new TexFrameInfoContainer + { + Magic = "TEXS0003", + GifWidth = gif.Width, + GifHeight = gif.Height + }; + + for (int i = 0; i < frames; i++) + { + using (var frame = gif.Frames.CloneFrame(i)) + { + if (frame.Width != texWidth || frame.Height != texHeight) + frame.Mutate(x => x.Resize(texWidth, texHeight)); + + var pixels = new Rgba32[texWidth * texHeight]; + frame.CopyPixelDataTo(pixels); + var pixelData = PixelDataFromRgba32(pixels, texWidth, texHeight); + + var mipmap = new TexMipmap + { + Width = texWidth, + Height = texHeight, + Bytes = pixelData, + Format = MipmapFormat.RGBA8888, + IsLZ4Compressed = false, + DecompressedBytesCount = pixelData.Length + }; + + if (lz4) + { + var compressor = new TexMipmapCompressor(); + compressor.CompressMipmap(mipmap, MipmapFormat.RGBA8888, true); + } + + var texImage = new TexImage(); + texImage.Mipmaps.Add(mipmap); + imageContainer.Images.Add(texImage); + + var delay = gif.Frames[i].Metadata.GetFormatMetadata(SixLabors.ImageSharp.Formats.Gif.GifFormat.Instance).FrameDelay; + frameInfos.Frames.Add(new TexFrameInfo + { + ImageId = i, + Frametime = delay / 100f, + X = 0, + Y = 0, + Width = gif.Width, + Height = gif.Height, + WidthY = gif.Width, + HeightX = gif.Height + }); + } + } + + var tex = new Tex + { + Magic1 = "TEXV0005", + Magic2 = "TEXI0001", + Header = header, + ImagesContainer = imageContainer, + FrameInfoContainer = frameInfos + }; + + return tex; + } + } + + private static MipmapFormat GetPixelData(Rgba32[] pixels, int width, int height, TexFormat format, out byte[] pixelData) + { + switch (format) + { + case TexFormat.RGBA8888: + pixelData = PixelDataFromRgba32(pixels, width, height); + return MipmapFormat.RGBA8888; + + case TexFormat.R8: + pixelData = new byte[width * height]; + for (int i = 0; i < width * height; i++) + pixelData[i] = (byte)((pixels[i].R * 299 + pixels[i].G * 587 + pixels[i].B * 114) / 1000); + return MipmapFormat.R8; + + case TexFormat.RG88: + pixelData = new byte[width * height * 2]; + for (int i = 0; i < width * height; i++) + { + pixelData[i * 2] = pixels[i].R; + pixelData[i * 2 + 1] = pixels[i].G; + } + return MipmapFormat.RG88; + + default: + throw new NotSupportedException($"TexFormat {format} not supported"); + } + } + + private static byte[] PixelDataFromRgba32(Rgba32[] pixels, int width, int height) + { + var data = new byte[width * height * 4]; + for (int i = 0; i < width * height; i++) + { + data[i * 4] = pixels[i].R; + data[i * 4 + 1] = pixels[i].G; + data[i * 4 + 2] = pixels[i].B; + data[i * 4 + 3] = pixels[i].A; + } + return data; + } + + public static Tex ConvertFromVideo(string videoPath, int width = 0, int height = 0, bool lz4 = false) + { + var videoBytes = File.ReadAllBytes(videoPath); + + if (width <= 0 || height <= 0) + ProbeVideoDimensions(videoPath, ref width, ref height); + + if (width <= 0) width = 1920; + if (height <= 0) height = 1080; + + var texWidth = NextPowerOfTwo(width); + var texHeight = NextPowerOfTwo(height); + + var mipmap = new TexMipmap + { + Width = texWidth, + Height = texHeight, + Bytes = videoBytes, + Format = MipmapFormat.VideoMp4, + IsLZ4Compressed = false, + DecompressedBytesCount = videoBytes.Length + }; + + var texImage = new TexImage(); + texImage.Mipmaps.Add(mipmap); + + var imageContainer = new TexImageContainer + { + Magic = "TEXB0004", + ImageContainerVersion = TexImageContainerVersion.Version4, + ImageFormat = FreeImageFormat.FIF_UNKNOWN + }; + imageContainer.Images.Add(texImage); + + var header = new TexHeader + { + Format = TexFormat.RGBA8888, + Flags = TexFlags.IsVideoTexture, + TextureWidth = texWidth, + TextureHeight = texHeight, + ImageWidth = width, + ImageHeight = height, + UnkInt0 = 0 + }; + + var tex = new Tex + { + Magic1 = "TEXV0005", + Magic2 = "TEXI0001", + Header = header, + ImagesContainer = imageContainer + }; + + return tex; + } + + private static void ProbeVideoDimensions(string videoPath, ref int width, ref int height) + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo("ffprobe") + { + Arguments = $"-v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 \"{videoPath}\"", + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using (var proc = System.Diagnostics.Process.Start(psi)) + { + if (proc != null) + { + var output = proc.StandardOutput.ReadToEnd().Trim(); + proc.WaitForExit(3000); + if (proc.ExitCode == 0 && !string.IsNullOrEmpty(output)) + { + var parts = output.Split(','); + if (parts.Length == 2) + { + int.TryParse(parts[0], out width); + int.TryParse(parts[1], out height); + } + } + } + } + } + catch + { + // ffprobe not available, user-provided or default dimensions used + } + } + + public static bool IsVideoFile(string path) + { + var ext = Path.GetExtension(path)?.ToLowerInvariant(); + switch (ext) + { + case ".mp4": + case ".webm": + case ".avi": + case ".mov": + case ".mkv": + case ".flv": + case ".wmv": + return true; + default: + return false; + } + } + + private static int NextPowerOfTwo(int n) + { + if (n <= 0) return 1; + n--; + n |= n >> 1; + n |= n >> 2; + n |= n >> 4; + n |= n >> 8; + n |= n >> 16; + return n + 1; + } + } +} diff --git a/RePKG.Application/Texture/Writer/TexImageContainerWriter.cs b/RePKG.Application/Texture/Writer/TexImageContainerWriter.cs index b422296..20b799c 100644 --- a/RePKG.Application/Texture/Writer/TexImageContainerWriter.cs +++ b/RePKG.Application/Texture/Writer/TexImageContainerWriter.cs @@ -32,13 +32,18 @@ public void WriteTo(BinaryWriter writer, ITexImageContainer imageContainer) writer.Write((int) imageContainer.ImageFormat); break; + case TexImageContainerVersion.Version4: + writer.Write((int) imageContainer.ImageFormat); + writer.Write(imageContainer.ImageFormat == FreeImageFormat.FIF_MP4 ? 1 : 0); + break; + default: throw new UnknownMagicException(nameof(TexImageContainerWriter), imageContainer.Magic); } foreach (var image in imageContainer.Images) { - _texImageWriter.WriteTo(writer, imageContainer.ImageContainerVersion, image); + _texImageWriter.WriteTo(writer, imageContainer.ImageContainerVersion, imageContainer.ImageFormat, image); } } } diff --git a/RePKG.Application/Texture/Writer/TexImageWriter.cs b/RePKG.Application/Texture/Writer/TexImageWriter.cs index 954452f..f5e4663 100644 --- a/RePKG.Application/Texture/Writer/TexImageWriter.cs +++ b/RePKG.Application/Texture/Writer/TexImageWriter.cs @@ -6,12 +6,12 @@ namespace RePKG.Application.Texture { public class TexImageWriter : ITexImageWriter { - public void WriteTo(BinaryWriter writer, TexImageContainerVersion containerVersion, ITexImage image) + public void WriteTo(BinaryWriter writer, TexImageContainerVersion containerVersion, FreeImageFormat format, ITexImage image) { if (writer == null) throw new ArgumentNullException(nameof(writer)); if (image == null) throw new ArgumentNullException(nameof(image)); - var mipmapWriter = PickMipmapWriter(containerVersion); + var mipmapWriter = PickMipmapWriter(containerVersion, format); writer.Write(image.Mipmaps.Count); @@ -53,7 +53,26 @@ private static void WriteMipmapV2And3(BinaryWriter writer, ITexMipmap mipmap) } } - private static Action PickMipmapWriter(TexImageContainerVersion containerVersion) + private static void WriteMipmapV4(BinaryWriter writer, ITexMipmap mipmap) + { + writer.Write(1); // param1 + writer.Write(2); // param2 + writer.WriteNString(""); // conditionJson (empty) + writer.Write(1); // param3 + writer.Write(mipmap.Width); + writer.Write(mipmap.Height); + writer.Write(mipmap.IsLZ4Compressed ? 1 : 0); + writer.Write(mipmap.DecompressedBytesCount); + + using (var stream = mipmap.GetBytesStream()) + { + writer.Write((int) stream.Length); + writer.Flush(); + stream.CopyTo(writer.BaseStream); + } + } + + private static Action PickMipmapWriter(TexImageContainerVersion containerVersion, FreeImageFormat format) { switch (containerVersion) { @@ -64,6 +83,11 @@ private static Action PickMipmapWriter(TexImageContain case TexImageContainerVersion.Version3: return WriteMipmapV2And3; + case TexImageContainerVersion.Version4: + if (format == FreeImageFormat.FIF_MP4) + return WriteMipmapV4; + return WriteMipmapV2And3; + default: throw new ArgumentOutOfRangeException(nameof(containerVersion)); } diff --git a/RePKG.Core/Texture/Interfaces/Writer/ITexImageWriter.cs b/RePKG.Core/Texture/Interfaces/Writer/ITexImageWriter.cs index 7b6a4f8..e55e250 100644 --- a/RePKG.Core/Texture/Interfaces/Writer/ITexImageWriter.cs +++ b/RePKG.Core/Texture/Interfaces/Writer/ITexImageWriter.cs @@ -4,6 +4,6 @@ namespace RePKG.Core.Texture { public interface ITexImageWriter { - void WriteTo(BinaryWriter writer, TexImageContainerVersion containerVersion, ITexImage image); + void WriteTo(BinaryWriter writer, TexImageContainerVersion containerVersion, FreeImageFormat format, ITexImage image); } } \ No newline at end of file diff --git a/RePKG/Command/Pack.cs b/RePKG/Command/Pack.cs new file mode 100644 index 0000000..6930f7d --- /dev/null +++ b/RePKG/Command/Pack.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.IO; +using CommandLine; +using RePKG.Application.Package; +using RePKG.Application.Texture; +using RePKG.Application.Texture.Helpers; +using RePKG.Core.Package; +using RePKG.Core.Package.Interfaces; +using RePKG.Core.Texture; + +namespace RePKG.Command +{ + public static class Pack + { + private static readonly IPackageWriter _packageWriter; + + static Pack() + { + _packageWriter = new PackageWriter(); + } + + public static void Action(PackOptions options) + { + var fileInfo = new FileInfo(options.Input); + var dirInfo = new DirectoryInfo(options.Input); + + if (fileInfo.Exists) + { + PackTexFile(options, fileInfo); + } + else if (dirInfo.Exists) + { + PackDirectory(options, dirInfo); + } + else + { + Console.WriteLine("Input not found"); + Console.WriteLine(options.Input); + } + } + + private static void PackTexFile(PackOptions options, FileInfo fileInfo) + { + var outputPath = options.Output; + if (string.IsNullOrEmpty(outputPath)) + outputPath = Path.ChangeExtension(fileInfo.FullName, ".tex"); + + var format = TexFormat.RGBA8888; + if (!string.IsNullOrEmpty(options.Format)) + { + switch (options.Format.ToUpperInvariant()) + { + case "RGBA8888": format = TexFormat.RGBA8888; break; + case "R8": format = TexFormat.R8; break; + case "RG88": format = TexFormat.RG88; break; + default: + Console.WriteLine($"Unsupported format: {options.Format}. Supported: RGBA8888, R8, RG88"); + return; + } + } + + var isVideo = ImageToTexConverter.IsVideoFile(fileInfo.FullName); + var isGif = fileInfo.Extension.Equals(".gif", StringComparison.OrdinalIgnoreCase) + && !isVideo && !options.NoGif; + + Console.WriteLine($"Converting {fileInfo.FullName} -> {outputPath}"); + + Tex tex; + if (isVideo) + { + Console.WriteLine("Video mode: MP4 embedded as video texture"); + tex = ImageToTexConverter.ConvertFromVideo( + fileInfo.FullName, options.VideoWidth, options.VideoHeight, options.Lz4); + } + else if (isGif) + { + Console.WriteLine("GIF mode: each frame packed as separate image"); + tex = ImageToTexConverter.ConvertFromGif(fileInfo.FullName, options.Lz4); + } + else + { + tex = ImageToTexConverter.Convert(fileInfo.FullName, format, options.Lz4); + Console.WriteLine($"Format: {format}, LZ4: {options.Lz4}"); + } + + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outputPath))); + + using (var writer = new BinaryWriter(File.Open(outputPath, FileMode.Create, FileAccess.Write))) + { + var texWriter = TexWriter.Default; + texWriter.WriteTo(writer, tex); + } + + Console.WriteLine("Done"); + } + + private static void PackDirectory(PackOptions options, DirectoryInfo inputInfo) + { + var outputPath = options.Output; + if (string.IsNullOrEmpty(outputPath)) + outputPath = Path.Combine(Directory.GetCurrentDirectory(), + options.Mpkg ? "output.mpkg" : "output.pkg"); + + var files = inputInfo.EnumerateFiles("*", SearchOption.AllDirectories); + + // Auto-detect magic: .mpkg -> PKGM0019 (Android), .pkg -> PKGV0005 (desktop) + var magic = options.Magic; + if (string.IsNullOrEmpty(magic)) + { + if (options.Mpkg || (outputPath.EndsWith(".mpkg", StringComparison.OrdinalIgnoreCase))) + magic = "PKGM0019"; + else + magic = "PKGV0005"; + } + + var package = new Package { Magic = magic }; + + var basePath = inputInfo.FullName.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + foreach (var file in files) + { + var relativePath = file.FullName.Substring(basePath.Length + 1); + var bytes = File.ReadAllBytes(file.FullName); + + package.Entries.Add(new PackageEntry + { + FullPath = relativePath, + Bytes = bytes, + Type = PackageEntryTypeGetter.GetFromFileName(relativePath) + }); + } + + if (package.Entries.Count == 0) + { + Console.WriteLine("No files found in input directory"); + return; + } + + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outputPath))); + + using (var writer = new BinaryWriter(File.Open(outputPath, FileMode.Create, FileAccess.Write))) + { + _packageWriter.WriteTo(writer, package); + } + + Console.WriteLine($"Package created: {outputPath}"); + Console.WriteLine($"Entries: {package.Entries.Count}"); + Console.WriteLine($"Magic: {package.Magic}"); + } + } + + [Verb("pack", HelpText = "Pack file to .tex or directory to PKG/MPKG.")] + public class PackOptions + { + [Option('o', "output", Required = false, HelpText = "Output path (.tex for file, .pkg/.mpkg for directory)")] + public string Output { get; set; } + + [Option('m', "magic", Required = false, HelpText = "Magic string for PKG header: PKGV0005 (desktop, default for .pkg) or PKGM0019 (Android, default for .mpkg)")] + public string Magic { get; set; } + + [Option("mpkg", Required = false, HelpText = "Create .mpkg package with PKGM0019 magic (Android Wallpaper Engine)")] + public bool Mpkg { get; set; } + + [Option('f', "format", Required = false, HelpText = "Tex format: RGBA8888, R8, RG88 (file mode only)")] + public string Format { get; set; } + + [Option("lz4", Required = false, HelpText = "Apply LZ4 compression (file mode only)")] + public bool Lz4 { get; set; } + + [Option("no-gif", Required = false, HelpText = "Treat GIF as single frame (file mode only)")] + public bool NoGif { get; set; } + + [Option("video-width", Required = false, HelpText = "Video width in pixels (auto-detected via ffprobe if omitted)")] + public int VideoWidth { get; set; } + + [Option("video-height", Required = false, HelpText = "Video height in pixels (auto-detected via ffprobe if omitted)")] + public int VideoHeight { get; set; } + + [Value(0, Required = true, HelpText = "Input file or directory path", MetaName = "Input")] + public string Input { get; set; } + } +} diff --git a/RePKG/Program.cs b/RePKG/Program.cs index bf7a1a3..6f4b406 100644 --- a/RePKG/Program.cs +++ b/RePKG/Program.cs @@ -18,9 +18,10 @@ private static void Main(string[] args) return; } - Parser.Default.ParseArguments(args) + Parser.Default.ParseArguments(args) .WithParsed(Extract.Action) - .WithParsed(Info.Action); + .WithParsed(Info.Action) + .WithParsed(Pack.Action); } private static void Cancel(object sender, ConsoleCancelEventArgs e) @@ -41,9 +42,10 @@ private static void InteractiveConsole() { var interactiveArgs = line.SplitArguments(); - Parser.Default.ParseArguments(interactiveArgs) + Parser.Default.ParseArguments(interactiveArgs) .WithParsed(Extract.Action) - .WithParsed(Info.Action); + .WithParsed(Info.Action) + .WithParsed(Pack.Action); } } }