Created
March 2, 2026 14:47
-
-
Save a1678991/58a6fcf74e25f4e718042899a7ef8b23 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Unity Texture2D format inspector. | |
| Parses Unity serialized asset files (.asset) and extracts Texture2D metadata | |
| including the GPU texture format, dimensions, and mip levels. | |
| Usage: | |
| python3 unity_texture_format.py <file.asset> [file2.asset ...] | |
| python3 unity_texture_format.py *.asset | |
| """ | |
| import struct | |
| import math | |
| import sys | |
| import os | |
| TEXTURE_FORMATS = { | |
| 1: "Alpha8", | |
| 2: "ARGB4444", | |
| 3: "RGB24", | |
| 4: "RGBA32", | |
| 5: "ARGB32", | |
| 7: "RGB565", | |
| 9: "R16", | |
| 10: "DXT1 (BC1)", | |
| 12: "DXT5 (BC3)", | |
| 13: "RGBA4444", | |
| 14: "BGRA32", | |
| 15: "RHalf", | |
| 16: "RGHalf", | |
| 17: "RGBAHalf", | |
| 18: "RFloat", | |
| 19: "RGFloat", | |
| 20: "RGBAFloat", | |
| 22: "YUY2", | |
| 24: "BC4", | |
| 25: "BC5", | |
| 26: "BC6H", | |
| 27: "BC7", | |
| 29: "ETC_RGB4", | |
| 30: "ATC_RGB4", | |
| 31: "ATC_RGBA8", | |
| 34: "ETC2_RGB4", | |
| 45: "ETC2_RGBA8", | |
| 46: "ASTC_RGB_4x4 (legacy)", | |
| 47: "ASTC_RGB_5x5 (legacy)", | |
| 48: "ASTC_4x4", | |
| 49: "ASTC_5x5", | |
| 50: "ASTC_6x6", | |
| 51: "ASTC_8x8", | |
| 52: "ASTC_10x10", | |
| 53: "ASTC_12x12", | |
| 54: "ASTC_HDR_4x4", | |
| 55: "ASTC_HDR_5x5", | |
| 56: "ASTC_HDR_6x6", | |
| 57: "ASTC_HDR_8x8", | |
| 58: "ASTC_HDR_10x10", | |
| 59: "ASTC_HDR_12x12", | |
| 62: "R8", | |
| 63: "R8_UINT", | |
| } | |
| # block_w, block_h, block_bytes | |
| BLOCK_INFO = { | |
| 10: (4, 4, 8), # DXT1 | |
| 12: (4, 4, 16), # DXT5 | |
| 24: (4, 4, 8), # BC4 | |
| 25: (4, 4, 16), # BC5 | |
| 26: (4, 4, 16), # BC6H | |
| 27: (4, 4, 16), # BC7 | |
| 29: (4, 4, 8), # ETC_RGB4 | |
| 34: (4, 4, 8), # ETC2_RGB4 | |
| 45: (4, 4, 16), # ETC2_RGBA8 | |
| 46: (4, 4, 16), # ASTC_RGB_4x4 | |
| 47: (5, 5, 16), # ASTC_RGB_5x5 | |
| 48: (4, 4, 16), # ASTC_4x4 | |
| 49: (5, 5, 16), # ASTC_5x5 | |
| 50: (6, 6, 16), # ASTC_6x6 | |
| 51: (8, 8, 16), # ASTC_8x8 | |
| 52: (10, 10, 16), # ASTC_10x10 | |
| 53: (12, 12, 16), # ASTC_12x12 | |
| 54: (4, 4, 16), # ASTC_HDR_4x4 | |
| 55: (5, 5, 16), # ASTC_HDR_5x5 | |
| 56: (6, 6, 16), # ASTC_HDR_6x6 | |
| 57: (8, 8, 16), # ASTC_HDR_8x8 | |
| 58: (10, 10, 16), # ASTC_HDR_10x10 | |
| 59: (12, 12, 16), # ASTC_HDR_12x12 | |
| } | |
| # bytes per pixel for uncompressed formats | |
| BPP_INFO = { | |
| 1: 1, # Alpha8 | |
| 2: 2, # ARGB4444 | |
| 3: 3, # RGB24 | |
| 4: 4, # RGBA32 | |
| 5: 4, # ARGB32 | |
| 7: 2, # RGB565 | |
| 9: 2, # R16 | |
| 13: 2, # RGBA4444 | |
| 14: 4, # BGRA32 | |
| 15: 2, # RHalf | |
| 16: 4, # RGHalf | |
| 17: 8, # RGBAHalf | |
| 18: 4, # RFloat | |
| 19: 8, # RGFloat | |
| 20: 16, # RGBAFloat | |
| 62: 1, # R8 | |
| 63: 1, # R8_UINT | |
| } | |
| PLATFORM_NAMES = { | |
| -2: "NoTarget (Editor)", | |
| -1: "Any", | |
| 0: "Unknown", | |
| 2: "StandaloneOSX", | |
| 4: "StandaloneWindows (x86)", | |
| 5: "iOS", | |
| 7: "Android", | |
| 9: "StandaloneWindows64", | |
| 13: "WebGL", | |
| 19: "StandaloneWindows64", | |
| 20: "StandaloneLinux64", | |
| 21: "PS4", | |
| 24: "Switch", | |
| 25: "XboxOne", | |
| 37: "tvOS", | |
| 38: "PS5", | |
| } | |
| PLATFORM_EXPECTED_FORMATS = { | |
| "desktop": {"DXT1 (BC1)", "DXT5 (BC3)", "BC4", "BC5", "BC6H", "BC7", | |
| "RGB24", "RGBA32", "ARGB32", "RGBAHalf", "RGBAFloat"}, | |
| "mobile": {"ASTC_4x4", "ASTC_5x5", "ASTC_6x6", "ASTC_8x8", | |
| "ASTC_10x10", "ASTC_12x12", "ETC_RGB4", "ETC2_RGB4", | |
| "ETC2_RGBA8"}, | |
| } | |
| def calc_expected_size(width, height, mip_count, fmt): | |
| """Calculate expected image data size for a given format with mipmaps.""" | |
| total = 0 | |
| if fmt in BLOCK_INFO: | |
| bw, bh, bb = BLOCK_INFO[fmt] | |
| for i in range(mip_count): | |
| w = max(1, width >> i) | |
| h = max(1, height >> i) | |
| total += math.ceil(w / bw) * math.ceil(h / bh) * bb | |
| elif fmt in BPP_INFO: | |
| bpp = BPP_INFO[fmt] | |
| for i in range(mip_count): | |
| w = max(1, width >> i) | |
| h = max(1, height >> i) | |
| total += w * h * bpp | |
| return total | |
| def classify_format(format_name): | |
| """Return 'desktop', 'mobile', or 'universal'.""" | |
| for category, names in PLATFORM_EXPECTED_FORMATS.items(): | |
| if format_name in names: | |
| return category | |
| return "universal" | |
| def parse_texture(filepath): | |
| """Parse a Unity serialized file and extract Texture2D info.""" | |
| with open(filepath, "rb") as f: | |
| data = f.read() | |
| if len(data) < 0x30: | |
| return None, "File too small to be a Unity serialized asset" | |
| # Parse header (always big-endian) | |
| fmt_version = struct.unpack(">I", data[8:12])[0] | |
| if fmt_version < 17 or fmt_version > 30: | |
| return None, f"Not a recognized Unity serialized format (version={fmt_version})" | |
| # Format version 22+ uses extended 64-bit header | |
| if fmt_version >= 22: | |
| metadata_size = struct.unpack(">I", data[0x14:0x18])[0] | |
| file_size = struct.unpack(">Q", data[0x18:0x20])[0] | |
| data_offset = struct.unpack(">Q", data[0x20:0x28])[0] | |
| header_end = 0x30 | |
| else: | |
| metadata_size = struct.unpack(">I", data[0:4])[0] | |
| file_size = struct.unpack(">I", data[4:8])[0] | |
| data_offset = struct.unpack(">I", data[12:16])[0] | |
| header_end = 0x14 if fmt_version >= 9 else 0x10 | |
| # Unity version string (null-terminated after header) | |
| ver_end = data.index(b"\x00", header_end) | |
| unity_version = data[header_end:ver_end].decode("ascii", errors="replace") | |
| # Platform ID (u32 LE, right after version string null terminator) | |
| plat_off = ver_end + 1 | |
| platform_raw = struct.unpack("<I", data[plat_off:plat_off + 4])[0] | |
| platform_id = struct.unpack("<i", data[plat_off:plat_off + 4])[0] | |
| # Search for Texture2D name field pattern in the data section | |
| # The type tree strings tell us the field order | |
| # Look for string fields that could be texture names | |
| results = [] | |
| search_start = int(data_offset) if data_offset else header_end | |
| # Find all potential texture name strings by searching for the | |
| # length-prefixed string pattern followed by known field sequence | |
| pos = search_start | |
| while pos < len(data) - 64: | |
| # Look for a plausible name length (1-255) followed by printable ASCII | |
| name_len = struct.unpack("<I", data[pos:pos + 4])[0] | |
| if name_len < 1 or name_len > 512: | |
| pos += 4 | |
| continue | |
| if pos + 4 + name_len > len(data): | |
| pos += 4 | |
| continue | |
| name_bytes = data[pos + 4:pos + 4 + name_len] | |
| # Check if it looks like a valid texture name | |
| if not all(0x20 <= b < 0x7F for b in name_bytes): | |
| pos += 4 | |
| continue | |
| name = name_bytes.decode("ascii") | |
| # After name: align to 4, then 16 bytes hash, then fields | |
| name_data_end = pos + 4 + name_len | |
| aligned = (name_data_end + 3) & ~3 | |
| off = aligned | |
| if off + 16 + 4 + 2 + 2 + 4 + 4 + 4 + 4 + 4 + 4 > len(data): | |
| pos += 4 | |
| continue | |
| # m_ImageContentsHash (16 bytes) - skip | |
| off += 16 | |
| # m_ForcedFallbackFormat (u32) | |
| forced_fallback = struct.unpack("<I", data[off:off + 4])[0] | |
| off += 4 | |
| # m_DownscaleFallback (bool) + m_IsAlphaChannelOptional (bool) | |
| downscale = data[off] | |
| off += 1 | |
| alpha_optional = data[off] | |
| off += 1 | |
| # Align to 4 | |
| off = (off + 3) & ~3 | |
| # m_Width, m_Height (i32 each) | |
| width = struct.unpack("<i", data[off:off + 4])[0] | |
| off += 4 | |
| height = struct.unpack("<i", data[off:off + 4])[0] | |
| off += 4 | |
| # Validate dimensions | |
| if width <= 0 or height <= 0 or width > 16384 or height > 16384: | |
| pos += 4 | |
| continue | |
| # m_CompleteImageSize (u32) | |
| image_size = struct.unpack("<I", data[off:off + 4])[0] | |
| off += 4 | |
| # m_MipsStripped (i32) | |
| mips_stripped = struct.unpack("<i", data[off:off + 4])[0] | |
| off += 4 | |
| # m_TextureFormat (i32) | |
| tex_format = struct.unpack("<i", data[off:off + 4])[0] | |
| off += 4 | |
| # m_MipCount (i32) | |
| mip_count = struct.unpack("<i", data[off:off + 4])[0] | |
| off += 4 | |
| # Validate: mip_count should be reasonable | |
| max_dim = max(width, height) | |
| max_mips = int(math.log2(max_dim)) + 1 if max_dim > 0 else 1 | |
| if mip_count < 1 or mip_count > max_mips + 2: | |
| pos += 4 | |
| continue | |
| # Validate: texture format should be known | |
| if tex_format not in TEXTURE_FORMATS: | |
| pos += 4 | |
| continue | |
| # Validate: image size should roughly match expected | |
| expected = calc_expected_size(width, height, mip_count, tex_format) | |
| if expected > 0 and abs(expected - image_size) > image_size * 0.01: | |
| pos += 4 | |
| continue | |
| # m_IsReadable (bool) | |
| is_readable = data[off] if off < len(data) else -1 | |
| off += 1 | |
| # m_IsPreProcessed (bool) | |
| is_preprocessed = data[off] if off < len(data) else -1 | |
| off += 1 | |
| format_name = TEXTURE_FORMATS.get(tex_format, f"Unknown({tex_format})") | |
| results.append({ | |
| "name": name, | |
| "width": width, | |
| "height": height, | |
| "format_id": tex_format, | |
| "format_name": format_name, | |
| "mip_count": mip_count, | |
| "mips_stripped": mips_stripped, | |
| "image_size": image_size, | |
| "expected_size": expected, | |
| "is_readable": is_readable, | |
| "forced_fallback": forced_fallback, | |
| "forced_fallback_name": TEXTURE_FORMATS.get(forced_fallback, f"Unknown({forced_fallback})"), | |
| "offset": pos, | |
| }) | |
| # Skip past this texture's data | |
| pos = off | |
| continue | |
| info = { | |
| "file": filepath, | |
| "file_size": len(data), | |
| "format_version": fmt_version, | |
| "unity_version": unity_version, | |
| "platform_id": platform_id, | |
| "platform_name": PLATFORM_NAMES.get(platform_id, f"Unknown({platform_raw})"), | |
| "data_offset": data_offset, | |
| "textures": results, | |
| } | |
| return info, None | |
| def print_report(info): | |
| """Print a formatted report for one asset file.""" | |
| sep = "=" * 70 | |
| print(sep) | |
| print(f" File: {info['file']}") | |
| print(f" Size: {info['file_size']:,} bytes ({info['file_size']/1024/1024:.2f} MB)") | |
| print(sep) | |
| print(f" Unity Version : {info['unity_version']}") | |
| print(f" Format Version : {info['format_version']}") | |
| print(f" Target Platform : {info['platform_name']} (id={info['platform_id']})") | |
| print(f" Data Offset : 0x{info['data_offset']:X}") | |
| print() | |
| if not info["textures"]: | |
| print(" [!] No Texture2D objects found in this file.") | |
| print() | |
| return | |
| for i, tex in enumerate(info["textures"]): | |
| fmt_category = classify_format(tex["format_name"]) | |
| size_match = tex["expected_size"] == tex["image_size"] | |
| print(f" --- Texture #{i} at offset 0x{tex['offset']:X} ---") | |
| print(f" Name : {tex['name']}") | |
| print(f" Dimensions : {tex['width']} x {tex['height']}") | |
| print(f" TextureFormat : {tex['format_name']} (id={tex['format_id']})") | |
| print(f" Format Category : {fmt_category.upper()}") | |
| print(f" Mip Count : {tex['mip_count']}") | |
| print(f" Mips Stripped : {tex['mips_stripped']}") | |
| print(f" Image Data Size : {tex['image_size']:,} bytes ({tex['image_size']/1024/1024:.2f} MB)") | |
| if tex["expected_size"] > 0: | |
| mark = "OK" if size_match else "MISMATCH" | |
| print(f" Expected Size : {tex['expected_size']:,} bytes [{mark}]") | |
| print(f" Fallback Format : {tex['forced_fallback_name']} (id={tex['forced_fallback']})") | |
| print(f" Is Readable : {bool(tex['is_readable'])}") | |
| print() | |
| # Warn on likely contamination | |
| platform_id = info["platform_id"] | |
| is_desktop = platform_id in (4, 9, 19, 20, 2) # Windows, Linux, macOS | |
| is_mobile = platform_id in (5, 7) # iOS, Android | |
| if fmt_category == "mobile" and is_desktop: | |
| print(f" [!!] WARNING: Mobile format ({tex['format_name']}) in a DESKTOP platform asset!") | |
| print(f" This will cause color noise / visual corruption at runtime.") | |
| print(f" Likely cause: build cache contamination from a mobile build.") | |
| print() | |
| elif fmt_category == "desktop" and is_mobile: | |
| print(f" [!!] WARNING: Desktop format ({tex['format_name']}) in a MOBILE platform asset!") | |
| print(f" This will cause decoding failure or fallback at runtime.") | |
| print(f" Likely cause: build cache contamination from a desktop build.") | |
| print() | |
| def main(): | |
| if len(sys.argv) < 2: | |
| print(f"Usage: {sys.argv[0]} <file.asset> [file2.asset ...]") | |
| print(f" {sys.argv[0]} *.asset") | |
| sys.exit(1) | |
| files = [f for f in sys.argv[1:] if not f.endswith(".meta")] | |
| for filepath in files: | |
| if not os.path.isfile(filepath): | |
| print(f"[!] Not a file: {filepath}") | |
| continue | |
| info, err = parse_texture(filepath) | |
| if err: | |
| print(f"[!] {filepath}: {err}") | |
| continue | |
| print_report(info) | |
| if __name__ == "__main__": | |
| main() |
Author
a1678991
commented
Mar 2, 2026
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment