From 76cec9e83bb3e708ab86819a236541ce8791a9c0 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Wed, 14 Oct 2026 23:17:15 +0800 Subject: [PATCH 1/2] fix(Texture2D): textures without size or stream data A dynamic font keeps its glyph texture in a Texture2D named "Font Texture" with a width and height of 0 and no image data. Like every texture that is not streamed, it serializes m_StreamData with an empty path, offset 0 and size 0. Texture2D.get_image_data looked up a resource whenever m_StreamData was set, so reading the image of such a texture passed an empty name to get_resource_data, which opened the environment directory as a file and failed with PermissionError on Windows and IsADirectoryError on Linux. A texture with a size and no image data took the same path. get_image_from_texture2d now returns an empty image for a texture without width or height, as parse_image_data does, without fetching any data. get_image_data reads a resource only when m_StreamData names one, and raises "No image data found" otherwise. tests/test_texture.py builds both textures in memory and checks that no dependency is loaded. --- UnityPy/classes/legacy_patch/Texture2D.py | 2 +- UnityPy/export/Texture2DConverter.py | 2 + tests/test_texture.py | 53 +++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 tests/test_texture.py diff --git a/UnityPy/classes/legacy_patch/Texture2D.py b/UnityPy/classes/legacy_patch/Texture2D.py index 680829af..aba927c8 100644 --- a/UnityPy/classes/legacy_patch/Texture2D.py +++ b/UnityPy/classes/legacy_patch/Texture2D.py @@ -63,7 +63,7 @@ def _Texture2d_set_image( def _Texture2D_get_image_data(self: Texture2D): if self.image_data: return self.image_data - if self.m_StreamData: + if self.m_StreamData and self.m_StreamData.path: from ...helpers.ResourceReader import get_resource_data return get_resource_data( diff --git a/UnityPy/export/Texture2DConverter.py b/UnityPy/export/Texture2DConverter.py index a46eed5f..7d6d4c81 100644 --- a/UnityPy/export/Texture2DConverter.py +++ b/UnityPy/export/Texture2DConverter.py @@ -239,6 +239,8 @@ def get_image_from_texture2d( flip: bool = True, ) -> Image.Image: """Converts the given Texture2D object to PIL Image.""" + if not texture_2d.m_Width or not texture_2d.m_Height: + return Image.new("RGBA", (0, 0)) return parse_image_data( texture_2d.get_image_data(), texture_2d.m_Width, diff --git a/tests/test_texture.py b/tests/test_texture.py new file mode 100644 index 00000000..f8f848e5 --- /dev/null +++ b/tests/test_texture.py @@ -0,0 +1,53 @@ +from types import SimpleNamespace + +import pytest + +import UnityPy +from UnityPy.classes.generated import GLTextureSettings, StreamingInfo, Texture2D +from UnityPy.enums import TextureFormat + +VERSION = (2022, 3, 0, 0) + + +def object_reader(): + environment = UnityPy.Environment() + + def load_dependencies(possible_dependencies=None): + raise AssertionError("no dependency should be loaded") + + assets_file = SimpleNamespace(environment=environment, load_dependencies=load_dependencies, _cache={}) + return SimpleNamespace(assets_file=assets_file, version=VERSION, platform=0) + + +def texture_settings(): + return GLTextureSettings(m_Aniso=1, m_FilterMode=1, m_MipBias=0.0, m_WrapMode=0) + + +def texture2d(width, height, image_data=b"", stream_path=""): + texture = Texture2D( + image_data=image_data, + m_CompleteImageSize=len(image_data), + m_ForcedFallbackFormat=0, + m_Height=height, + m_ImageCount=1, + m_IsReadable=False, + m_LightmapFormat=0, + m_MipCount=1, + m_Name="texture", + m_TextureDimension=2, + m_TextureFormat=TextureFormat.RGBA32, + m_TextureSettings=texture_settings(), + m_Width=width, + m_StreamData=StreamingInfo(offset=0, path=stream_path, size=0), + ) + texture.object_reader = object_reader() + return texture + + +def test_texture2d_without_size(): + assert texture2d(0, 0).image.size == (0, 0) + + +def test_texture2d_without_image_data(): + with pytest.raises(ValueError): + texture2d(2, 2).get_image_data() From f8906d75c1ff6d661d6ac396186f10c95388bc82 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Wed, 14 Oct 2026 23:23:37 +0800 Subject: [PATCH 2/2] fix(Texture2DArray): read the layers of streamed texture arrays Texture2DArray.images only read m_StreamData when image_data was None. A streamed array serializes image_data as an empty byte string, so the resource file was never read and every streamed array failed with "Texture2D has no image data". Texture2DArray now has get_image_data(), with the same rules as Texture2D.get_image_data(): the inline data when there is any, else the resource named by m_StreamData, else a ValueError. images uses it and decodes the layers with the platform of the object reader instead of platform 0, as Texture2D does. tests/test_texture.py decodes a two-layer RGBA32 array stored inline and the same array stored in a registered .resS resource. --- .../classes/legacy_patch/Texture2DArray.py | 29 ++++++---- tests/test_texture.py | 53 ++++++++++++++++++- 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/UnityPy/classes/legacy_patch/Texture2DArray.py b/UnityPy/classes/legacy_patch/Texture2DArray.py index e43039de..1ead3860 100644 --- a/UnityPy/classes/legacy_patch/Texture2DArray.py +++ b/UnityPy/classes/legacy_patch/Texture2DArray.py @@ -9,22 +9,30 @@ from PIL import Image +def _Texture2DArray_get_image_data(self: Texture2DArray): + if self.image_data: + return self.image_data + if self.m_StreamData and self.m_StreamData.path: + from ...helpers.ResourceReader import get_resource_data + + return get_resource_data( + self.m_StreamData.path, + self.object_reader.assets_file, + self.m_StreamData.offset, + self.m_StreamData.size, + ) + raise ValueError("No image data found") + + def _Texture2DArray_get_images(self: Texture2DArray) -> List[Image.Image]: + from ...enums import BuildTarget from ...export import Texture2DConverter - from ...helpers.ResourceReader import get_resource_data texture_format = GRAPHICS_TO_TEXTURE_MAP.get(GraphicsFormat(self.m_Format)) if not texture_format: raise NotImplementedError(f"GraphicsFormat {self.m_Format} not supported yet") - image_data = self.image_data - if image_data is None: - image_data = get_resource_data( - self.m_StreamData.path, - self.object_reader.assets_file, - self.m_StreamData.offset, - self.m_StreamData.size, - ) + image_data = self.get_image_data() # calculate the number of textures in the array texture_size = self.m_DataSize // self.m_Depth @@ -36,7 +44,7 @@ def _Texture2DArray_get_images(self: Texture2DArray) -> List[Image.Image]: self.m_Height, texture_format, self.object_reader.version, - 0, + getattr(self.object_reader, "platform", BuildTarget.UnknownPlatform), None, ) for offset in range(0, self.m_DataSize, texture_size) @@ -44,5 +52,6 @@ def _Texture2DArray_get_images(self: Texture2DArray) -> List[Image.Image]: Texture2DArray.images = property(_Texture2DArray_get_images) +Texture2DArray.get_image_data = _Texture2DArray_get_image_data __all__ = ("Texture2DArray",) diff --git a/tests/test_texture.py b/tests/test_texture.py index f8f848e5..b2604fd3 100644 --- a/tests/test_texture.py +++ b/tests/test_texture.py @@ -3,8 +3,10 @@ import pytest import UnityPy -from UnityPy.classes.generated import GLTextureSettings, StreamingInfo, Texture2D +from UnityPy.classes.generated import GLTextureSettings, StreamingInfo, Texture2D, Texture2DArray from UnityPy.enums import TextureFormat +from UnityPy.enums.GraphicsFormat import GraphicsFormat +from UnityPy.streams import EndianBinaryReader VERSION = (2022, 3, 0, 0) @@ -51,3 +53,52 @@ def test_texture2d_without_size(): def test_texture2d_without_image_data(): with pytest.raises(ValueError): texture2d(2, 2).get_image_data() + + +ARRAY_LAYERS = [bytes([255, 0, 0, 255, 0, 255, 0, 255]), bytes([0, 0, 255, 255, 255, 255, 255, 255])] +ARRAY_PIXELS = [ + [(255, 0, 0, 255), (0, 255, 0, 255)], + [(0, 0, 255, 255), (255, 255, 255, 255)], +] + + +def texture2darray(image_data=b"", stream_path=""): + data_size = sum(len(layer) for layer in ARRAY_LAYERS) + texture = Texture2DArray( + image_data=image_data, + m_ColorSpace=1, + m_DataSize=data_size, + m_Depth=len(ARRAY_LAYERS), + m_Format=GraphicsFormat.R8G8B8A8_UNorm, + m_Height=1, + m_IsReadable=False, + m_MipCount=1, + m_Name="array", + m_TextureSettings=texture_settings(), + m_Width=2, + m_StreamData=StreamingInfo(offset=4, path=stream_path, size=data_size if stream_path else 0), + ) + texture.object_reader = object_reader() + return texture + + +def array_pixels(texture): + return [[image.getpixel((x, 0)) for x in range(2)] for image in texture.images] + + +def test_texture2darray_inline(): + assert array_pixels(texture2darray(image_data=b"".join(ARRAY_LAYERS))) == ARRAY_PIXELS + + +def test_texture2darray_streamed(): + # streamed arrays serialize an empty image_data and point at a resource file + texture = texture2darray(stream_path="archive:/CAB-array/CAB-array.resS") + resource = EndianBinaryReader(b"\0" * 4 + b"".join(ARRAY_LAYERS)) + texture.object_reader.assets_file.environment.register_cab("CAB-array.resS", resource) + assert array_pixels(texture) == ARRAY_PIXELS + assert texture.get_image_data() == b"".join(ARRAY_LAYERS) + + +def test_texture2darray_without_image_data(): + with pytest.raises(ValueError): + texture2darray().get_image_data()