diff --git a/.gitignore b/.gitignore index 8b9606c..940794e 100644 --- a/.gitignore +++ b/.gitignore @@ -286,4 +286,3 @@ __pycache__/ *.btm.cs *.odx.cs *.xsd.cs -/Shockky.Sandbox diff --git a/Shockky.Sandbox/Palettes/grey.pal b/Shockky.Sandbox/Palettes/grey.pal new file mode 100644 index 0000000..b1934ff Binary files /dev/null and b/Shockky.Sandbox/Palettes/grey.pal differ diff --git a/Shockky.Sandbox/Palettes/mac.pal b/Shockky.Sandbox/Palettes/mac.pal new file mode 100644 index 0000000..536f806 Binary files /dev/null and b/Shockky.Sandbox/Palettes/mac.pal differ diff --git a/Shockky.Sandbox/Palettes/metallic.pal b/Shockky.Sandbox/Palettes/metallic.pal new file mode 100644 index 0000000..67998f1 Binary files /dev/null and b/Shockky.Sandbox/Palettes/metallic.pal differ diff --git a/Shockky.Sandbox/Palettes/ntsc.pal b/Shockky.Sandbox/Palettes/ntsc.pal new file mode 100644 index 0000000..661fbee Binary files /dev/null and b/Shockky.Sandbox/Palettes/ntsc.pal differ diff --git a/Shockky.Sandbox/Palettes/pastels.pal b/Shockky.Sandbox/Palettes/pastels.pal new file mode 100644 index 0000000..e0a3c4d Binary files /dev/null and b/Shockky.Sandbox/Palettes/pastels.pal differ diff --git a/Shockky.Sandbox/Palettes/rainbow.pal b/Shockky.Sandbox/Palettes/rainbow.pal new file mode 100644 index 0000000..cc576a7 Binary files /dev/null and b/Shockky.Sandbox/Palettes/rainbow.pal differ diff --git a/Shockky.Sandbox/Palettes/vivid.pal b/Shockky.Sandbox/Palettes/vivid.pal new file mode 100644 index 0000000..f0ce35a Binary files /dev/null and b/Shockky.Sandbox/Palettes/vivid.pal differ diff --git a/Shockky.Sandbox/Palettes/web216.pal b/Shockky.Sandbox/Palettes/web216.pal new file mode 100644 index 0000000..c334cc3 Binary files /dev/null and b/Shockky.Sandbox/Palettes/web216.pal differ diff --git a/Shockky.Sandbox/Palettes/win.pal b/Shockky.Sandbox/Palettes/win.pal new file mode 100644 index 0000000..edea65a Binary files /dev/null and b/Shockky.Sandbox/Palettes/win.pal differ diff --git a/Shockky.Sandbox/Palettes/windir4.pal b/Shockky.Sandbox/Palettes/windir4.pal new file mode 100644 index 0000000..edea65a Binary files /dev/null and b/Shockky.Sandbox/Palettes/windir4.pal differ diff --git a/Shockky.Sandbox/Program.cs b/Shockky.Sandbox/Program.cs new file mode 100644 index 0000000..de15595 --- /dev/null +++ b/Shockky.Sandbox/Program.cs @@ -0,0 +1,290 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Drawing; +using System.Diagnostics; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; + +using Shockky.Chunks; +using Shockky.Chunks.Cast; + +using System.CommandLine; +using System.CommandLine.Invocation; + +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace Shockky.Sandbox +{ + class Program + { + static int Main(string[] args) + { + Console.Title = "Shockky.Sandbox"; + Console.WriteLine("Shockky.Sandbox"); + Console.WriteLine(); + + //TODO: Verbose and Quiet levels, and rest of the resources of course + var rootCommand = new RootCommand() + { + new Argument>("input") + { + Arity = ArgumentArity.OneOrMore, + Description = "Director movie (.dir, .dxt, .dcr) or external cast (.cst, .cxt, .cct) file(s)." + }.ExistingOnly(), + + new Option("--images", + getDefaultValue: () => true, + description: "Extract all (with bit depth 8) bitmaps from the file."), + + new Option("--output", + getDefaultValue: () => new DirectoryInfo("Output/"), + description: "Directory for the extracted resources").LegalFilePathsOnly() + }; + rootCommand.Handler = CommandHandler.Create, bool, DirectoryInfo>(HandleExtractCommand); + + return rootCommand.Invoke(args); + } + + private static IReadOnlyDictionary ReadPalettes() + { + static System.Drawing.Color[] ReadPalette(string fileName) + { + using var fs = File.OpenRead(fileName); + using var input = new BinaryReader(fs, Encoding.ASCII); + + input.ReadChars(4); + input.ReadInt32(); + + input.ReadChars(4); + + input.ReadChars(4); + input.ReadInt32(); + input.ReadInt16(); + + System.Drawing.Color[] colors = new System.Drawing.Color[input.ReadInt16()]; + for (int i = 0; i < colors.Length; i++) + { + byte r = input.ReadByte(); + byte g = input.ReadByte(); + byte b = input.ReadByte(); + + colors[i] = System.Drawing.Color.FromArgb(r, g, b); + + input.ReadByte(); + } + return colors; + } + + return new Dictionary + { + { -1, ReadPalette("Palettes/mac.pal") }, + { -2, ReadPalette("Palettes/rainbow.pal") }, + { -3, ReadPalette("Palettes/grey.pal") }, + { -4, ReadPalette("Palettes/pastels.pal") }, + { -5, ReadPalette("Palettes/vivid.pal") }, + { -6, ReadPalette("Palettes/ntsc.pal") }, + { -7, ReadPalette("Palettes/metallic.pal") }, + { -8, ReadPalette("Palettes/web216.pal") }, + { -9, null }, //TODO: "Palettes/VGA.pal" + { -101, ReadPalette("Palettes/windir4.pal") }, + { -102, ReadPalette("Palettes/win.pal") } + }; + } + + private static int HandleExtractCommand(IEnumerable input, bool images, DirectoryInfo output) + { + if (!images) + return 0; + + output.Create(); + + //Load the built-in system palettes + IReadOnlyDictionary systemPalettes = ReadPalettes(); + + foreach (var file in input) + { + //TODO: Seperate different resource types. + DirectoryInfo fileOutputDirectory = output.CreateSubdirectory(file.Name); + + Console.Write($"Disassembling file \"{file.Name}\".."); + + using var shockwaveFile = new ShockwaveFile(file.FullName); + shockwaveFile.Disassemble(); + + Console.WriteLine("Done!"); + + List<(CastMemberPropertiesChunk Member, ChunkItem Media)> memberMedia = new List<(CastMemberPropertiesChunk, ChunkItem)>(); + + var associationTable = shockwaveFile.Chunks + .FirstOrDefault(c => c.Kind == ChunkKind.KEYPointer) as AssociationTableChunk; + + var castAssociationTable = shockwaveFile.Chunks + .FirstOrDefault(c => c.Kind == ChunkKind.CASPointer) as CastAssociationTableChunk; + + if (associationTable == null) + { + Console.WriteLine($"Chunk \"{nameof(AssociationTableChunk)}\" was not found!"); + continue; + } + + if (castAssociationTable == null) + { + Console.WriteLine($"Chunk \"{nameof(CastAssociationTableChunk)}\" was not found!"); + continue; + } + Console.Write("Extracting resources.."); + + //TODO: Report progress, try some of those System.CommandLine goodies? + + //Build a list of the cast member-media pairs. + foreach (int memberId in castAssociationTable.Members) + { + if (memberId == 0) continue; + + var castMemberChunk = shockwaveFile[memberId] as CastMemberPropertiesChunk; + + var mediaEntries = associationTable.CastEntries.Where(e => e.OwnerId == memberId); + + foreach (var mediaEntry in mediaEntries) + { + //TODO: filter mediaEntry.Kind here to extract only wanted resources + + memberMedia.Add((castMemberChunk, shockwaveFile[mediaEntry.Id])); + } + } + + //TODO: DIB and others.. + foreach (var (member, media) in memberMedia.Where(entry => entry.Media?.Kind == ChunkKind.BITD)) + { + var bitmapChunk = media as BitmapChunk; + var bitmapProperties = member?.Properties as BitmapCastProperties; + + if (bitmapProperties == null) continue; + + string outputFileName = CoerceValidFileName(member.Common?.Name) + ?? $"NONAME-{member.Header.Id}-{media.Header.Id}"; + + int paletteIndex = bitmapProperties.Palette - 1; //castMemRef + + if (paletteIndex >= 0 && paletteIndex < castAssociationTable.Members.Length) + { + //TODO: Research why these safety checks still fail for some files.. CastMemRef's seems to point to non palette members? + + int paletteMemberChunkId = castAssociationTable.Members[paletteIndex]; + + if (shockwaveFile[paletteMemberChunkId] is CastMemberPropertiesChunk paletteMember) + { + if (memberMedia.FirstOrDefault(entry => entry.Member == paletteMember).Media is PaletteChunk paletteChunk) + { + bitmapChunk.PopulateMedia(bitmapProperties); + if (TryExtractBitmapResource(fileOutputDirectory, outputFileName, bitmapChunk, paletteChunk.Colors)) + { + Console.Write('.'); + continue; + } + } + } + } + else if (systemPalettes.TryGetValue(paletteIndex, out System.Drawing.Color[] palette)) + { + bitmapChunk.PopulateMedia(bitmapProperties); + if (TryExtractBitmapResource(fileOutputDirectory, outputFileName, bitmapChunk, palette)) + { + Console.Write('.'); + continue; + } + } + Console.Write('x'); + } + Console.WriteLine(" Done!"); + } + + return 0; + } + + //TODO: Look more into ImageSharp, could offer some helpful tools to do this + private static bool TryExtractBitmapResource(DirectoryInfo outputDirectory, string name, BitmapChunk bitmap, System.Drawing.Color[] palette) + { + //TODO: Properly render flags etc. TrimWhitespace for example uses flood fill apparently so that could be fun. + + Span buffer = bitmap.Data.AsSpan(); + + int width = bitmap.Width < bitmap.TotalWidth ? bitmap.Width : bitmap.TotalWidth; //TODO: This is wrong way + + using var image = new Image(bitmap.Width, bitmap.Height); + for (int y = 0; y < bitmap.Height; y++) + { + Span row = buffer.Slice(y * bitmap.TotalWidth, bitmap.TotalWidth); + + if (bitmap.BitDepth == 32) //TODO: Can't get this right yet, probably wrong PixelFormat + { + return false; + + //Span pixels = MemoryMarshal.Cast(row); + // + //for (int x = 0; x < bitmap.Width; x++) + //{ + // image[x, y] = pixels[x]; + //} + } + else if (bitmap.BitDepth == 4) + { + return false; + + //for (int x = 0; x < width; x++) + //{ + // System.Drawing.Color pixelColor = palette[row[x] >> 4]; + // System.Drawing.Color secondPixelColor = palette[row[x] & 0xF]; + // + // //image[x, y] = new Bgra32(pixelColor.R, pixelColor.G, pixelColor.B); + //} + } + else + { + for (int x = 0; x < width; x++) + { + System.Drawing.Color pixelColor = palette[row[x]]; + image[x, y] = new Bgra32(pixelColor.R, pixelColor.G, pixelColor.B); + } + } + } + + using var fs = File.Create(Path.GetFullPath(name + ".png", outputDirectory.FullName)); + image.SaveAsPng(fs); + + return true; + } + + /// + /// Strip illegal chars and reserved words from a candidate filename (should not include the directory path) + /// + /// + /// http://stackoverflow.com/questions/309485/c-sharp-sanitize-file-name + /// + public static string CoerceValidFileName(string filename) + { + var invalidChars = Regex.Escape(new string(Path.GetInvalidFileNameChars())); + var invalidReStr = string.Format(@"[{0}]+", invalidChars); + + var reservedWords = new[] + { + "CON", "PRN", "AUX", "CLOCK$", "NUL", "COM0", "COM1", "COM2", "COM3", "COM4", + "COM5", "COM6", "COM7", "COM8", "COM9", "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", + "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + }; + + var sanitisedNamePart = Regex.Replace(filename, invalidReStr, "_"); + foreach (var reservedWord in reservedWords) + { + var reservedWordPattern = string.Format("^{0}\\.", reservedWord); + sanitisedNamePart = Regex.Replace(sanitisedNamePart, reservedWordPattern, "_reservedWord_.", RegexOptions.IgnoreCase); + } + + return sanitisedNamePart; + } + } +} diff --git a/Shockky.Sandbox/Shockky.Sandbox.csproj b/Shockky.Sandbox/Shockky.Sandbox.csproj new file mode 100644 index 0000000..07c692f --- /dev/null +++ b/Shockky.Sandbox/Shockky.Sandbox.csproj @@ -0,0 +1,21 @@ + + + + Exe + netcoreapp3.1 + + + + + + + + + + + + + + + + diff --git a/Shockky.sln b/Shockky.sln index 4a44c2d..595299d 100644 --- a/Shockky.sln +++ b/Shockky.sln @@ -1,10 +1,12 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.27130.2027 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29311.281 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shockky", "Shockky\Shockky.csproj", "{0F807D73-1838-4227-B6D3-932D83E99D9C}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shockky.Sandbox", "Shockky.Sandbox\Shockky.Sandbox.csproj", "{0BD542DD-87A2-4BAD-8528-87D209C48BE5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,10 @@ Global {0F807D73-1838-4227-B6D3-932D83E99D9C}.Debug|Any CPU.Build.0 = Debug|Any CPU {0F807D73-1838-4227-B6D3-932D83E99D9C}.Release|Any CPU.ActiveCfg = Release|Any CPU {0F807D73-1838-4227-B6D3-932D83E99D9C}.Release|Any CPU.Build.0 = Release|Any CPU + {0BD542DD-87A2-4BAD-8528-87D209C48BE5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0BD542DD-87A2-4BAD-8528-87D209C48BE5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0BD542DD-87A2-4BAD-8528-87D209C48BE5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0BD542DD-87A2-4BAD-8528-87D209C48BE5}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE