using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Abstractions.Extractors.Unity.Services;
using AssetsTools.NET;
using AssetsTools.NET.Extra;
using AssetsTools.NET.Texture;
using SkiaSharp;
namespace Abstractions.Extractors.Unity;
///
/// Extracts Texture2D and Sprite objects to PNG, using AssetsTools.NET.Texture for the format decode
/// plus a hand-rolled sprite crop. Unity stores texture rows bottom-to-top, so decoded pixels are
/// flipped before encoding.
///
public sealed class TextureExtractor : IAssetExtractor
{
public IReadOnlyCollection HandledTypes { get; } = ["Texture2D", "Sprite"];
public async Task ExtractAsync(ExtractionContext context, CancellationToken cancellationToken = default)
{
return context.ObjectType switch
{
"Texture2D" => await ExtractTexture2DAsync(context, cancellationToken),
"Sprite" => await ExtractSpriteAsync(context, cancellationToken),
_ => false,
};
}
private static async Task ExtractTexture2DAsync(ExtractionContext context, CancellationToken cancellationToken)
{
try
{
var (pixels, width, height) = DecodeTopDownRgba(context.FileInstance, context.BaseField, context.DependencyIndex);
if (pixels is null) return false;
WritePng(pixels, width, height, context.OutputPathNoExtension + ".png");
var baseField = context.BaseField;
var texInfo = new Dictionary
{
["width"] = width,
["height"] = height,
["format"] = baseField.Get("m_TextureFormat") is { IsDummy: false } fmt
? ((TextureFormat)fmt.AsInt).ToString()
: "Unknown",
["mip_count"] = baseField.Get("m_MipCount") is { IsDummy: false } mc ? mc.AsInt : 1,
["is_readable"] = baseField.Get("m_IsReadable") is { IsDummy: false } r && r.AsBool,
["streaming_info"] = context.StreamingInfo,
};
await ObjectSerializer.WriteJsonAsync(texInfo, context.OutputPathNoExtension + "_info.json", cancellationToken);
return true;
}
catch
{
return false;
}
}
///
/// Unpacked sprites carry their own m_RD.texture PPtr straight to a Texture2D and crop with
/// m_Rect. Atlas-packed sprites (the common case when Sprite Packer/Atlas is on) instead leave
/// m_RD.texture at zero; the real texture + crop rect live in the SpriteAtlas object referenced
/// by m_SpriteAtlas (frequently a *different* .bundle file entirely), keyed by this sprite's
/// m_RenderDataKey inside the atlas's m_RenderDataMap.
///
private static async Task ExtractSpriteAsync(ExtractionContext context, CancellationToken cancellationToken)
{
try
{
var baseField = context.BaseField;
var resolved =
TryResolveDirectTexture(context, baseField) ??
TryResolveAtlasTexture(context, baseField);
if (resolved is not { } renderData) return false;
var (textureFileInstance, textureBaseField, rectField) = renderData;
var (pixels, texWidth, texHeight) = DecodeTopDownRgba(textureFileInstance, textureBaseField, context.DependencyIndex);
if (pixels is null) return false;
var rectX = (int)rectField.Get("x").AsFloat;
var rectY = (int)rectField.Get("y").AsFloat;
var rectWidth = (int)rectField.Get("width").AsFloat;
var rectHeight = (int)rectField.Get("height").AsFloat;
if (rectWidth <= 0 || rectHeight <= 0) return false;
var cropTop = texHeight - (rectY + rectHeight);
var cropped = Crop(pixels, texWidth, rectX, cropTop, rectWidth, rectHeight);
WritePng(cropped, rectWidth, rectHeight, context.OutputPathNoExtension + ".png");
var pivot = baseField.Get("m_Pivot");
var spriteInfo = new Dictionary
{
["rect_x"] = rectX,
["rect_y"] = rectY,
["rect_width"] = rectWidth,
["rect_height"] = rectHeight,
["pivot_x"] = pivot.Get("x") is { IsDummy: false } px ? px.AsFloat : 0.5f,
["pivot_y"] = pivot.Get("y") is { IsDummy: false } py ? py.AsFloat : 0.5f,
["pixels_per_unit"] = baseField.Get("m_PixelsPerUnit") is { IsDummy: false } ppu ? ppu.AsFloat : 100f,
};
await ObjectSerializer.WriteJsonAsync(spriteInfo, context.OutputPathNoExtension + "_info.json", cancellationToken);
return true;
}
catch
{
return false;
}
}
private static (AssetsFileInstance FileInstance, AssetTypeValueField TextureBaseField, AssetTypeValueField RectField)? TryResolveDirectTexture(
ExtractionContext context, AssetTypeValueField spriteBaseField)
{
var texturePptr = spriteBaseField.Get("m_RD").Get("texture");
if (!context.Loader.TryResolvePPtr(context.FileInstance, texturePptr, context.DependencyIndex, out var textureBaseField, out var textureFileInstance) ||
textureBaseField is null || textureFileInstance is null)
return null;
return (textureFileInstance, textureBaseField, spriteBaseField.Get("m_Rect"));
}
private static (AssetsFileInstance FileInstance, AssetTypeValueField TextureBaseField, AssetTypeValueField RectField)? TryResolveAtlasTexture(
ExtractionContext context, AssetTypeValueField spriteBaseField)
{
var atlasPptr = spriteBaseField.Get("m_SpriteAtlas");
if (!context.Loader.TryResolvePPtr(context.FileInstance, atlasPptr, context.DependencyIndex, out var atlasBaseField, out var atlasFileInstance) ||
atlasBaseField is null || atlasFileInstance is null)
return null;
var renderDataKey = spriteBaseField.Get("m_RenderDataKey");
var spriteAtlasData = BundleLoader.ArrayElements(atlasBaseField.Get("m_RenderDataMap"))
.FirstOrDefault(entry => RenderDataKeysMatch(entry.Get("first"), renderDataKey))
?.Get("second");
if (spriteAtlasData is null || spriteAtlasData.IsDummy) return null;
var texturePptr = spriteAtlasData.Get("texture");
if (!context.Loader.TryResolvePPtr(atlasFileInstance, texturePptr, context.DependencyIndex, out var textureBaseField, out var textureFileInstance) ||
textureBaseField is null || textureFileInstance is null)
return null;
return (textureFileInstance, textureBaseField, spriteAtlasData.Get("textureRect"));
}
private static bool RenderDataKeysMatch(AssetTypeValueField mapKey, AssetTypeValueField spriteKey)
{
var mapGuid = mapKey.Get("first");
var spriteGuid = spriteKey.Get("first");
for (var i = 0; i < 4; i++)
{
if (mapGuid.Get($"data[{i}]").AsUInt != spriteGuid.Get($"data[{i}]").AsUInt)
return false;
}
return mapKey.Get("second").AsLong == spriteKey.Get("second").AsLong;
}
/// Reads+decodes a Texture2D field to top-down RGBA32 bytes (width*height*4). If
/// is owned by a shared BundleDependencyIndex (i.e. it was
/// reached through a cross-bundle hop, not this bundle's own private loader), FillPictureData
/// reads raw bytes through that bundle's single shared reader - so the whole read+decode has to
/// run under the index's lock, same as resolving the PPtr chain that got us here did.
private static (byte[]? Pixels, int Width, int Height) DecodeTopDownRgba(
AssetsFileInstance fileInstance, AssetTypeValueField textureBaseField, BundleDependencyIndex? dependencyIndex)
{
if (dependencyIndex is not null && dependencyIndex.Owns(fileInstance))
return dependencyIndex.RunLocked(() => DecodeTopDownRgbaCore(fileInstance, textureBaseField));
return DecodeTopDownRgbaCore(fileInstance, textureBaseField);
}
private static (byte[]? Pixels, int Width, int Height) DecodeTopDownRgbaCore(AssetsFileInstance fileInstance, AssetTypeValueField textureBaseField)
{
var texture = TextureFile.ReadTextureFile(textureBaseField);
var textureData = texture.FillPictureData(fileInstance);
if (textureData.Length == 0) return (null, 0, 0);
var rgba = texture.DecodeTextureRaw(textureData, useBgra: false);
if (rgba.Length == 0) return (null, 0, 0);
// Unity stores rows bottom-to-top; PNG/SkiaSharp expect top-to-bottom.
var stride = texture.m_Width * 4;
var flipped = new byte[rgba.Length];
for (var y = 0; y < texture.m_Height; y++)
Buffer.BlockCopy(rgba, y * stride, flipped, (texture.m_Height - 1 - y) * stride, stride);
return (flipped, texture.m_Width, texture.m_Height);
}
private static byte[] Crop(byte[] topDownRgba, int sourceWidth, int x, int y, int width, int height)
{
var result = new byte[width * height * 4];
var srcStride = sourceWidth * 4;
var dstStride = width * 4;
for (var row = 0; row < height; row++)
{
var srcOffset = (y + row) * srcStride + x * 4;
Buffer.BlockCopy(topDownRgba, srcOffset, result, row * dstStride, dstStride);
}
return result;
}
private static void WritePng(byte[] topDownRgba, int width, int height, string path)
{
var info = new SKImageInfo(width, height, SKColorType.Rgba8888, SKAlphaType.Unpremul);
var handle = GCHandle.Alloc(topDownRgba, GCHandleType.Pinned);
try
{
using var bitmap = new SKBitmap();
bitmap.InstallPixels(info, handle.AddrOfPinnedObject(), width * 4);
using var image = SKImage.FromBitmap(bitmap);
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
using var stream = File.Create(path);
data.SaveTo(stream);
}
finally
{
handle.Free();
}
}
}