using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.InteropServices; using static System.Buffers.Binary.BinaryPrimitives; namespace PKHeX.Core; /// /// Generation 5 C-Gear Background Image /// public sealed class CGearBackground { public const string Extension = "cgb"; public const string Filter = $"C-Gear Background|*.{Extension}"; public const int Width = 256; // px public const int Height = 192; // px private const int ColorCount = 0x10; private const int TileSize = 8; private const int TileCount = (Width / TileSize) * (Height / TileSize); // 0x300 internal const int CountTilePool = 0xFF; private const int LengthTilePool = CountTilePool * Tile.SIZE_TILE; // 0x1FE0 private const int LengthColorData = ColorCount * sizeof(ushort); // 0x20 private const int OffsetTileMap = LengthTilePool + LengthColorData; // 0x2000 private const int LengthTileMap = TileCount * sizeof(ushort); // 0x600 public const int SIZE_CGB = OffsetTileMap + LengthTileMap; // 0x2600 /* CGearBackground Documentation * CGearBackgrounds (.cgb) are tiled images. * Tiles are 8x8, and serve as a tileset for building the image. * The first 0x2000 bytes are the tile building region. * A tile to have two pixels defined in one byte of space. * A tile takes up 64 pixels, 32 bytes, 0x20 chunks. * The last tile is actually the colors used in the image (16bit). * Only 16 colors can be used for the entire image. * 255 tiles may be chosen from, as (0x2000-(0x20))/0x20 = 0xFF * The last 0x600 bytes are the tiles used. * 256/8 = 32, 192/8 = 24 * 32 * 24 = 0x300 * The tiles are chosen based on the 16bit index of the tile. * 0x300 * 2 = 0x600! * * CGearBackgrounds tilemap (when stored on B/W) employs some obfuscation. * B/W obfuscates by adding 0xA0A0. * The obfuscated number is then tweaked by adding 15*(i/17) * To reverse, use a similar reverse calculation * PSK files are basically raw game rips (obfuscated) * CGB files are un-obfuscated / B2/W2. * Due to B/W and B2/W2 using different obfuscation adds, PSK files are incompatible between the versions. */ public readonly int[] ColorPalette; public readonly Tile[] Tiles; public readonly TileMap Map; public CGearBackground(ReadOnlySpan data) { if (data.Length != SIZE_CGB) throw new ArgumentOutOfRangeException(nameof(data)); var dataTiles = data[..LengthTilePool]; var dataColors = data.Slice(LengthTilePool, LengthColorData); var dataArrange = data.Slice(OffsetTileMap, LengthTileMap); Tiles = ReadTiles(dataTiles); ColorPalette = ReadColorPalette(dataColors); Map = new TileMap(dataArrange); foreach (var tile in Tiles) tile.SetTile(ColorPalette); } private CGearBackground(int[] palette, Tile[] tilelist, TileMap tm) { Map = tm; ColorPalette = palette; Tiles = tilelist; } /// /// Writes the data to a binary form. /// /// Destination buffer to write the skin to /// True if the destination game is , otherwise false for . /// Serialized skin data for writing to the save file public void Write(Span data, bool cgb) { var dataTiles = data[..LengthTilePool]; var dataColors = data.Slice(LengthTilePool, LengthColorData); var dataArrange = data.Slice(OffsetTileMap, LengthTileMap); WriteTiles(dataTiles, Tiles); WriteColorPalette(dataColors, ColorPalette); Map.Write(dataArrange, cgb); } private static Tile[] ReadTiles(ReadOnlySpan data) { var result = new Tile[data.Length / Tile.SIZE_TILE]; for (int i = 0; i < result.Length; i++) { var span = data.Slice(i * Tile.SIZE_TILE, Tile.SIZE_TILE); result[i] = new Tile(span); } return result; } private static void WriteTiles(Span data, ReadOnlySpan tiles) { for (int i = 0; i < tiles.Length; i++) { var tile = tiles[i]; var span = data.Slice(i * Tile.SIZE_TILE, Tile.SIZE_TILE); tile.Write(span); } } private static int[] ReadColorPalette(ReadOnlySpan data) { var colors = new int[data.Length / 2]; // u16->rgb32 ReadColorPalette(data, colors); return colors; } private static void ReadColorPalette(ReadOnlySpan data, Span colors) { var buffer = MemoryMarshal.Cast(data)[..colors.Length]; for (int i = 0; i < colors.Length; i++) { var value = buffer[i]; if (!BitConverter.IsLittleEndian) value = ReverseEndianness(value); colors[i] = Color15Bit.GetColorExpand(value); } } private static void WriteColorPalette(Span data, ReadOnlySpan colors) { var buffer = MemoryMarshal.Cast(data)[..colors.Length]; for (int i = 0; i < colors.Length; i++) { var value = Color15Bit.GetColorCompress(colors[i]); if (!BitConverter.IsLittleEndian) value = ReverseEndianness(value); buffer[i] = value; } } /// /// Creates a new C-Gear Background object from an input image data byte array, with 32 bits per pixel. /// /// Image data /// new C-Gear Background object public static CGearBackground GetBackground(ReadOnlySpan data) { const int bpp = 4; const int expectLength = Width * Height * bpp; ArgumentOutOfRangeException.ThrowIfNotEqual(data.Length, expectLength); var colors = GetColorData(data); var palette = colors.Distinct().ToArray(); if (palette.Length > ColorCount) throw new ArgumentException($"Too many unique colors. Expected <= 16, got {palette.Length}"); var tiles = GetTiles(colors, palette); GetTileList(tiles, out List tilelist, out TileMap tm); if (tilelist.Count >= CountTilePool) throw new ArgumentException($"Too many unique tiles. Expected < 256, received {tilelist.Count}."); // Finished! return new CGearBackground(palette, [.. tilelist], tm); } private static int[] GetColorData(ReadOnlySpan data) { var pixels = MemoryMarshal.Cast(data); int[] colors = new int[pixels.Length]; for (int i = 0; i < pixels.Length; i++) { var pixel = pixels[i]; if (!BitConverter.IsLittleEndian) pixel = ReverseEndianness(pixel); colors[i] = Color15Bit.GetColorAsOpaque(pixel); } return colors; } private static Tile[] GetTiles(ReadOnlySpan colors, ReadOnlySpan palette) { var tiles = new Tile[TileCount]; for (int i = 0; i < tiles.Length; i++) tiles[i] = GetTile(colors, palette, i); return tiles; } private static Tile GetTile(ReadOnlySpan colors, ReadOnlySpan palette, int tileIndex) { int x = (tileIndex * 8) % Width; int y = 8 * ((tileIndex * 8) / Width); var t = new Tile(); var choices = t.ColorChoices; for (uint ix = 0; ix < 8; ix++) { for (uint iy = 0; iy < 8; iy++) { int index = ((int) (y + iy) * Width) + (int) (x + ix); var c = colors[index]; choices[(ix % 8) + (iy * 8)] = (byte)palette.IndexOf(c); } } t.SetTile(palette); return t; } private static void GetTileList(ReadOnlySpan tiles, out List tilelist, out TileMap tm) { tilelist = [tiles[0]]; tm = new TileMap(LengthTileMap); // start at 1 as the 0th tile is always non-duplicate for (int i = 1; i < tm.TileChoices.Length; i++) FindPossibleRotatedTile(tiles[i], tilelist, tm, i); } private static void FindPossibleRotatedTile(Tile t, List tilelist, TileMap tm, int tileIndex) { // Test all tiles currently in the list for (int i = 0; i < tilelist.Count; i++) { var rotVal = t.GetRotationValue(tilelist[i].ColorChoices); if (rotVal == Tile.ROTATION_BAD) continue; tm.TileChoices[tileIndex] = (byte)i; tm.Rotations[tileIndex] = rotVal; return; } // No tile found, add to list tilelist.Add(t); tm.TileChoices[tileIndex] = (byte)(tilelist.Count - 1); tm.Rotations[tileIndex] = 0; } public byte[] GetImageData() { byte[] data = new byte[4 * Width * Height]; WriteImageData(data); return data; } private void WriteImageData(Span data) { var tiles = Map.TileChoices; var rotations = Map.Rotations; for (int i = 0; i < tiles.Length; i++) { var choice = tiles[i]; var rotation = rotations[i]; var tile = Tiles[choice]; var tileData = tile.Rotate(rotation); int x = (i * TileSize) % Width; int y = TileSize * ((i * TileSize) / Width); for (int row = 0; row < TileSize; row++) { const int pixelLineSize = TileSize * sizeof(int); int ofsSrc = row * pixelLineSize; int ofsDest = (((y + row) * Width) + x) * sizeof(int); var line = tileData.Slice(ofsSrc, pixelLineSize); line.CopyTo(data[ofsDest..]); } } } } /// /// Generation 5 image tile composed of 8x8 pixels. /// /// Each pixel's color choice is a nibble (4 bits). public sealed class Tile { internal const int SIZE_TILE = 0x20; private const int TileWidth = 8; private const int TileHeight = 8; internal readonly byte[] ColorChoices = new byte[TileWidth * TileHeight]; // Keep track of known rotations for this tile. // If the tile's rotated value has not yet been calculated, the field is null. private byte[] PixelData = []; private byte[]? PixelDataX; private byte[]? PixelDataY; private const byte FlagFlipX = 0b0100; // 0x4 private const byte FlagFlipY = 0b1000; // 0x8 private const byte FlagFlipXY = FlagFlipX | FlagFlipY; // 0xC private const byte FlagFlipNone = 0b0000; // 0x0 internal const byte ROTATION_BAD = byte.MaxValue; internal Tile() { } internal Tile(ReadOnlySpan data) : this() { ArgumentOutOfRangeException.ThrowIfNotEqual(data.Length, SIZE_TILE); // Unpack the nibbles into the color choice array. for (int i = 0; i < data.Length; i++) { var value = data[i]; var ofs = i * 2; ColorChoices[ofs + 0] = (byte)(value & 0xF); ColorChoices[ofs + 1] = (byte)(value >> 4); } } internal void SetTile(ReadOnlySpan palette) => PixelData = GetTileData(palette, ColorChoices); private static byte[] GetTileData(ReadOnlySpan Palette, ReadOnlySpan choices) { byte[] data = new byte[choices.Length * 4]; SetTileData(data, Palette, choices); return data; } private static void SetTileData(Span result, ReadOnlySpan palette, ReadOnlySpan colorChoices) { for (int i = 0; i < colorChoices.Length; i++) { var choice = colorChoices[i]; var value = palette[choice]; var span = result.Slice(4 * i, 4); WriteInt32LittleEndian(span, value); } } public void Write(Span data) => Write(data, ColorChoices); private static void Write(Span data, ReadOnlySpan colorChoices) { for (int i = 0; i < data.Length; i++) { var span = colorChoices.Slice(i * 2, 2); var second = span[1] & 0xF; var first = span[0] & 0xF; data[i] = (byte)(first | (second << 4)); } } public ReadOnlySpan Rotate(int rotFlip) { if (rotFlip == 0) return PixelData; if ((rotFlip & FlagFlipXY) == FlagFlipXY) return FlipY(PixelDataX ??= FlipX(PixelData, TileWidth), TileHeight); if ((rotFlip & FlagFlipX) == FlagFlipX) return PixelDataX ??= FlipX(PixelData, TileWidth); if ((rotFlip & FlagFlipY) == FlagFlipY) return PixelDataY ??= FlipY(PixelData, TileHeight); return PixelData; } private static byte[] FlipX(ReadOnlySpan data, [ConstantExpected(Min = 0)] int width, [ConstantExpected(Min = 4, Max = 4)] int bpp = 4) { byte[] result = new byte[data.Length]; FlipX(data, result, width, bpp); return result; } private static byte[] FlipY(ReadOnlySpan data, [ConstantExpected(Min = 0)] int height, [ConstantExpected(Min = 4, Max = 4)] int bpp = 4) { byte[] result = new byte[data.Length]; FlipY(data, result, height, bpp); return result; } private static void FlipX(ReadOnlySpan data, Span result, [ConstantExpected(Min = 0)] int width, [ConstantExpected(Min = 4, Max = 4)] int bpp) { int pixels = data.Length / bpp; var resultInt = MemoryMarshal.Cast(result); var dataInt = MemoryMarshal.Cast(data); for (int i = 0; i < pixels; i++) { int x = i % width; int y = i / width; x = width - x - 1; // flip x int dest = ((y * width) + x); resultInt[dest] = dataInt[i]; } } private static void FlipY(ReadOnlySpan data, Span result, [ConstantExpected(Min = 0)] int height, [ConstantExpected(Min = 4, Max = 4)] int bpp) { int pixels = data.Length / bpp; int width = pixels / height; var resultInt = MemoryMarshal.Cast(result); var dataInt = MemoryMarshal.Cast(data); for (int i = 0; i < pixels; i++) { int x = i % width; int y = i / width; y = height - y - 1; // flip y int dest = ((y * width) + x); resultInt[dest] = dataInt[i]; } } internal byte GetRotationValue(ReadOnlySpan tileColors) { // Check all rotation types if (tileColors.SequenceEqual(ColorChoices)) return FlagFlipNone; if (IsMirrorX(tileColors)) return FlagFlipX; if (IsMirrorY(tileColors)) return FlagFlipY; if (IsMirrorXY(tileColors)) return FlagFlipXY; return ROTATION_BAD; } private bool IsMirrorX(ReadOnlySpan tileColors) { const int pixels = TileWidth * TileHeight; for (int i = 0; i < pixels; i++) { var index = (7 - (i & 7)) + (8 * (i / 8)); if (ColorChoices[index] != tileColors[i]) return false; } return true; } private bool IsMirrorY(ReadOnlySpan tileColors) { const int pixels = TileWidth * TileHeight; for (int i = 0; i < pixels; i++) { var index = pixels - (8 * (1 + (i / 8))) + (i & 7); if (ColorChoices[index] != tileColors[i]) return false; } return true; } private bool IsMirrorXY(ReadOnlySpan tileColors) { const int pixels = TileWidth * TileHeight; for (int i = 0; i < pixels; i++) { var index = pixels - 1 - i; if (ColorChoices[index] != tileColors[i]) return false; } return true; } } public sealed class TileMap(int length) { public readonly byte[] TileChoices = new byte[length / 2]; public readonly byte[] Rotations = new byte[length / 2]; internal TileMap(ReadOnlySpan data) : this(data.Length) => LoadData(data, TileChoices, Rotations); private static void LoadData(ReadOnlySpan data, Span tiles, Span rotations) { bool isCGB = IsCGB(data); if (!isCGB) LoadDataPSK(data, tiles, rotations); else LoadDataCGB(data, tiles, rotations); } private static void LoadDataCGB(ReadOnlySpan data, Span tiles, Span rotations) { for (int i = 0; i < data.Length; i += 2) { var span = data.Slice(i, 2); var tile = span[0]; var rot = span[1]; tiles[i / 2] = tile; rotations[i / 2] = rot; } } private static void LoadDataPSK(ReadOnlySpan data, Span tiles, Span rotations) { for (int i = 0; i < data.Length; i += 2) { var span = data.Slice(i, 2); var value = ReadUInt16LittleEndian(span); var (tile, rot) = DecomposeValuePSK(value); tiles[i / 2] = tile; rotations[i / 2] = rot; } } private static bool IsCGB(ReadOnlySpan data) { // check odd bytes for anything not rotation flag for (int i = 0; i < data.Length; i += 2) { if ((data[i + 1] & ~0b1100) != 0) return false; } return true; } public void Write(Span data, bool cgb) => Write(data, TileChoices, Rotations, cgb); private static void Write(Span data, ReadOnlySpan choices, ReadOnlySpan rotations, bool cgb) { if (choices.Length != rotations.Length) throw new ArgumentException($"length of {nameof(TileChoices)} and {nameof(Rotations)} must be equal"); if (data.Length != choices.Length + rotations.Length) throw new ArgumentException($"data length must be twice the length of the {nameof(TileMap)}"); if (!cgb) WriteDataPSK(data, choices, rotations); else WriteDataCGB(data, choices, rotations); } private static void WriteDataCGB(Span data, ReadOnlySpan choices, ReadOnlySpan rotations) { for (int i = 0; i < data.Length; i += 2) { var span = data.Slice(i, 2); span[0] = choices[i / 2]; span[1] = rotations[i / 2]; } } private static void WriteDataPSK(Span data, ReadOnlySpan choices, ReadOnlySpan rotations) { for (int i = 0; i < data.Length; i += 2) { var span = data.Slice(i, 2); var tile = choices[i / 2]; var rot = rotations[i / 2]; int val = GetPSKValue(tile, rot); WriteUInt16LittleEndian(span, (ushort)val); } } public static (byte Tile, byte Rotation) DecomposeValuePSK(ushort val) { ushort value = UnmapPSKValue(val); byte tile = (byte)value; byte rot = (byte)(value >> 8); if (tile == CGearBackground.CountTilePool) // out of range? tile = 0; return (tile, rot); } private static ushort UnmapPSKValue(ushort val) { var rot = val & 0xFC00; var trunc = (val & 0x3FF); if (trunc is < 0xA0 or > 0x280) return (ushort)(rot | CGearBackground.CountTilePool); // default empty return (ushort)(rot | ((val & 0x1F) + (17 * ((trunc - 0xA0) >> 5)))); } public static ushort GetPSKValue(byte tile, byte rot) { if (tile == CGearBackground.CountTilePool) // out of range? tile = 0; var result = ((rot & 0x0C) << 8) | ((15 * (tile / 17)) + tile + 0xA0) | 0xA000; return (ushort)result; } }