Skip to content

Instantly share code, notes, and snippets.

@a1678991
Created March 2, 2026 14:47
Show Gist options
  • Select an option

  • Save a1678991/58a6fcf74e25f4e718042899a7ef8b23 to your computer and use it in GitHub Desktop.

Select an option

Save a1678991/58a6fcf74e25f4e718042899a7ef8b23 to your computer and use it in GitHub Desktop.
#!/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()
@a1678991
Copy link
Author

a1678991 commented Mar 2, 2026

======================================================================
  File: Mafuyu_Lite_Mobile(Clone)/_assets/AAO Merged Texture (for _MainTex).asset
  Size: 11,189,580 bytes (10.67 MB)
======================================================================
  Unity Version    : 2022.3.22f1
  Format Version   : 22
  Target Platform  : NoTarget (Editor) (id=-2)
  Data Offset      : 0x1130

  --- Texture #0 at offset 0x1158 ---
  Name             : AAO Merged Texture (for _MainTex)
  Dimensions       : 4096 x 2048
  TextureFormat    : ASTC_4x4 (id=48)
  Format Category  : MOBILE
  Mip Count        : 13
  Mips Stripped    : 0
  Image Data Size  : 11,184,848 bytes (10.67 MB)
  Expected Size    : 11,184,848 bytes [OK]
  Fallback Format  : RGBA32 (id=4)
  Is Readable      : True

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment