diff --git a/.ort/package-configurations/unpackrat-build-tests-unity-nuget-monitor.yml b/.ort/package-configurations/unpackrat-build-tests-unity-nuget-monitor.yml index 10bbc2f..f09d098 100644 --- a/.ort/package-configurations/unpackrat-build-tests-unity-nuget-monitor.yml +++ b/.ort/package-configurations/unpackrat-build-tests-unity-nuget-monitor.yml @@ -1,6 +1,6 @@ { - "id": "SpdxDocumentFile:vcpkg:unpackrat-build-tests-unity-nuget-monitor:e50376b9ea641346f93a1a2e2e1a32b42e6617088165ec890acd99599f13975f", - "source_artifact_url": "http://127.0.0.1:8765/sources/e50376b9ea641346f93a1a2e2e1a32b42e6617088165ec890acd99599f13975f.tar", + "id": "SpdxDocumentFile:vcpkg:unpackrat-build-tests-unity-nuget-monitor:93d3fca7a12e2761f5caf987dabd1e2d4b6b917a2ae725e44ef38e205d3945c6", + "source_artifact_url": "http://127.0.0.1:8765/sources/93d3fca7a12e2761f5caf987dabd1e2d4b6b917a2ae725e44ef38e205d3945c6.tar", "license_finding_curations": [ { "path": "build/tests/test_unity_nuget_monitor.py", @@ -9,7 +9,7 @@ "detected_license": "GPL-3.0-only", "reason": "INCORRECT", "concluded_license": "NONE", - "comment": "Line 123 is the attribution string list entry containing GPL-3.0 combined work and StbImageSharp 2.30.15 that the test requires in the shipped Unity notice. This first-party MIT test tooling checks that retained notice text; it does not grant a license, and no third-party adoption is claimed. The linked AssetRipper GPL-3.0 runtime source is recorded as an incorporated component in the inventory. The false-positive finding is removed." + "comment": "Line 123 is the attribution string list entry containing GPL-3.0 combined work that the test requires in the shipped Unity notice. This first-party MIT test tooling checks that retained notice text; it does not grant a license, and no third-party adoption is claimed. The linked AssetRipper GPL-3.0 runtime source is recorded as an incorporated component in the inventory. The false-positive finding is removed." } ] } diff --git a/.ort/package-configurations/unpackrat-build-tests-unity-packaging.yml b/.ort/package-configurations/unpackrat-build-tests-unity-packaging.yml index a16ea12..1da9ca0 100644 --- a/.ort/package-configurations/unpackrat-build-tests-unity-packaging.yml +++ b/.ort/package-configurations/unpackrat-build-tests-unity-packaging.yml @@ -1,6 +1,6 @@ { - "id": "SpdxDocumentFile:vcpkg:unpackrat-build-tests-unity-packaging:1630ef74260d058a8a443e5767049cd5693d6e34dc9f2f4a68ece675528b022f", - "source_artifact_url": "http://127.0.0.1:8765/sources/1630ef74260d058a8a443e5767049cd5693d6e34dc9f2f4a68ece675528b022f.tar", + "id": "SpdxDocumentFile:vcpkg:unpackrat-build-tests-unity-packaging:6dbb645efe00999a6bbfd1f5a77bd16296cbb5f5fa4c00b444f77e699077f663", + "source_artifact_url": "http://127.0.0.1:8765/sources/6dbb645efe00999a6bbfd1f5a77bd16296cbb5f5fa4c00b444f77e699077f663.tar", "license_finding_curations": [ { "path": "build/tests/test_unity_packaging.py", diff --git a/README.md b/README.md index d07302b..4433891 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,15 @@ resource files, and the other dependencies the selected file references, are res selected file, while resources owned by unrelated dependencies are not added, so keep the sidecar files next to the selected file. Whole-game catalogues and code decompilation are not offered. -Each entry converts on demand to its own output file. `Texture2D` and `Sprite` become PNG; `Sprite` -crops its atlas region and handles packing, flips, 90-degree rectangles, a transparent canvas and a -tight mask. `TextAsset` keeps its original bytes as `.bin`, which does not mean plain text only. +Each entry converts on demand to its own output file. `Texture2D` exports the complete texture as +PNG, including atlas textures. Individual `Sprite` images are not reconstructed or listed; their +pixels remain in the complete textures. `TextAsset` keeps its original bytes as `.bin`, which does +not mean plain text only. +Texture memory checks use the actual decoded pixel format and account for PNG conversion and +encoder buffers. Ordinary textures accept up to 256 MiB of source data and decoded pixels, +subject to a 1 GiB working-memory estimate per extraction. Block-compressed and crunched textures, platform-specific +Xbox/Switch conversions and PNG images wider or taller than 65535 pixels retain the previous +stricter limits. These limits bound individual operations; they do not reserve memory in FAR. `AudioClip` uses the mature Fmod5Sharp decoder: FSB5 single-sample PCM and ADPCM export to WAV and Vorbis to Ogg, legacy stored sound bytes are preserved, and unsupported FSB5 banks or codecs keep their raw `.fsb`. Embedded fonts export to TTF/OTF/TTC, `VideoClip` and `MovieTexture` keep their @@ -42,10 +48,10 @@ scene, materials or animations. Original hierarchy paths are kept when present, with literal `{Type}` folders as a fallback. DisplayedSize and PackedSize report the original source payload length (0 means unknown); converted -output may differ. An unsupported downscale value beyond 0 or 1, a tight mask that cannot be read, -or a source geometry budget refusal fails that one entry's export, so not every variant or Unity -version is guaranteed. External code, `MonoBehaviour`, `ScriptableObject`, `Material` and -`AnimationClip` are outside this resource feature. The Unity module is a single DLL with no +output may differ. An unsupported resource variant or a source geometry budget refusal fails that +one entry's export, so not every variant or Unity version is guaranteed. External code, +`MonoBehaviour`, `ScriptableObject`, `Material` and `AnimationClip` are outside this resource +feature. The Unity module is a single DLL with no separate .NET runtime to install. Each module ZIP carries the module DLL for one architecture, its `observer_user.ini`, both diff --git a/build/tests/fixtures/license_package_configurations/firstparty_artifacts.json b/build/tests/fixtures/license_package_configurations/firstparty_artifacts.json index 2eab61b..57d87a7 100644 --- a/build/tests/fixtures/license_package_configurations/firstparty_artifacts.json +++ b/build/tests/fixtures/license_package_configurations/firstparty_artifacts.json @@ -619,9 +619,9 @@ { "name": "unpackrat-build-tests-unity-packaging", "member": "build/tests/test_unity_packaging.py", - "version": "1630ef74260d058a8a443e5767049cd5693d6e34dc9f2f4a68ece675528b022f", - "sha256": "1630ef74260d058a8a443e5767049cd5693d6e34dc9f2f4a68ece675528b022f", - "artifact_filename": "1630ef74260d058a8a443e5767049cd5693d6e34dc9f2f4a68ece675528b022f.tar", + "version": "6dbb645efe00999a6bbfd1f5a77bd16296cbb5f5fa4c00b444f77e699077f663", + "sha256": "6dbb645efe00999a6bbfd1f5a77bd16296cbb5f5fa4c00b444f77e699077f663", + "artifact_filename": "6dbb645efe00999a6bbfd1f5a77bd16296cbb5f5fa4c00b444f77e699077f663.tar", "extension": ".tar", "curations": [ { @@ -656,9 +656,9 @@ { "name": "unpackrat-build-tests-unity-nuget-monitor", "member": "build/tests/test_unity_nuget_monitor.py", - "version": "e50376b9ea641346f93a1a2e2e1a32b42e6617088165ec890acd99599f13975f", - "sha256": "e50376b9ea641346f93a1a2e2e1a32b42e6617088165ec890acd99599f13975f", - "artifact_filename": "e50376b9ea641346f93a1a2e2e1a32b42e6617088165ec890acd99599f13975f.tar", + "version": "93d3fca7a12e2761f5caf987dabd1e2d4b6b917a2ae725e44ef38e205d3945c6", + "sha256": "93d3fca7a12e2761f5caf987dabd1e2d4b6b917a2ae725e44ef38e205d3945c6", + "artifact_filename": "93d3fca7a12e2761f5caf987dabd1e2d4b6b917a2ae725e44ef38e205d3945c6.tar", "extension": ".tar", "curations": [ { @@ -668,7 +668,7 @@ "detected_license": "GPL-3.0-only", "reason": "INCORRECT", "concluded_license": "NONE", - "comment": "Line 123 is the attribution string list entry containing GPL-3.0 combined work and StbImageSharp 2.30.15 that the test requires in the shipped Unity notice. This first-party MIT test tooling checks that retained notice text; it does not grant a license, and no third-party adoption is claimed. The linked AssetRipper GPL-3.0 runtime source is recorded as an incorporated component in the inventory. The false-positive finding is removed." + "comment": "Line 123 is the attribution string list entry containing GPL-3.0 combined work that the test requires in the shipped Unity notice. This first-party MIT test tooling checks that retained notice text; it does not grant a license, and no third-party adoption is claimed. The linked AssetRipper GPL-3.0 runtime source is recorded as an incorporated component in the inventory. The false-positive finding is removed." } ] } diff --git a/build/tests/test_unity_nuget_monitor.py b/build/tests/test_unity_nuget_monitor.py index ad9936f..8fa7e90 100644 --- a/build/tests/test_unity_nuget_monitor.py +++ b/build/tests/test_unity_nuget_monitor.py @@ -120,12 +120,20 @@ def test_shipped_notice_reproduces_upstream_mit_and_attribution(self) -> None: "AssetsTools.NET 3.0.5", "https://www.nuget.org/packages/AssetsTools.NET/3.0.5", "https://github.com/nesrak1/AssetsTools.NET", - "GPL-3.0 combined work", "StbImageSharp 2.30.15", + "GPL-3.0 combined work", ): with self.subTest(attribution=attribution): self.assertTrue(attribution in notice, "missing attribution: " + attribution) self.assertTrue(MIT_NOTICE in notice, "the complete upstream MIT notice must ship") + def test_retired_sprite_image_reader_is_absent_from_dependencies_and_notice(self) -> None: + project = ET.parse(REPOSITORY / PROJECT).getroot() + packages = {reference.get("Include") for reference in project.iter("PackageReference")} + self.assertNotIn("StbImageSharp", packages) + notice = (REPOSITORY / "licenses/Unity.txt").read_text(encoding="utf-8") + self.assertFalse("StbImageSharp" in notice, "retired Sprite decoder attribution must not ship") + self.assertIn("StbImageWriteSharp", notice, "the retained PNG writer attribution must ship") + if __name__ == "__main__": unittest.main() diff --git a/build/tests/test_unity_packaging.py b/build/tests/test_unity_packaging.py index edd8a82..2d7b0b1 100644 --- a/build/tests/test_unity_packaging.py +++ b/build/tests/test_unity_packaging.py @@ -25,7 +25,7 @@ UNITY = "unity" -UNITY_VERSION = "1.0.1" +UNITY_VERSION = "2.0.0" MODULES_UNDER_TEST = ("renpy", "rpgmaker", "zanzarah", "godot", "wolfrpg") UNITY_SO_X86 = b"\x7fELF unity shared object x86 fixture\n" @@ -215,7 +215,7 @@ def test_cli_selects_unity_by_name(self) -> None: class UnityShippedTreeTests(unittest.TestCase): """The real ``src/modules/unity`` registration must match the shipped layout.""" - def test_version_is_release_1_0_1(self) -> None: + def test_version_is_release_2_0_0(self) -> None: self.assertEqual(main.read_version(REPOSITORY_ROOT, UNITY), UNITY_VERSION) def test_observer_registration_uses_the_module_binary_and_a_narrow_filter(self) -> None: @@ -234,8 +234,8 @@ def test_bilingual_changelogs_document_the_version(self) -> None: russian = (REPOSITORY_ROOT / "src" / "modules" / UNITY / "ChangeLog.ru.txt").read_text("utf-8") english_headers = re.findall(r"^Version \d+\.\d+\.\d+$", english, re.MULTILINE) russian_headers = re.findall(r"^Версия \d+\.\d+\.\d+$", russian, re.MULTILINE) - self.assertEqual(english_headers, ["Version 1.0.1", "Version 1.0.0"]) - self.assertEqual(russian_headers, ["Версия 1.0.1", "Версия 1.0.0"]) + self.assertEqual(english_headers, ["Version 2.0.0", "Version 1.0.1", "Version 1.0.0"]) + self.assertEqual(russian_headers, ["Версия 2.0.0", "Версия 1.0.1", "Версия 1.0.0"]) def test_shipped_unity_notice_bundle_labels_gpl_and_the_third_parties(self) -> None: notice = (REPOSITORY_ROOT / "licenses" / "Unity.txt").read_bytes() diff --git a/build/tests/test_unity_security_config.py b/build/tests/test_unity_security_config.py new file mode 100644 index 0000000..d97c415 --- /dev/null +++ b/build/tests/test_unity_security_config.py @@ -0,0 +1,122 @@ +"""Select safe NativeAOT packs without restoring or running a compiler. + +The installed SDK's real ProcessFrameworkReferences task is exercised against a +stale SDK/package compiler selection. This target only emits package-selection +items; Restore, Compile and Publish are never invoked by these tests. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +import xml.etree.ElementTree as ET + +from test_unity_size_config import _dotnet_path, _project_path + + +SAFE_VERSION = "10.0.15" +NATIVE = { + "Configuration": "Release", + "PublishAot": "true", + "NativeLib": "Shared", +} + + +class UnitySecurityConfigurationTests(unittest.TestCase): + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory(prefix="unity-security-") + self.addCleanup(temporary.cleanup) + self.workspace = Path(temporary.name) + self.project = _project_path() + # Reproduce either an old SDK or a previously restored compiler package. + # The project-owned policy must win after evaluation, before selection. + document = ET.Element("Project") + ET.SubElement(document, "Import", Project=str(self.project.parents[1] / "Directory.Build.targets")) + item_group = ET.SubElement(document, "ItemGroup") + item = ET.SubElement(item_group, "KnownILCompilerPack", Update="Microsoft.DotNet.ILCompiler") + ET.SubElement(item, "ILCompilerPackVersion").text = "10.0.13" + self.imports = self.workspace / "stale-compiler.targets" + ET.ElementTree(document).write(self.imports, encoding="utf-8", xml_declaration=True) + + def select(self, **overrides: str) -> subprocess.CompletedProcess[str]: + properties = { + **NATIVE, + "BaseIntermediateOutputPath": str(self.workspace / "obj") + os.sep, + "MSBuildProjectExtensionsPath": str(self.workspace / "obj") + os.sep, + "DirectoryBuildTargetsPath": str(self.imports), + **overrides, + } + command = [ + _dotnet_path(), "msbuild", str(self.project), "-nologo", "-verbosity:quiet", + "-m:1", "-nr:false", "-target:ProcessFrameworkReferences", + "-getProperty:RuntimeFrameworkVersion", + "-getItem:HostILCompilerPack,TargetILCompilerPack", + ] + command.extend(f"-p:{name}={value}" for name, value in properties.items()) + return subprocess.run(command, cwd=self.project.parent, text=True, encoding="utf-8", + capture_output=True, timeout=120, check=False) + + def selected(self, **properties: str) -> dict: + result = self.select(**properties) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return json.loads(result.stdout) + + def assert_safe(self, document: dict, arch: str) -> None: + self.assertEqual(document["Properties"]["RuntimeFrameworkVersion"], SAFE_VERSION) + host = document["Items"]["HostILCompilerPack"] + target = document["Items"]["TargetILCompilerPack"] + self.assertEqual(len(host), 1) + self.assertEqual(len(target), 1) + self.assertTrue(host[0]["Identity"].casefold().startswith("runtime.")) + self.assertTrue(host[0]["Identity"].casefold().endswith(".microsoft.dotnet.ilcompiler")) + self.assertEqual(host[0]["NuGetPackageVersion"], SAFE_VERSION) + self.assertEqual(target[0]["NuGetPackageVersion"], SAFE_VERSION) + self.assertEqual(target[0]["Identity"].casefold(), + f"Microsoft.NETCore.App.Runtime.NativeAOT.win-{arch}".casefold()) + + def test_windows_shared_aot_selects_safe_runtime_and_compiler_on_both_architectures(self) -> None: + for arch in ("x64", "x86"): + for configuration in ("Debug", "Release"): + with self.subTest(architecture=arch, configuration=configuration): + self.assert_safe(self.selected(RuntimeIdentifier=f"win-{arch}", + Configuration=configuration), arch) + + def test_explicit_safe_runtime_remains_accepted(self) -> None: + for arch in ("x64", "x86"): + with self.subTest(architecture=arch): + self.assert_safe(self.selected(RuntimeIdentifier=f"win-{arch}", + RuntimeFrameworkVersion=SAFE_VERSION), arch) + + def test_conflicting_runtime_overrides_fail_before_pack_selection(self) -> None: + for arch in ("x64", "x86"): + for version in ("10.0.13", "10.0.14"): + with self.subTest(architecture=arch, version=version): + result = self.select(RuntimeIdentifier=f"win-{arch}", RuntimeFrameworkVersion=version) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("CVE-2026-111497", result.stdout + result.stderr) + self.assertIn(SAFE_VERSION, result.stdout + result.stderr) + + def test_managed_and_other_native_modes_keep_explicit_runtime_configuration(self) -> None: + cases = ( + {"PublishAot": "false", "RuntimeIdentifier": "win-x64"}, + {"PublishAot": "false"}, + {"NativeLib": "Static", "RuntimeIdentifier": "win-x64"}, + {"RuntimeIdentifier": "linux-x64"}, + {"RuntimeIdentifier": "win-arm64"}, + {"RuntimeIdentifier": ""}, + ) + for properties in cases: + with self.subTest(properties=properties): + document = self.selected(**properties, RuntimeFrameworkVersion="10.0.13") + self.assertEqual(document["Properties"]["RuntimeFrameworkVersion"], "10.0.13") + for kind in ("HostILCompilerPack", "TargetILCompilerPack"): + self.assertTrue(all(item["NuGetPackageVersion"] == "10.0.13" + for item in document["Items"][kind])) + + +if __name__ == "__main__": + unittest.main() diff --git a/licenses/Unity.txt b/licenses/Unity.txt index afd42a0..4003524 100644 --- a/licenses/Unity.txt +++ b/licenses/Unity.txt @@ -3120,23 +3120,6 @@ NIST-developed software is expressly provided "AS IS." NIST MAKES NO WARRANTY OF You are solely responsible for determining the appropriateness of using and distributing the software and you assume all risks associated with its use, including but not limited to the risks and costs of program errors, compliance with applicable laws, damage to or loss of data, programs or equipment, and the unavailability or interruption of operation. This software is not intended to be used in any situation where a failure could cause risk of injury or damage to property. The software developed by NIST employees is not subject to copyright protection within the United States. -============================================================================== -StbImageSharp 2.30.15 -============================================================================== - -The unpackrat Unity module references the StbImageSharp NuGet package, version -2.30.15 (pinned; upstream tag 2.30.15, commit -6fd7aebe1dbf10e28d78745f42a0c095c61d1945). It is used only to read a small cropped -PNG back into a fixed four-byte RGBA buffer. The package is a pure managed -assembly with no native binaries; its NuGet metadata names the authors -"StbImageSharpTeam" and the project https://github.com/StbSharp/StbImageSharp. - -StbImageSharp is a C# port of the public-domain stb_image.h; the upstream README -states the license as follows, reproduced verbatim: - -# License -Public Domain or MIT - ============================================================================== AssetsTools.NET 3.0.5 (MIT) ============================================================================== diff --git a/src/modules/unity/ChangeLog.en.txt b/src/modules/unity/ChangeLog.en.txt index dd1c6cc..8612f54 100644 --- a/src/modules/unity/ChangeLog.en.txt +++ b/src/modules/unity/ChangeLog.en.txt @@ -1,3 +1,12 @@ +Version 2.0.0 +------------- + + * Simplified image extraction by removing atlas slicing into individual sprites and its processing and memory overhead. + * Added support for Unity 6.5. + * Fixed errors when extracting large textures. + * Reduced memory use when extracting resources. + * Fixed a vulnerability when processing Brotli-compressed archives. + Version 1.0.1 ------------- diff --git a/src/modules/unity/ChangeLog.ru.txt b/src/modules/unity/ChangeLog.ru.txt index 60bef27..b06230a 100644 --- a/src/modules/unity/ChangeLog.ru.txt +++ b/src/modules/unity/ChangeLog.ru.txt @@ -1,3 +1,12 @@ +Версия 2.0.0 +------------- + + * Упрощено извлечение изображений: убрана нарезка атласов на отдельные спрайты и связанные с ней затраты на обработку и память. + * Добавлена поддержка Unity 6.5. + * Исправлены ошибки извлечения крупных текстур. + * Снижен расход памяти при извлечении ресурсов. + * Исправлена уязвимость обработки архивов со сжатием Brotli. + Версия 1.0.1 ------------- diff --git a/src/modules/unity/Directory.Build.targets b/src/modules/unity/Directory.Build.targets index 639bba4..eedb9eb 100644 --- a/src/modules/unity/Directory.Build.targets +++ b/src/modules/unity/Directory.Build.targets @@ -1,4 +1,19 @@ + + + + 10.0.15 + + + + - diff --git a/src/modules/unity/UnityProbe/UnityProjection.Quad.cs b/src/modules/unity/UnityProbe/UnityProjection.Quad.cs deleted file mode 100644 index e1a8da2..0000000 --- a/src/modules/unity/UnityProbe/UnityProjection.Quad.cs +++ /dev/null @@ -1,309 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using AssetsTools.NET; - -namespace Unpackrat.UnityProbe; - -internal sealed partial class UnityProjection -{ - private const int QuadIndexedElements = 32; - - internal sealed partial class State - { - internal QuadPlan? QuadPlan { get; set; } - - private void CompileQuadPlan(AssetTypeTemplateField template) - => QuadPlan = QuadPlan.TryCompile(template, this); - } - - private sealed class QuadStorage - { - private readonly bool[] present; - private readonly int[] order; - private int count; - - internal QuadStorage(QuadPlan plan) - { - Plan = plan; - int slots = plan.SlotCount; - Numbers = new long[slots]; - Texts = new string?[slots]; - Ranges = new ByteRange[slots]; - present = new bool[slots]; - order = new int[slots]; - } - - internal QuadPlan Plan { get; } - internal long[] Numbers { get; } - internal string?[] Texts { get; } - internal ByteRange[] Ranges { get; } - internal bool Active { get; set; } - - internal IEnumerable Keys - { - get { for (int i = 0; i < count; i++) yield return Plan.Keys[order[i]]; } - } - - internal void Clear() - { - Active = false; - Array.Clear(present); - Array.Clear(Numbers); - Array.Clear(Texts); - Array.Clear(Ranges); - count = 0; - } - - private void Mark(int slot) - { - if (present[slot]) return; - present[slot] = true; - order[count++] = slot; - } - - internal void SetNumber(int slot, long value) { Mark(slot); Numbers[slot] = value; } - internal void SetText(int slot, string value) { Mark(slot); Texts[slot] = value; } - internal void SetRange(int slot, ByteRange value) { Mark(slot); Ranges[slot] = value; } - - internal bool TrySlot(string path, ValueKind kind, out int slot) - { - if (Plan.Slots.TryGetValue(path, out slot) && present[slot] && Plan.Kinds[slot] == kind) return true; - slot = 0; - return false; - } - } - - internal sealed class QuadPlan - { - private const int MaxPlanSlots = 8192; - - private readonly struct Binding(int baseSlot, int dataDepth) - { - public readonly int BaseSlot = baseSlot; - public readonly int DataDepth = dataDepth; - } - - private readonly AssetTypeTemplateField root; - - private QuadPlan(AssetTypeTemplateField root, Dictionary bindings, - Dictionary slots, ValueKind[] kinds, string[] keys) - { - this.root = root; - Bindings = bindings; - Slots = slots; - Kinds = kinds; - Keys = keys; - } - - private Dictionary Bindings { get; } - internal Dictionary Slots { get; } - internal ValueKind[] Kinds { get; } - internal string[] Keys { get; } - internal int SlotCount => Keys.Length; - - internal static QuadPlan? TryCompile(AssetTypeTemplateField template, State state) - { - if (state.Paths.Count == 0) return null; - Builder builder = new(state); - foreach (AssetTypeTemplateField field in template.Children) - if (!builder.Walk(field, 1, 0)) return null; - return builder.Build(template); - } - - internal bool Read(UnityProjection target, AssetsFileReader reader, ProjectionStream bounded, - AssetTypeTemplateField template, bool captureRawName) - { - if (captureRawName) return false; - // Bindings belong to this exact root instance. A reused child object under a - // different wrapper must use the mature runtime path rules. - if (!ReferenceEquals(root, template)) return false; - State state = target.state; - QuadStorage store = target.quadStorage!; - ReusableAssetTypeValueIterator iterator = state.Iterator ??= new(template, reader); - iterator.Reset(template, reader); - Array.Clear(state.Names); - Array.Clear(state.Indices); - int steps = 0; - Span textScratch = stackalloc byte[TextScratchLength]; - - while (iterator.ReadNext()) - { - if (++steps > state.VisitBudget) throw new InvalidDataException("Unity projection exceeded its field budget."); - AssetTypeTemplateField field = iterator.TempField; - bool lastField = ReferenceEquals(field, state.LastField); - int depth = iterator.Depth; - if (field.Name == "data" && depth > 0 && state.Names[depth - 1] == "Array") - { - int element = state.Indices[depth - 1] + 1; - if (element > QuadIndexedElements) return false; - state.Indices[depth - 1] = element; - } - // State.Visit rejected every template path at depth >= Names.Length; - // the runtime iterator follows that same template. - state.Names[depth] = field.Name; - state.Indices[depth] = 0; - - bool captured = Bindings.TryGetValue(field, out Binding binding); - if (field.ValueType == AssetValueType.ManagedReferencesRegistry) - throw new InvalidDataException("Managed-reference projection is unsupported."); - if (field.IsArray && field.ValueType != AssetValueType.ByteArray) - { - int count = iterator.CurrentArrayCount; - if (count < 0 || count > state.VisitBudget) throw new InvalidDataException("Invalid Unity array count."); - if (count > QuadIndexedElements) return false; - } - else if (field.ValueType is AssetValueType.String or AssetValueType.ByteArray) - { - long position = reader.Position; - int length = reader.ReadInt32(); - if (length < 0 || length > bounded.Length - reader.Position) - throw new InvalidDataException("Invalid Unity byte range."); - if (captured) - { - int slot = Slot(binding, state); - ValueKind kind = Kinds[slot]; - if (kind == ValueKind.Text) - { - if (length > 1_048_576) throw new InvalidDataException("Unity metadata string is too long."); - store.SetText(slot, ReadText(reader, length, textScratch)); - if (!lastField) reader.Position = position; - } - else - { - // State.Visit publishes String/ByteArray captures as Text or Bytes only. - store.SetRange(slot, new ByteRange(reader.Position, length)); - reader.Position = position; - } - } - else reader.Position = position; - } - else if (captured) - { - int slot = Slot(binding, state); - long position = reader.Position; - long number = field.ValueType switch - { - AssetValueType.Bool => reader.ReadBoolean() ? 1 : 0, - AssetValueType.Int8 => reader.ReadSByte(), - AssetValueType.UInt8 => reader.ReadByte(), - AssetValueType.Int16 => reader.ReadInt16(), - AssetValueType.UInt16 => reader.ReadUInt16(), - AssetValueType.Int32 => reader.ReadInt32(), - AssetValueType.UInt32 => reader.ReadUInt32(), - AssetValueType.Int64 => reader.ReadInt64(), - AssetValueType.UInt64 => unchecked((long)reader.ReadUInt64()), - AssetValueType.Float => reader.ReadUInt32(), - // Captured fields exclude None/Array; ManagedReferencesRegistry is - // rejected above, and String/ByteArray take the branch above. Every - // remaining numeric variant except Double is explicit. - _ => unchecked((long)reader.ReadUInt64()), - }; - reader.Position = position; - // Every non-string scalar capture compiles to Number, Float or Double. - store.SetNumber(slot, number); - } - if (lastField) break; - } - store.Active = true; - return true; - } - - private static int Slot(Binding binding, State state) - { - if (binding.DataDepth == 0) return binding.BaseSlot; - // The array-count guard rejects extra elements before any data child is visited. - return binding.BaseSlot + state.Indices[binding.DataDepth - 1] - 1; - } - - private sealed class Builder(State state) - { - private readonly string[] names = new string[state.Names.Length]; - private readonly List keys = []; - private readonly List kinds = []; - private readonly Dictionary slots = new(StringComparer.Ordinal); - private readonly Dictionary bindings = []; - private readonly Dictionary wanted = []; - - public bool Walk(AssetTypeTemplateField field, int depth, int dataDepth) - { - if (!HasCapture(field)) return true; - // State.Visit rejects depth >= names.Length before TryCompile runs. - names[depth] = field.Name; - if (field.ValueType is not (AssetValueType.None or AssetValueType.Array)) - { - if (!state.Paths.TryGetValue(field, out Capture capture)) return true; - string path = UnityProjection.PathOf(names, depth); - if (path != capture.Path) return false; - if (dataDepth == 0) - { - int slot; - if (slots.TryGetValue(path, out slot)) - { - if (kinds[slot] != capture.Slot.Kind) return false; - } - else - { - if (keys.Count >= MaxPlanSlots) return false; - slot = keys.Count; - Add(path, capture.Slot.Kind); - } - return bindings.TryAdd(field, new Binding(slot, 0)); - } - // A non-zero dataDepth is recorded only for an exact Array/data - // component at that component's depth and is never changed later. - if (keys.Count + QuadIndexedElements > MaxPlanSlots) return false; - string[] parts = path.Split('/'); - // parts.Length == depth proves no name contains '/', so parts align - // with names and parts[dataDepth - 1] is that exact data component. - if (parts.Length != depth) return false; - int baseSlot = keys.Count; - for (int i = 0; i < QuadIndexedElements; i++) - { - parts[dataDepth - 1] = "data#" + i; - string key = string.Join('/', parts); - if (slots.ContainsKey(key)) return false; - Add(key, capture.Slot.Kind); - } - return bindings.TryAdd(field, new Binding(baseSlot, dataDepth)); - } - if (field.IsArray) - { - // State.Visit already indexed Children[1] for every visited array. - if (dataDepth != 0) return false; - AssetTypeTemplateField element = field.Children[1]; - bool indexed = field.Name == "Array" && element.Name == "data"; - return Walk(element, depth + 1, indexed ? depth + 1 : 0); - } - // Mature Quad naming indexes data under any field named Array. A plain - // composite cannot share that rule with an actual indexed array. - if (field.Name == "Array") return false; - foreach (AssetTypeTemplateField child in field.Children) - if (!Walk(child, depth + 1, dataDepth)) return false; - return true; - } - - private void Add(string key, ValueKind kind) - { - slots.Add(key, keys.Count); - keys.Add(key); - kinds.Add(kind); - } - - private bool HasCapture(AssetTypeTemplateField field) - { - if (wanted.TryGetValue(field, out bool known)) return known; - bool result = state.Paths.ContainsKey(field); - if (!result) - foreach (AssetTypeTemplateField child in field.Children) - if (HasCapture(child)) { result = true; break; } - wanted.Add(field, result); - return result; - } - - // TryCompile calls Build only after Walk accepted at least one capture. - public QuadPlan Build(AssetTypeTemplateField root) - => new(root, bindings, slots, kinds.ToArray(), keys.ToArray()); - } - } -} diff --git a/src/modules/unity/UnityProbe/UnityProjection.Route.cs b/src/modules/unity/UnityProbe/UnityProjection.Route.cs index c478450..d36a2be 100644 --- a/src/modules/unity/UnityProbe/UnityProjection.Route.cs +++ b/src/modules/unity/UnityProbe/UnityProjection.Route.cs @@ -21,7 +21,7 @@ private readonly record struct Step(StepKind Kind, int Length, Scalar[] Scalars, public bool Matches(AssetTypeTemplateField candidate) => ReferenceEquals(template, candidate); public static SchemaRoute? TryCompile(AssetTypeTemplateField template, State state) { - if (state.LastField is null || state.DynamicPaths || state.DictionaryStorage) return null; + if (state.LastField is null || state.DictionaryStorage) return null; if (!WithinFieldBudget(template)) return null; Builder builder = new(state); foreach (AssetTypeTemplateField field in template.Children) builder.Field(field); diff --git a/src/modules/unity/UnityProbe/UnityProjection.cs b/src/modules/unity/UnityProbe/UnityProjection.cs index f55c49e..ffc9dc9 100644 --- a/src/modules/unity/UnityProbe/UnityProjection.cs +++ b/src/modules/unity/UnityProbe/UnityProjection.cs @@ -16,17 +16,11 @@ internal sealed partial class UnityProjection private const int TextScratchLength = 256; // The mature iterator's visit ceiling; shared plans must not bypass it. private const int FieldBudget = 16_000_000; - private const int QuadFieldBudget = 512; internal readonly record struct ByteRange(long Offset, int Length); internal enum ValueKind { Number, Text, Bytes, Float, Double } - internal enum Profile { Catalogue, Quad } internal readonly record struct Slot(ValueKind Kind, int Index); internal readonly record struct Capture(string Path, Slot Slot); private readonly State state; - private readonly QuadStorage? quadStorage; - // Getters observe only the storage that actually produced this projection; a failed or - // bypassed fastpath leaves the mature values authoritative. - private QuadStorage? quad => quadStorage is { Active: true } ? quadStorage : null; private readonly Dictionary? values; private readonly long[] numbers; private readonly string[] texts; @@ -36,10 +30,9 @@ internal enum Profile { Catalogue, Quad } private UnityProjection(State state) { this.state = state; - if (state.QuadPlan is { } plan) quadStorage = new QuadStorage(plan); if (state.DictionaryStorage) { - values = new(state.DynamicPaths ? 0 : state.Paths.Count, StringComparer.Ordinal); + values = new(state.Paths.Count, StringComparer.Ordinal); numbers = []; texts = []; ranges = []; } else @@ -49,31 +42,16 @@ private UnityProjection(State state) ranges = state.RangeCount == 0 ? [] : new ByteRange[state.RangeCount]; } } - public IEnumerable Keys => quad is not null ? quad.Keys : values is null ? state.Slots.Keys : values.Keys; - public string Text(string path) => quad is not null - // TrySlot(Text) succeeds only after SetText stored a non-null value; Clear removes both. - ? quad.TrySlot(path, ValueKind.Text, out int quadText) ? quad.Texts[quadText]! : string.Empty - : values is not null + public IEnumerable Keys => values is null ? state.Slots.Keys : values.Keys; + public string Text(string path) => values is not null ? values.TryGetValue(path, out object? value) && value is string text ? text : string.Empty : TrySlot(path, ValueKind.Text, out int index) ? texts[index] : string.Empty; - public long Number(string path) => quad is not null - ? quad.TrySlot(path, ValueKind.Number, out int quadNumber) ? quad.Numbers[quadNumber] : 0 - : values is not null + public long Number(string path) => values is not null ? values.TryGetValue(path, out object? value) && value is long number ? number : 0 : TrySlot(path, ValueKind.Number, out int index) ? numbers[index] : 0; public ulong Unsigned(string path) => unchecked((ulong)Number(path)); public bool TryNumber(string path, out long number) { - if (quad is not null) - { - if (quad.TrySlot(path, ValueKind.Number, out int slot)) - { - number = quad.Numbers[slot]; - return true; - } - number = 0; - return false; - } if (values is not null) { if (values.TryGetValue(path, out object? value) && value is long stored) @@ -94,16 +72,6 @@ public bool TryNumber(string path, out long number) } public bool TryBits(string path, out long bits) { - if (quad is not null) - { - if (quad.TrySlot(path, ValueKind.Float, out int quadSlot) || quad.TrySlot(path, ValueKind.Double, out quadSlot)) - { - bits = quad.Numbers[quadSlot]; - return true; - } - bits = 0; - return false; - } if (values is not null) { if (values.TryGetValue(path, out object? value)) @@ -133,16 +101,6 @@ public bool TryBits(string path, out long bits) } public bool TryFloat32(string path, out uint bits) { - if (quad is not null) - { - if (quad.TrySlot(path, ValueKind.Float, out int quadSlot)) - { - bits = unchecked((uint)quad.Numbers[quadSlot]); - return true; - } - bits = 0; - return false; - } if (values is not null) { if (values.TryGetValue(path, out object? value) && value is uint stored) @@ -171,9 +129,7 @@ public bool TryUnsigned(string path, out ulong value) value = 0; return false; } - public ByteRange Bytes(string path) => quad is not null - ? quad.TrySlot(path, ValueKind.Bytes, out int quadRange) ? quad.Ranges[quadRange] : default - : values is not null + public ByteRange Bytes(string path) => values is not null ? values.TryGetValue(path, out object? value) && value is ByteRange range ? range : default : TrySlot(path, ValueKind.Bytes, out int index) ? ranges[index] : default; private bool TrySlot(string path, ValueKind kind, out int index) @@ -186,28 +142,28 @@ private bool TrySlot(string path, ValueKind kind, out int index) index = 0; return false; } - private void SetText(Capture capture, string? dynamicPath, string value) + private void SetText(Capture capture, string value) { - if (values is not null) values[dynamicPath ?? capture.Path] = value; + if (values is not null) values[capture.Path] = value; else texts[capture.Slot.Index] = value; } - private void SetNumber(Capture capture, string? dynamicPath, long value) + private void SetNumber(Capture capture, long value) { - if (values is not null) values[dynamicPath ?? capture.Path] = value; + if (values is not null) values[capture.Path] = value; else numbers[capture.Slot.Index] = value; } - private void SetBits(Capture capture, string? dynamicPath, long value, ValueKind kind) + private void SetBits(Capture capture, long value, ValueKind kind) { if (values is not null) { - if (kind == ValueKind.Float) values[dynamicPath ?? capture.Path] = unchecked((uint)value); - else values[dynamicPath ?? capture.Path] = unchecked((ulong)value); + if (kind == ValueKind.Float) values[capture.Path] = unchecked((uint)value); + else values[capture.Path] = unchecked((ulong)value); } else numbers[capture.Slot.Index] = value; } - private void SetBytes(Capture capture, string? dynamicPath, ByteRange value) + private void SetBytes(Capture capture, ByteRange value) { - if (values is not null) values[dynamicPath ?? capture.Path] = value; + if (values is not null) values[capture.Path] = value; else ranges[capture.Slot.Index] = value; } @@ -262,7 +218,7 @@ internal static UnityProjection ReadBorrowed(Stream input, long offset, long len } internal void ClearScratch() { - rawName.Clear(); quadStorage?.Clear(); values?.Clear(); Array.Clear(numbers); Array.Clear(texts); Array.Clear(ranges); + rawName.Clear(); values?.Clear(); Array.Clear(numbers); Array.Clear(texts); Array.Clear(ranges); } private static UnityProjection Read(AssetTypeTemplateField template, State state, bool validateLayout, bool textureTail, bool reuse = false, bool captureRawName = false) @@ -275,15 +231,7 @@ private static UnityProjection Read(AssetTypeTemplateField template, State state if (reuse) { result.ClearScratch(); - if (!validateLayout && state.QuadPlan is { } quadPlan) - { - if (quadPlan.Read(result, reader, bounded, template, captureRawName)) return result; - // Unrepresentable dynamic layouts restart under the mature iterator so budget, - // cursor and capture semantics remain authoritative. - reader.Position = 0; - result.ClearScratch(); - } - if (!validateLayout && !state.DynamicPaths && !state.DictionaryStorage + if (!validateLayout && !state.DictionaryStorage && state.Route is { } route && route.Matches(template)) { if (route.Read(result, reader, bounded, captureRawName)) return result; @@ -295,8 +243,6 @@ private static UnityProjection Read(AssetTypeTemplateField template, State state } ReusableAssetTypeValueIterator iterator = state.Iterator ??= new(template, reader); iterator.Reset(template, reader); - string[] names = state.Names; - int[] indices = state.Indices; int steps = 0; // One bounded scratch span per object; field count must never grow stack usage. Span textScratch = stackalloc byte[TextScratchLength]; @@ -307,18 +253,6 @@ private static UnityProjection Read(AssetTypeTemplateField template, State state AssetTypeTemplateField field = iterator.TempField; bool lastField = !validateLayout && ReferenceEquals(field, state.LastField); bool capture = state.Paths.TryGetValue(field, out Capture binding); - string? dynamicPath = null; - // Fixed schemas already bind capture paths; only atlases need runtime path bookkeeping. - if (state.DynamicPaths) - { - int depth = iterator.Depth; - string name = field.Name; - if (name == "data" && names[depth - 1] == "Array" - && (state.Quad || names[1] == "m_RenderDataMap")) name += "#" + indices[depth - 1]++; - names[depth] = name; - indices[depth] = 0; - if (capture) dynamicPath = PathOf(names, depth); - } if (field.ValueType == AssetValueType.ManagedReferencesRegistry) throw new InvalidDataException("Managed-reference projection is unsupported."); if (field.IsArray && field.ValueType != AssetValueType.ByteArray) @@ -340,13 +274,13 @@ private static UnityProjection Read(AssetTypeTemplateField template, State state int read = reader.BaseStream.ReadAtLeast(name, length, throwOnEndOfStream: false); result.rawName.Advance(read); } - else result.SetText(binding, dynamicPath, ReadText(reader, length, textScratch)); + else result.SetText(binding, ReadText(reader, length, textScratch)); // Continuing traversal leaves advancement and alignment to the iterator. if (!lastField) reader.Position = position; } else { - if (capture) result.SetBytes(binding, dynamicPath, new ByteRange(reader.Position, length)); + if (capture) result.SetBytes(binding, new ByteRange(reader.Position, length)); reader.Position = position; } } @@ -372,8 +306,8 @@ private static UnityProjection Read(AssetTypeTemplateField template, State state // still advances and aligns every scalar when ReadNext is called. reader.Position = position; if (binding.Slot.Kind is ValueKind.Float or ValueKind.Double) - result.SetBits(binding, dynamicPath, number, binding.Slot.Kind); - else result.SetNumber(binding, dynamicPath, number); + result.SetBits(binding, number, binding.Slot.Kind); + else result.SetNumber(binding, number); } if (lastField) break; } @@ -411,16 +345,13 @@ private static string ReadText(AssetsFileReader reader, int length, Span s internal sealed partial class State { public string[] Names { get; } = new string[130]; - public int[] Indices { get; } = new int[130]; public Dictionary Paths { get; } = []; // The reader owns only managed scratch; its stream borrows an input for one Read. public AssetsFileReader Reader { get; } = new(new ProjectionStream(Stream.Null)); public UnityProjection? Scratch { get; set; } public ReusableAssetTypeValueIterator? Iterator { get; set; } public AssetTypeTemplateField? LastField { get; private set; } - public bool DynamicPaths { get; } - public bool Quad { get; } - public int VisitBudget { get; } + public int VisitBudget => FieldBudget; public Dictionary Slots { get; } = new(StringComparer.Ordinal); public int NumberCount { get; private set; } public int TextCount { get; private set; } @@ -428,15 +359,10 @@ internal sealed partial class State public bool DictionaryStorage { get; private set; } private readonly SchemaRoute? route; internal SchemaRoute? Route => route; - public State(AssetTypeTemplateField template, Profile profile = Profile.Catalogue) + public State(AssetTypeTemplateField template) { - Quad = profile == Profile.Quad; - DynamicPaths = Quad || template.Type == "SpriteAtlas"; - DictionaryStorage = DynamicPaths; - VisitBudget = Quad ? QuadFieldBudget : FieldBudget; foreach (AssetTypeTemplateField field in template.Children) Visit(field, 1, false); route = SchemaRoute.TryCompile(template, this); - if (Quad) CompileQuadPlan(template); } private void Visit(AssetTypeTemplateField field, int depth, bool repeated) { @@ -445,7 +371,7 @@ private void Visit(AssetTypeTemplateField field, int depth, bool repeated) bool scalar = field.ValueType is not (AssetValueType.None or AssetValueType.Array); if (scalar) { - if (Wanted(Names, depth) || (Quad && WantedQuad(Names))) + if (Wanted(Names, depth)) { string path = PathOf(Names, depth); ValueKind kind = field.ValueType == AssetValueType.String && path != "m_Script" ? ValueKind.Text @@ -488,30 +414,9 @@ private static bool Wanted(string[] names, int depth) string root = names[1]; return root is "m_Name" or "m_Width" or "m_Height" or "image data" or "m_StreamData" or "m_Script" or "m_FontData" or "m_MovieData" or "m_OriginalPath" or "m_ExternalResources" - or "m_AudioData" or "m_Type" or "m_Resource" or "m_IndexBuffer" or "m_SpriteAtlas" or "m_RenderDataKey" - or "m_Rect" or "m_Offset" + or "m_AudioData" or "m_Type" or "m_Resource" or "m_IndexBuffer" || root == "m_VertexData" && names[2] is "m_VertexCount" or "m_DataSize" - || root == "m_CompressedMesh" && names[2] == "m_Vertices" && names[depth] == "m_NumItems" - || root == "m_RD" && names[2] is "texture" or "textureRect" or "textureRectOffset" or "downscaleMultiplier" - or "settingsRaw" or "packed" or "packingMode" or "packingRotation" - // The catalogue pass already crosses m_RD. Capture only this exact leaf, not - // allVertexData or a shallow m_RD/m_VertexCount. - || root == "m_RD" && depth == 3 && names[2] == "m_VertexData" && names[3] == "m_VertexCount" - || root == "m_RenderDataMap" && ( - depth < 5 - || names[4] == "first" - || names[5] == "texture" - || names[4] == "second" && (names[5] is "textureRect" or "textureRectOffset" - or "downscaleMultiplier" or "settingsRaw" or "packed" or "packingMode" or "packingRotation")); - } - private static bool WantedQuad(string[] names) - { - string root = names[1]; - if (root is "m_PixelsToUnits" or "m_Pivot") return true; - if (root != "m_RD") return false; - if (names[2] is "m_SubMeshes" or "m_IndexBuffer") return true; - return names[2] == "m_VertexData" - && names[3] is "m_VertexCount" or "m_DataSize" or "m_Channels"; + || root == "m_CompressedMesh" && names[2] == "m_Vertices" && names[depth] == "m_NumItems"; } // MemoryStream permits out-of-bounds seeks. The parser sees a strict object-sized view instead. diff --git a/src/modules/unity/UnityProbe/UnityResourceDefinitionFactory.cs b/src/modules/unity/UnityProbe/UnityResourceDefinitionFactory.cs index 675529c..d74d696 100644 --- a/src/modules/unity/UnityProbe/UnityResourceDefinitionFactory.cs +++ b/src/modules/unity/UnityProbe/UnityResourceDefinitionFactory.cs @@ -15,8 +15,6 @@ using RuleSetFileAsset = AssetRipper.SourceGenerated.Classes.ClassID_954905827.RuleSetFileAsset; using Shader = AssetRipper.SourceGenerated.Classes.ClassID_48.Shader; using ShaderInclude = AssetRipper.SourceGenerated.Classes.ClassID_109.ShaderInclude; -using Sprite = AssetRipper.SourceGenerated.Classes.ClassID_213.Sprite; -using SpriteAtlas = AssetRipper.SourceGenerated.Classes.ClassID_687078895.SpriteAtlas; using TextAsset = AssetRipper.SourceGenerated.Classes.ClassID_49.TextAsset; using Texture2D = AssetRipper.SourceGenerated.Classes.ClassID_28.Texture2D; using VideoClip = AssetRipper.SourceGenerated.Classes.ClassID_329.VideoClip; @@ -40,8 +38,6 @@ internal static class UnityResourceDefinitionFactory 152 => MovieTexture.Create(info, version), 83 => AudioClip.Create(info, version), 43 => Mesh.Create(info, version), - 213 => Sprite.Create(info, version), - 687078895 => SpriteAtlas.Create(info, version), 89 => Cubemap.Create(info, version), 115 => MonoScript.Create(info, version), 954905827 => RuleSetFileAsset.Create(info), diff --git a/src/modules/unity/UnityProbe/UnitySession.cs b/src/modules/unity/UnityProbe/UnitySession.cs index 6164e87..bc88737 100644 --- a/src/modules/unity/UnityProbe/UnitySession.cs +++ b/src/modules/unity/UnityProbe/UnitySession.cs @@ -89,34 +89,30 @@ internal void CopyName(int index, Span destination) public bool Export(int index, Stream destination) { ArgumentNullException.ThrowIfNull(destination); + byte[]? payload = ExportData(index); + if (payload is null) return false; + destination.Write(payload, 0, payload.Length); + return true; + } + + /// Returns one materialised payload for immediate delivery without staging another copy. + internal byte[]? ExportData(int index) + { if (disposed || index < 0 || index >= entries.Count) { - return false; + return null; } - if (graph is ICompactUnityGraph compact) - { - byte[]? data = compact.Export(index); - if (data is null) return false; - destination.Write(data); - return true; - } + if (graph is ICompactUnityGraph compact) return compact.Export(index); IProbeAsset asset = exportTargets[index]; // Describe admits only these interfaces. Preserve its precedence for overlapping views; // the remaining accepted target is a resource, with no per-entry delegate or metadata copy. - byte[]? payload = asset switch + return asset switch { IProbeTexture texture => texture.RenderPng(), IProbeText text => text.ReadData(), _ => ((IProbeResource)asset).ExportData(), }; - if (payload is null) - { - return false; - } - - destination.Write(payload, 0, payload.Length); - return true; } public void Dispose() diff --git a/src/modules/unity/UnityProbe/UnitySessionAbi.cs b/src/modules/unity/UnityProbe/UnitySessionAbi.cs index 9ff88f4..3945ad6 100644 --- a/src/modules/unity/UnityProbe/UnitySessionAbi.cs +++ b/src/modules/unity/UnityProbe/UnitySessionAbi.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Threading; @@ -641,17 +640,14 @@ private static int ExportCore(UnitySession session, int index, delegate* unmanag return UnityAbi.OutOfRange; } - // The session already materialises the payload; the sink only lets the callback boundary be - // honoured without a second copy of the bytes. - using MemoryStream sink = new(); - if (!session.Export(index, sink)) + byte[]? payload = session.ExportData(index); + if (payload is null) { return UnityAbi.DataFailure; } - byte[] payload = sink.GetBuffer(); - int payloadLength = (int)sink.Length; - fixed (byte* pointer = payload) + int payloadLength = payload.Length; + fixed (byte* pointer = payload.AsSpan()) { for (int offset = 0; offset < payloadLength; offset += MaxChunkSize) { diff --git a/src/modules/unity/UnityProbe/UnitySource.Compact.cs b/src/modules/unity/UnityProbe/UnitySource.Compact.cs index a26c476..f90b89d 100644 --- a/src/modules/unity/UnityProbe/UnitySource.Compact.cs +++ b/src/modules/unity/UnityProbe/UnitySource.Compact.cs @@ -36,7 +36,7 @@ private void PrepareCompactTable(AssetCollection collection, SourceTable objects { ValidateObjectTable(objects); if (!compactTable) return; - int resources = 0, sprites = 0; + int resources = 0; for (int i = 0; i < objects.Count; i++) { ObjectInfo descriptor = objects[i]; @@ -44,10 +44,10 @@ private void PrepareCompactTable(AssetCollection collection, SourceTable objects int classID = descriptor.TypeID < 0 ? 114 : descriptor.TypeID; if (classID is 142 or 147) continue; Shape shape = GetShape(new(collection, descriptor.FileID, classID), collection.Version); - if (shape.Kind is not { } kind) continue; - resources++; if (kind == UnityEntryKind.Sprite) sprites++; + if (shape.Kind is null) continue; + resources++; } - if (resources > 0) compactCollections.Add(collection, new(this, collection, resources, sprites)); + if (resources > 0) compactCollections.Add(collection, new(this, collection, resources)); } public override void CompleteCollection(AssetCollection collection) { @@ -60,18 +60,6 @@ public IUnityGraph BuildCompactGraph(GameBundle root, FileBase selected, IReadOnlyList selectedCollections, IReadOnlyList owned, IReadOnlyList roots) => new CompactGraph(this, root, selected, selectedCollections, owned, roots); - private void ResolveCompactCollections(IReadOnlyList selected) - { - foreach (CompactCollection rows in compactCollections.Values) rows.ResolveSpriteReferences(); - HashSet seen = []; - HashSet selectedSet = new(selected, ReferenceEqualityComparer.Instance); - foreach (SerializedAssetCollection collection in selected) - { - if (compactCollections.TryGetValue(collection, out CompactCollection? rows)) - rows.SuppressStructuralDuplicates(seen, selectedSet); - } - foreach (CompactCollection rows in compactCollections.Values) rows.ReleaseSpriteScratch(); - } private struct PayloadRow(long path, long offset, uint length, int prototype) { @@ -82,38 +70,21 @@ private struct PayloadRow(long path, long offset, uint length, int prototype) } private struct Header(int nameOffset, int nameLength, long size, string extension, bool visible) { - private const int VisibilityBit = int.MinValue; - private const int ExportableBit = 1 << 30; - private const int NameLengthMask = ExportableBit - 1; public int NameOffset = nameOffset; - // Projection bounds raw names to 1,048,576 bytes, leaving the two high bits - // free: the sign bit carries visibility and bit 30 carries exportability. - private int flags = nameLength | (visible ? VisibilityBit | ExportableBit : 0); - public readonly int NameLength => flags & NameLengthMask; + // Names are bounded below int.MaxValue; retain visibility in the spare sign bit + // so the immutable header remains 24 bytes instead of padding a separate bool. + private readonly int nameLengthAndVisibility = nameLength | (visible ? int.MinValue : 0); + public readonly int NameLength => nameLengthAndVisibility & int.MaxValue; public long Size = size; public string Extension = extension; - public readonly bool Exportable => (flags & ExportableBit) != 0; - public readonly bool Visible => (flags & VisibilityBit) != 0; - public void Reveal() { flags |= VisibilityBit | ExportableBit; } - public void Hide() => flags &= ~VisibilityBit; + public readonly bool Visible => nameLengthAndVisibility < 0; } private sealed record Prototype(ObjectInfo Data, Shape Shape, IReadOnlyList References, int ClassID); - private readonly record struct SpriteLink(int Row, int File, long Path, int AtlasKeyIndex) - { - // Occupies the record's existing padding. The four-argument positional constructor - // and the 24-byte layout remain unchanged. - public bool QuadVertexCountFour { get; init; } - } - - private sealed class CompactCollection(LazyAssetFactory factory, AssetCollection collection, int resourceCount, int spriteCount) : IAssetResolver + private sealed class CompactCollection(LazyAssetFactory factory, AssetCollection collection, int resourceCount) : IAssetResolver { private readonly List prototypes = []; private readonly Dictionary<(SerializedType?, Shape, int), int> prototypeIndices = []; - private SpriteLink[] links = new SpriteLink[spriteCount]; - private SpriteFacts[]? spriteFacts = spriteCount == 0 ? null : new SpriteFacts[spriteCount]; - private (int Width, int Height)[]? textureSizes; - private (ulong, ulong, ulong, ulong, long)[]? atlasKeys; - private int nextRow, nextLink; + private int nextRow; private readonly List<(int Row, string Source, ulong Offset, ulong Size)> audioPrefixes = []; private readonly Dictionary identities = []; private readonly Dictionary paths = []; @@ -163,49 +134,14 @@ public void Add(AssetRipper.Assets.Metadata.AssetInfo info, in ObjectInfo data, } else length = checked((uint)data.DataSize); rows[index] = new(info.PathID, data.DataOffset, length, prototype); - bool sprite = shape.Kind == UnityEntryKind.Sprite; - var metadata = fields is null || sprite ? (Visible: false, Size: 0L, Extension: string.Empty) : DescribeHeader(collection, data, shape, fields, resolveResources: false); + var metadata = fields is null ? (Visible: false, Size: 0L, Extension: string.Empty) : DescribeHeader(collection, data, shape, fields, resolveResources: false); int nameOffset = factory.names.WrittenCount; ReadOnlySpan name = fields is null ? [] : fields.RawName; factory.names.Write(name); headers[index] = new(nameOffset, name.Length, metadata.Size, metadata.Extension, metadata.Visible); - if (shape.Kind == UnityEntryKind.Texture) - { - textureSizes ??= new (int, int)[rows.Length]; - textureSizes[index] = fields is null - ? default - : (unchecked((int)fields.Number("m_Width")), unchecked((int)fields.Number("m_Height"))); - } if (metadata.Visible && shape.Kind == UnityEntryKind.AudioClip && !shape.Audio!.Has_Type()) audioPrefixes.Add((index, fields!.Text("m_Resource/m_Source"), fields.Unsigned("m_Resource/m_Offset"), fields.Unsigned("m_Resource/m_Size"))); - if (sprite && fields is not null) - { - bool atlas = fields.Number("m_SpriteAtlas/m_PathID") != 0; - int file = (int)fields.Number(atlas ? "m_SpriteAtlas/m_FileID" : "m_RD/texture/m_FileID"); - long path = fields.Number(atlas ? "m_SpriteAtlas/m_PathID" : "m_RD/texture/m_PathID"); - int atlasKeyIndex = -1; - if (atlas) - { - // Bound optional key storage by the known Sprite count; mixed/all-atlas - // tables never incur geometric growth or lose any GUID bits. - atlasKeys ??= new (ulong, ulong, ulong, ulong, long)[links.Length]; - atlasKeyIndex = nextLink; atlasKeys[atlasKeyIndex] = Key(fields, "m_RenderDataKey"); - } - spriteFacts![nextLink] = new SpriteFacts - { - Canvas = SpriteFactsReader.ReadCanvas(fields), - Source = atlas ? default : SpriteFactsReader.ReadLocalSource(fields), - }; - // Compare the stored wide value directly. Narrowing to int would let - // unrelated integer values masquerade as four. - bool quadVertexCountFour = - fields.TryNumber("m_RD/m_VertexData/m_VertexCount", out long vertexCount) - && vertexCount == 4L; - links[nextLink++] = new(index, file, path, atlasKeyIndex) - { - QuadVertexCountFour = quadVertexCountFour, - }; - } + } finally { fields?.ClearScratch(); } } @@ -233,21 +169,9 @@ private ObjectInfo Descriptor(int index) return prototypes[row.Prototype].Data.WithDataRange(row.Path, row.Offset, wideLengths is { } lengths && lengths[index] != 0 ? lengths[index] : row.Length); } - // ResolveSpriteReferences records Resolved only after TryTextureInfo accepted this exact - // exportable texture header; suppression only clears Visible, so the row stays present. - private void HideTexture(long path) => headers[Find(path)].Hide(); - public bool TryTextureInfo(long path, out long size, out int width, out int height) - { - size = 0; width = 0; height = 0; int index = Find(path); - if (index < 0 || !headers[index].Exportable || prototypes[rows[index].Prototype].Shape.Kind != UnityEntryKind.Texture) return false; - size = headers[index].Size; - if (textureSizes is not null) (width, height) = textureSizes[index]; - return true; - } - public void ResolveSpriteReferences() + public void ResolveAudioExtensions() { - // Nested collections acquire their parent bundle only after parsing completes. - // Resolve resource-dependent labels after that ownership graph is attached. + // Resource streams become resolvable once nested collections have their parent bundle. foreach (var audio in audioPrefixes) { var file = collection.Bundle.ResolveResource(audio.Source); @@ -255,125 +179,6 @@ public void ResolveSpriteReferences() headers[audio.Row].Extension = AudioPayload.DescribeModern(prefix, headers[audio.Row].Size, audio.Size).Extension; } audioPrefixes.Clear(); audioPrefixes.TrimExcess(); - for (int linkIndex = 0; linkIndex < nextLink; linkIndex++) - { - SpriteLink link = links[linkIndex]; - if (link.File < 0 || link.File >= collection.Dependencies.Count || collection.Dependencies[link.File] is not { } target) continue; - AssetCollection textureCollection; long texturePath; - bool atlasLink = link.AtlasKeyIndex >= 0; - if (atlasLink) - { - if (target.TryGetAsset(link.Path) is not DeferredObject atlas || !atlas.Shape.Atlas || atlas.Projection is not { } fields) continue; - atlas.AtlasTextures ??= AtlasIndex(fields); - if (!atlas.AtlasTextures.TryGetValue(atlasKeys![link.AtlasKeyIndex], out AtlasEntry pointer) - || pointer.FileId < 0 || pointer.FileId >= target.Dependencies.Count - || target.Dependencies[pointer.FileId] is not { } foundCollection) continue; - textureCollection = foundCollection; texturePath = pointer.PathId; - spriteFacts![linkIndex].Source = pointer.Geometry; - } - else - { - textureCollection = target; texturePath = link.Path; - } - if (!factory.TryTextureInfo(textureCollection, texturePath, - out long textureSize, out int textureWidth, out int textureHeight)) continue; - SpriteFacts resolved = spriteFacts![linkIndex]; - resolved.SourceCollection = textureCollection; resolved.SourcePath = texturePath; - resolved.SourceWidth = textureWidth; resolved.SourceHeight = textureHeight; resolved.Resolved = true; - spriteFacts[linkIndex] = resolved; - headers[link.Row].Reveal(); headers[link.Row].Extension = "png"; - headers[link.Row].Size = atlasLink ? 0 : textureSize; - } - } - public void SuppressStructuralDuplicates(HashSet seen, HashSet selected) - { - if (spriteFacts is null) return; - for (int index = 0; index < nextLink; index++) - { - SpriteFacts facts = spriteFacts[index]; - if (TryCanonicalizeFullImage(index, facts, selected, seen)) continue; - if (facts.Source.SettingsRaw == 64u) continue; - if (!facts.Comparable) continue; - if (!seen.Add(facts.Key)) - { - headers[links[index].Row].Hide(); - continue; - } - HideFullTextureCounterpart(facts, selected); - } - } - private void HideFullTextureCounterpart(SpriteFacts facts, HashSet selected) - { - // SuppressStructuralDuplicates calls this only for a resolved, comparable Sprite. - AssetCollection source = facts.SourceCollection!; - if (!selected.Contains(source)) return; - // A mixed Eager/metadata-only factory resolves an eager source without a compact owner. - if (!factory.compactCollections.TryGetValue(source, out CompactCollection? owner)) return; - if (!facts.IsFullTexture(facts.SourceWidth, facts.SourceHeight)) return; - owner!.HideTexture(facts.SourcePath); - } - private bool TryCanonicalizeFullImage(int linkIndex, SpriteFacts facts, HashSet selected, - HashSet seen) - { - if (!facts.Resolved || !facts.Source.Present || !facts.Canvas.Present) return false; - if (facts.Source.SettingsRaw is not (2u or 64u) - || !SpriteFactsReader.IsUnitDownscale(facts.Source.Downscale)) return false; - // Resolved facts carry the actual source identity chosen above, and only a selected - // collection may have its texture hidden by this projection. - AssetCollection source = facts.SourceCollection!; - if (!selected.Contains(source)) return false; - // A mixed Eager/metadata-only factory resolves an eager source without a compact owner. - if (!factory.compactCollections.TryGetValue(source, out CompactCollection? owner)) return false; - int width = facts.SourceWidth; - int height = facts.SourceHeight; - bool tight = facts.Source.SettingsRaw == 64u; - SpriteFacts normalized = facts; - normalized.Source = facts.Source with { SettingsRaw = 2u, Downscale = SpriteFactsReader.Bits(1f) }; - if (!normalized.IsFullTexture(width, height)) return false; - if (tight) - { - // Missing, floating-point or non-four metadata cannot pass SpriteQuadProof. - // Refuse before Shape.QuadTraversal or the second object read is created. - if (!links[linkIndex].QuadVertexCountFour) return false; - // SpriteQuadProof enforces the tighter 8,388,608-pixel proof bound. - if (!TryProveQuad(links[linkIndex].Row, width, height)) return false; - } - owner!.HideTexture(facts.SourcePath); - if (seen.Add(normalized.Key)) return true; - headers[links[linkIndex].Row].Hide(); - return true; - } - private bool TryProveQuad(int sprite, int width, int height) - { - PayloadRow row = rows[sprite]; - Prototype prototype = prototypes[row.Prototype]; - Shape shape = prototype.Shape; - UnityProjection.State traversal = shape.QuadTraversal!; - try - { - // Add records a Sprite only after projecting it with a template, and a compact table - // admits only borrowed descriptors; IsFullTexture already proved both dimensions - // positive before this tight-mesh check runs. - ObjectInfo data = Descriptor(sprite); - Stream source = data.BorrowedSource!; - UnityProjection fields = UnityProjection.ReadBorrowed(source, data.DataOffset, data.DataSize, - shape.Template!, collection.EndianType == EndianType.BigEndian, traversal); - return SpriteQuadProof.TryProve(fields, source, data.DataOffset, collection.Version, - collection.EndianType, width, height); - } - catch (Exception error) when (error is InvalidDataException or IOException or ArgumentException - or OverflowException or NotSupportedException) - { - return false; - } - finally - { - traversal.Scratch?.ClearScratch(); - } - } - public void ReleaseSpriteScratch() - { - links = []; atlasKeys = null; spriteFacts = null; textureSizes = null; } public void EndOperation(bool capturePaths) { @@ -513,7 +318,7 @@ public CompactGraph(LazyAssetFactory factory, GameBundle root, FileBase selected { this.root = root; this.selected = selected; this.owned = owned; this.roots = roots; this.factory = factory; selectedCollections = collections; Collections = collections.Select(collection => (IUnityGraphCollection)new CollectionView(collection)).ToArray(); - factory.ResolveCompactCollections(collections); + foreach (CompactCollection rows in factory.compactCollections.Values) rows.ResolveAudioExtensions(); factory.EndCompactOperation(true); IReadOnlyList[] legacy = collections.Select(factory.Describe).ToArray(); List found = new(collections.Count); diff --git a/src/modules/unity/UnityProbe/UnitySource.Lazy.cs b/src/modules/unity/UnityProbe/UnitySource.Lazy.cs index d14a920..7289f89 100644 --- a/src/modules/unity/UnityProbe/UnitySource.Lazy.cs +++ b/src/modules/unity/UnityProbe/UnitySource.Lazy.cs @@ -24,8 +24,6 @@ using AssetRipper.SourceGenerated.Classes.ClassID_329; using AssetRipper.SourceGenerated.Classes.ClassID_83; using AssetRipper.SourceGenerated.Classes.ClassID_43; -using AssetRipper.SourceGenerated.Classes.ClassID_213; -using AssetRipper.SourceGenerated.Classes.ClassID_687078895; using AssetRipper.SourceGenerated.Extensions; using AssetsTools.NET; @@ -33,11 +31,9 @@ namespace Unpackrat.UnityProbe; internal static partial class UnitySource { - // Retained resource/atlas identities remain ordinary Unity objects for upstream PPtr and + // Retained resource identities remain ordinary Unity objects for upstream PPtr and // original-path processing. Unsupported shapes are skipped after complete source ID validation. - private readonly record struct AtlasEntry(int FileId, long PathId, SpriteSourceFacts Geometry); - private sealed class DeferredObject(AssetInfo info, ObjectInfo data, IReadOnlyList references, Shape shape, LazyAssetFactory owner) : UnityObjectBase(info), IObject, INamed, IProbeResource { private long sourceSize; @@ -53,7 +49,6 @@ private sealed class DeferredObject(AssetInfo info, ObjectInfo data, IReadOnlyLi public IReadOnlyList References { get; } = references; public Shape Shape { get; } = shape; public UnityProjection? Projection { get; set; } - public Dictionary<(ulong, ulong, ulong, ulong, long), AtlasEntry>? AtlasTextures { get; set; } UnityEntryKind IProbeResource.Kind => Shape.Kind.GetValueOrDefault(); long IProbeResource.PathId => PathID; string IProbeResource.Name => name; @@ -68,11 +63,9 @@ public void CaptureResourceMetadata(long size, string outputExtension) } } - private sealed record Shape(UnityEntryKind? Kind, bool Atlas, AssetTypeTemplateField? Template, IAudioClip? Audio) + private sealed record Shape(UnityEntryKind? Kind, AssetTypeTemplateField? Template, IAudioClip? Audio) { - private UnityProjection.State? quadTraversal; public UnityProjection.State? Traversal { get; } = Template is null ? null : new(Template); - public UnityProjection.State? QuadTraversal => Template is null ? null : quadTraversal ??= new(Template!, UnityProjection.Profile.Quad); } internal sealed partial class LazyAssetFactory(AssetFactoryBase fullReader, bool compact = false) : AssetFactoryBase @@ -117,7 +110,7 @@ private void ValidateObjectTable(SourceTable objects) { if (info.ClassID is 142 or 147) return fullReader.ReadAsset(info, descriptor.ObjectData, descriptor.Type, references); Shape shape = GetShape(info, info.Collection.Version); - if (shape.Kind is null && !shape.Atlas) return null; + if (shape.Kind is null) return null; bool patch = info.Collection.Version.Type == UnityVersionType.Patch && !SpecialFileNames.IsDefaultResourceOrBuiltinExtra(info.Collection.Name); if (compactTable && shape.Kind is not null) @@ -174,13 +167,12 @@ private static Shape BuildShape(AssetInfo info, IUnityObjectBase? definition, Un ITexture2D => UnityEntryKind.Texture, ITextAsset => UnityEntryKind.Text, IFont => UnityEntryKind.Font, IVideoClip => UnityEntryKind.VideoClip, IMovieTexture => UnityEntryKind.MovieTexture, IAudioClip => UnityEntryKind.AudioClip, - IMesh => UnityEntryKind.Mesh, ISprite => UnityEntryKind.Sprite, _ => null, + IMesh => UnityEntryKind.Mesh, _ => null, }; - bool atlas = definition is ISpriteAtlas; AssetTypeTemplateField? template = null; - if ((kind is not null || atlas) && TypeTreeNodeStruct.TryMakeFromTpk((ClassIDType)info.ClassID, version, out var releaseRoot, out var editorRoot)) + if (kind is not null && TypeTreeNodeStruct.TryMakeFromTpk((ClassIDType)info.ClassID, version, out var releaseRoot, out var editorRoot)) template = UnityProjection.Template(info.Collection.Flags.IsRelease() ? releaseRoot : editorRoot); - return new(kind, atlas, template, definition as IAudioClip); + return new(kind, template, definition as IAudioClip); } public IReadOnlyList Describe(AssetCollection collection) @@ -193,7 +185,6 @@ public void ReleaseProjectedMetadata(IEnumerable assets) if (asset is DeferredObject identity) { identity.Projection = null; - identity.AtlasTextures = null; } } } @@ -201,19 +192,9 @@ public void ReleaseProjectedMetadata(IEnumerable assets) private IProbeAsset Describe(DeferredObject identity) { if (identity.Projection is not { } fields || identity.Shape.Kind is not { } kind) return OtherAsset.Instance; - long size; string extension; - if (kind == UnityEntryKind.Sprite) - { - if (!TrySpriteSize(identity, out bool fromAtlas, out long textureSize)) return OtherAsset.Instance; - size = fromAtlas ? 0 : textureSize; extension = "png"; - } - else - { - var metadata = DescribeHeader(identity.Collection, identity.Data, identity.Shape, fields); - if (!metadata.Visible) return OtherAsset.Instance; - size = metadata.Size; extension = metadata.Extension; - } - identity.CaptureResourceMetadata(size, extension); return identity; + var metadata = DescribeHeader(identity.Collection, identity.Data, identity.Shape, fields); + if (!metadata.Visible) return OtherAsset.Instance; + identity.CaptureResourceMetadata(metadata.Size, metadata.Extension); return identity; } private static (bool Visible, long Size, string Extension) DescribeHeader(AssetCollection collection, in ObjectInfo descriptor, Shape shape, UnityProjection fields, bool resolveResources = true) { @@ -223,7 +204,7 @@ private static (bool Visible, long Size, string Extension) DescribeHeader(AssetC switch (kind) { case UnityEntryKind.Texture: - if (EmptyTexture(shape, fields)) return (false, 0, string.Empty); + if (EmptyTexture(fields)) return (false, 0, string.Empty); size = TextureSize(fields); extension = "png"; break; case UnityEntryKind.Text: size = Range(fields, "m_Script").Length; extension = "bin"; break; @@ -267,7 +248,7 @@ private static (bool Visible, long Size, string Extension) DescribeHeader(AssetC } else extension = OutputExtensions.FallbackBin; break; - // Factory shapes are a closed set; callers handle Sprite separately. + // Factory shapes are a closed set. default: // Mesh is the remaining resource kind. if (fields.Number("m_VertexData/m_VertexCount") == 0 && fields.Number("m_CompressedMesh/m_Vertices/m_NumItems") == 0) return (false, 0, string.Empty); size = (long)Range(fields, "m_VertexData/m_DataSize").Length + Range(fields, "m_IndexBuffer").Length; @@ -286,62 +267,10 @@ private static long TextureSize(UnityProjection fields) int inline = Range(fields, "image data").Length; return inline != 0 ? inline : fields.Text("m_StreamData/path").Length != 0 ? DeclaredSourceSize.Narrow(fields.Unsigned("m_StreamData/size")) : 0; } - private static bool EmptyTexture(DeferredObject texture) => EmptyTexture(texture.Shape, texture.Projection); - private static bool EmptyTexture(Shape shape, UnityProjection? fields) - => shape.Kind != UnityEntryKind.Texture || fields is null - || fields.Number("m_Width") == 0 && fields.Number("m_Height") == 0 && Range(fields, "image data").Length == 0 + private static bool EmptyTexture(UnityProjection fields) + => fields.Number("m_Width") == 0 && fields.Number("m_Height") == 0 && Range(fields, "image data").Length == 0 && fields.Unsigned("m_StreamData/size") == 0 && fields.Text("m_StreamData/path").Length == 0; - private static DeferredObject? Reference(DeferredObject owner, string path) - => owner.Collection.TryGetAsset((int)owner.Projection!.Number(path + "/m_FileID"), owner.Projection.Number(path + "/m_PathID")) as DeferredObject; - - private static (ulong, ulong, ulong, ulong, long) Key(UnityProjection fields, string prefix) - => new(fields.Unsigned(prefix + "/first/data[0]"), fields.Unsigned(prefix + "/first/data[1]"), fields.Unsigned(prefix + "/first/data[2]"), fields.Unsigned(prefix + "/first/data[3]"), fields.Number(prefix + "/second")); - private static Dictionary<(ulong, ulong, ulong, ulong, long), AtlasEntry> AtlasIndex(UnityProjection fields) - { - Dictionary<(ulong, ulong, ulong, ulong, long), AtlasEntry> result = []; - foreach (string path in fields.Keys) - { - const string tail = "/first/second"; - if (!path.EndsWith(tail, StringComparison.Ordinal)) continue; - string entry = path[..^tail.Length]; - string pointer = entry + "/second/texture"; - // The pinned AccessDictionary returns the first matching entry for duplicate keys. - result.TryAdd(Key(fields, entry + "/first"), - new AtlasEntry((int)fields.Number(pointer + "/m_FileID"), fields.Number(pointer + "/m_PathID"), - SpriteFactsReader.ReadSource(fields, entry + "/second/"))); - } - return result; - } - private bool TryTextureInfo(AssetCollection collection, long path, out long size, out int width, out int height) - { - if (compactCollections.TryGetValue(collection, out CompactCollection? rows)) return rows.TryTextureInfo(path, out size, out width, out height); - size = 0; width = 0; height = 0; - if (collection.TryGetAsset(path) is not DeferredObject texture || EmptyTexture(texture) - || texture.Projection is not { } fields) return false; - size = TextureSize(fields); width = unchecked((int)fields.Number("m_Width")); height = unchecked((int)fields.Number("m_Height")); - return true; - } - private bool TryTextureSize(AssetCollection collection, long path, out long size) - => TryTextureInfo(collection, path, out size, out _, out _); - private bool TrySpriteSize(DeferredObject sprite, out bool fromAtlas, out long size) - { - UnityProjection fields = sprite.Projection!; size = 0; - fromAtlas = fields.Number("m_SpriteAtlas/m_PathID") != 0; - AssetCollection owner = sprite.Collection; - int file; long path; - if (fromAtlas) - { - DeferredObject? atlas = Reference(sprite, "m_SpriteAtlas"); - if (atlas?.Projection is not { } atlasFields || !atlas.Shape.Atlas) return false; - atlas.AtlasTextures ??= AtlasIndex(atlasFields); - if (!atlas.AtlasTextures.TryGetValue(Key(fields, "m_RenderDataKey"), out AtlasEntry pointer)) return false; - owner = atlas.Collection; file = pointer.FileId; path = pointer.PathId; - } - else { file = (int)fields.Number("m_RD/texture/m_FileID"); path = fields.Number("m_RD/texture/m_PathID"); } - return file >= 0 && file < owner.Dependencies.Count && owner.Dependencies[file] is { } target && TryTextureSize(target, path, out size); - } - internal byte[]? Export(IProbeResource exportTarget) { DeferredObject identity = (DeferredObject)exportTarget; @@ -349,19 +278,6 @@ private bool TrySpriteSize(DeferredObject sprite, out bool fromAtlas, out long s try { IUnityObjectBase? full = Materialize(identity, replaced); - if (full is ISprite sprite) - { - if (sprite.SpriteAtlas is { } pointer && !pointer.IsNull()) - { - if (sprite.Collection.TryGetAsset(pointer.FileID, pointer.PathID) is DeferredObject atlasIdentity) - Materialize(atlasIdentity, replaced); - if (sprite.SpriteAtlasP is { } atlas && atlas.RenderDataMap.TryGetValue(sprite.RenderDataKey, out var data) - && atlas.Collection.TryGetAsset(data.Texture.FileID, data.Texture.PathID) is DeferredObject texture) - Materialize(texture, replaced); - } - else if (sprite.Collection.TryGetAsset(sprite.RD.Texture.FileID, sprite.RD.Texture.PathID) is DeferredObject texture) - Materialize(texture, replaced); - } IProbeAsset asset = full is null ? OtherAsset.Instance : Wrap([full]).Single(); return asset switch { IProbeTexture texture => texture.RenderPng(), IProbeText text => text.ReadData(), IProbeResource resource => resource.ExportData(), _ => null }; } diff --git a/src/modules/unity/UnityProbe/UnitySource.Quad.cs b/src/modules/unity/UnityProbe/UnitySource.Quad.cs deleted file mode 100644 index c69c4cf..0000000 --- a/src/modules/unity/UnityProbe/UnitySource.Quad.cs +++ /dev/null @@ -1,205 +0,0 @@ -using System; -using System.Buffers.Binary; -using System.Collections.Generic; -using System.IO; -using System.Numerics; -using AssetRipper.IO.Endian; -using AssetRipper.Numerics; -using AssetRipper.Primitives; -using AssetRipper.SourceGenerated.Extensions; -using AssetRipper.SourceGenerated.Subclasses.ChannelInfo; -using AssetRipper.SourceGenerated.Subclasses.StreamInfo; - -namespace Unpackrat.UnityProbe; - -internal static partial class UnitySource -{ - private const int QuadVertices = 4; - private const int QuadIndices = 6; - private const int QuadVertexDataLimit = 4096; - private const int QuadChannelLimit = 32; - private const int QuadStreamLimit = 8; - - private static class SpriteQuadProof - { - public static bool TryProve(UnityProjection fields, Stream source, long objectOffset, - UnityVersion version, EndianType endian, int width, int height) - { - if (width <= 0 || height <= 0) return false; - if (!fields.TryNumber("m_RD/settingsRaw", out long settings) || settings != 64) return false; - if (!TryFloat(fields, "m_RD/downscaleMultiplier", out float downscale) - || downscale is not (0f or 1f)) return false; - if (!TryFloat(fields, "m_PixelsToUnits", out float pixelsToUnits) - || !float.IsFinite(pixelsToUnits) || pixelsToUnits <= 0f) return false; - if (!TryFloat(fields, "m_Rect/width", out float rectWidth) - || !TryFloat(fields, "m_Rect/height", out float rectHeight) - || rectWidth != width || rectHeight != height) return false; - if (!TryPivot(fields, rectWidth, rectHeight, out float pivotX, out float pivotY)) return false; - - if (!fields.TryNumber("m_RD/m_VertexData/m_VertexCount", out long vertexCount) - || vertexCount != QuadVertices) return false; - const string subMesh = "m_RD/m_SubMeshes/Array/data#0/"; - if (!fields.TryNumber(subMesh + "firstByte", out long firstByte) || firstByte != 0 - || !fields.TryNumber(subMesh + "indexCount", out long indexCount) || indexCount != QuadIndices - || !fields.TryNumber(subMesh + "topology", out long topology) || topology != 0 - || !fields.TryNumber(subMesh + "firstVertex", out long firstVertex) || firstVertex != 0 - || !fields.TryNumber(subMesh + "vertexCount", out long subVertexCount) || subVertexCount != QuadVertices - || fields.TryNumber("m_RD/m_SubMeshes/Array/data#1/firstByte", out _)) return false; - - UnityProjection.ByteRange indexRange = LazyAssetFactory.Range(fields, "m_RD/m_IndexBuffer"); - if (indexRange.Length != QuadIndices * sizeof(ushort)) return false; - Span indexBytes = stackalloc byte[QuadIndices * sizeof(ushort)]; - if (!TryRead(source, objectOffset, indexRange, indexBytes)) return false; - ushort[] indices = new ushort[QuadIndices]; - for (int i = 0; i < indices.Length; i++) - indices[i] = BinaryPrimitives.ReadUInt16LittleEndian(indexBytes[(i * sizeof(ushort))..]); - - UnityProjection.ByteRange dataRange = LazyAssetFactory.Range(fields, "m_RD/m_VertexData/m_DataSize"); - if (dataRange.Length <= 0 || dataRange.Length > QuadVertexDataLimit) return false; - byte[] data = new byte[dataRange.Length]; - if (!TryRead(source, objectOffset, dataRange, data)) return false; - - if (!TryChannels(fields, out List channels)) return false; - IReadOnlyList streams = VertexDataBlob.ConvertChannelsToStreams(channels, (uint)QuadVertices, version); - VertexDataBlob blob = new(channels, streams, data, QuadVertices, version, endian); - // TryChannels guarantees a decoded position channel with a known format and bounded - // dimension, so the declared source bound is the only remaining Quad refusal here. - _ = TryDeclaredVertexChannels(blob, out _, out long requiredSourceBytes); - if (requiredSourceBytes > data.Length) return false; - MeshData decoded = blob.ToMeshData(); - if (decoded.Vertices.Length != QuadVertices) return false; - - float[] points = new float[QuadVertices * 2]; - for (int i = 0; i < QuadVertices; i++) - { - Vector3 vertex = decoded.Vertices[i]; - points[i * 2] = vertex.X; - points[(i * 2) + 1] = vertex.Y; - } - SpriteGeometry.ToCanvasPixels(points, pixelsToUnits, pivotX, pivotY, rectWidth, rectHeight); - return Covers(points, indices, width, height); - } - - private static bool TryPivot(UnityProjection fields, float rectWidth, float rectHeight, - out float pivotX, out float pivotY) - { - bool hasX = TryFloat(fields, "m_Pivot/x", out pivotX); - bool hasY = TryFloat(fields, "m_Pivot/y", out pivotY); - if (hasX != hasY) return false; - if (hasX) return float.IsFinite(pivotX) && float.IsFinite(pivotY); - if (!TryFloat(fields, "m_Offset/x", out float offsetX) - || !TryFloat(fields, "m_Offset/y", out float offsetY)) return false; - pivotX = ((rectWidth / 2f) + offsetX) / rectWidth; - pivotY = ((rectHeight / 2f) + offsetY) / rectHeight; - return float.IsFinite(pivotX) && float.IsFinite(pivotY); - } - - private static bool TryChannels(UnityProjection fields, out List channels) - { - channels = new(QuadVertices); - for (int i = 0; i < QuadChannelLimit; i++) - { - string prefix = $"m_RD/m_VertexData/m_Channels/Array/data#{i}/"; - bool hasStream = fields.TryNumber(prefix + "stream", out long stream); - bool hasOffset = fields.TryNumber(prefix + "offset", out long offset); - bool hasFormat = fields.TryNumber(prefix + "format", out long format); - bool hasDimension = fields.TryNumber(prefix + "dimension", out long dimension); - if (!hasStream && !hasOffset && !hasFormat && !hasDimension) break; - if (!hasStream || !hasOffset || !hasFormat || !hasDimension) return false; - if (stream is < 0 or >= QuadStreamLimit - || offset is < 0 or > byte.MaxValue - || format is < 0 or > byte.MaxValue - || dimension is < 0 or > 4) return false; - bool active = dimension != 0; - if (active && !IsKnownVertexFormat(format)) return false; - // This bounded proof covers basic geometry only. Active blend channels (12 = weights, - // 13 = indices) can reach SDK skin decoding that indexes an empty component array. - if (active && i is 12 or 13) return false; - if (active) - { - foreach (ChannelInfo existing in channels) - if (existing.Dimension != 0 && existing.Stream == stream && existing.Offset == offset) return false; - } - channels.Add(new ChannelInfo - { - Stream = (byte)stream, - Offset = (byte)offset, - Format = (byte)format, - Dimension = (byte)dimension, - }); - } - if (channels.Count == 0 || channels[0].Dimension != 3) return false; - const string beyond = "m_RD/m_VertexData/m_Channels/Array/data#32/"; - if (fields.TryNumber(beyond + "stream", out _) || fields.TryNumber(beyond + "offset", out _) - || fields.TryNumber(beyond + "format", out _) || fields.TryNumber(beyond + "dimension", out _)) return false; - return true; - } - - // TryChannels rejects a negative format before this check; the private predicate only - // needs the supported upper bound. - private static bool IsKnownVertexFormat(long format) => format <= 6; - - private static bool TryFloat(UnityProjection fields, string path, out float value) - { - if (!fields.TryFloat32(path, out uint bits)) { value = 0f; return false; } - value = BitConverter.Int32BitsToSingle(unchecked((int)bits)); - return true; - } - - private static bool TryRead(Stream source, long objectOffset, UnityProjection.ByteRange range, Span destination) - { - if (range.Offset < 0 || range.Length != destination.Length) return false; - long offset = checked(objectOffset + range.Offset); - if (offset < 0 || offset > source.Length || destination.Length > source.Length - offset) return false; - source.Position = offset; - return source.ReadAtLeast(destination, destination.Length, throwOnEndOfStream: false) == destination.Length; - } - - private static bool Covers(float[] points, ushort[] indices, int width, int height) - { - if (points.Length != QuadVertices * 2 || indices.Length != QuadIndices) return false; - if (width < 1 || height < 1 || (long)width * height > 8_388_608) return false; - int[] corners = new int[QuadVertices]; - bool[] used = new bool[QuadVertices]; - for (int i = 0; i < QuadVertices; i++) - { - float x = points[i * 2]; - float y = points[(i * 2) + 1]; - int corner; - if (y == 0f) - { - if (x == 0f) corner = 0; - else if (x == width) corner = 1; - else return false; - } - else if (y == height) - { - if (x == 0f) corner = 2; - else if (x == width) corner = 3; - else return false; - } - else return false; - if (used[corner]) return false; - used[corner] = true; - corners[i] = corner; - } - - int first = FaceMask(corners, indices, 0); - int second = FaceMask(corners, indices, 3); - if (first < 0 || second < 0 || (first | second) != 0b1111) return false; - int shared = first & second; - return shared is 0b1001 or 0b0110; - } - - private static int FaceMask(int[] corners, ushort[] indices, int start) - { - int mask = 0; - for (int i = start; i < start + 3; i++) - { - if (indices[i] >= QuadVertices) return -1; - mask |= 1 << corners[indices[i]]; - } - return BitOperations.PopCount((uint)mask) == 3 ? mask : -1; - } - } -} \ No newline at end of file diff --git a/src/modules/unity/UnityProbe/UnitySource.Sprite.cs b/src/modules/unity/UnityProbe/UnitySource.Sprite.cs deleted file mode 100644 index 8718c4a..0000000 --- a/src/modules/unity/UnityProbe/UnitySource.Sprite.cs +++ /dev/null @@ -1,837 +0,0 @@ -using System; -using System.Buffers.Binary; -using System.Collections.Generic; -using System.IO; -using System.Numerics; -using System.Runtime.CompilerServices; -using AssetRipper.Assets.Collections; -using AssetRipper.Export.Modules.Textures; -using AssetRipper.SourceGenerated.Classes.ClassID_213; -using AssetRipper.SourceGenerated.Classes.ClassID_28; -using AssetRipper.SourceGenerated.Classes.ClassID_687078895; -using AssetRipper.SourceGenerated.Enums; -using AssetRipper.SourceGenerated.Extensions; -using AssetRipper.SourceGenerated.Subclasses.ChannelInfo; -using AssetRipper.SourceGenerated.Subclasses.SpriteAtlasData; -using AssetRipper.SourceGenerated.Subclasses.SpriteRenderData; -using AssetRipper.SourceGenerated.Subclasses.StreamInfo; -using AssetRipper.SourceGenerated.Subclasses.SubMesh; -using AssetRipper.TextureDecoder.Rgb.Formats; -using StbImageSharp; - -namespace Unpackrat.UnityProbe; - -/// -/// Sprite adapter for the additional resource kinds. Like the font, media and mesh adapters it is -/// one of the only places these AssetRipper types are touched: it resolves its catalogue metadata -/// from declared fields alone and defers the texture decode, crop and PNG write to an explicit -/// export. A Unity Sprite is a rectangle (or a tight mesh) inside a texture, so the export is a -/// standalone PNG of the sprite's own pixels, never the whole atlas. -/// -internal static partial class UnitySource -{ - private sealed class SpriteAsset(ISprite sprite, SpriteSource source) : IProbeResource - { - public UnityEntryKind Kind => UnityEntryKind.Sprite; - public long PathId => sprite.PathID; - public string Name => sprite.Name.ToString(); - public string? OriginalPath => sprite.OriginalPath; - - // Metadata only: an atlas texture is shared with other sprites, so its contribution is - // unknown; a sprite whose own render data names its texture reports that declared length. - public long SourceSize => source.FromAtlas ? 0 : DeclaredTextureSourceSize(source.Texture); - - public string OutputExtension => OutputExtensions.SpritePng; - - public byte[]? ExportData() => RenderSprite(sprite, source); - } - - /// - /// The metadata a sprite's image is cropped from: the texture, its atlas-space rect measured - /// from the bottom-left, the trimmed offset, and the packing settings. Every value comes from - /// one selected record, either the sprite's own render data or the atlas entry its render-data - /// key selects. - /// - private readonly struct SpriteSource - { - public SpriteSource( - ITexture2D texture, - bool fromAtlas, - float rectX, - float rectY, - float rectWidth, - float rectHeight, - float offsetX, - float offsetY, - float downscale, - bool packed, - int packingMode, - int packingRotation) - { - Texture = texture; - FromAtlas = fromAtlas; - RectX = rectX; - RectY = rectY; - RectWidth = rectWidth; - RectHeight = rectHeight; - OffsetX = offsetX; - OffsetY = offsetY; - Downscale = downscale; - Packed = packed; - PackingMode = packingMode; - PackingRotation = packingRotation; - } - - public ITexture2D Texture { get; } - public bool FromAtlas { get; } - public float RectX { get; } - public float RectY { get; } - public float RectWidth { get; } - public float RectHeight { get; } - public float OffsetX { get; } - public float OffsetY { get; } - public float Downscale { get; } - public bool Packed { get; } - public int PackingMode { get; } - public int PackingRotation { get; } - } - - /// - /// The declared geometry needed to decide whether two selected Sprite rows render the same - /// image. Floats stay as their raw IEEE payload so a NaN, a signed zero or a fractional value - /// can never be confused with a missing field or a rounded integer. - /// - private readonly record struct SpriteSourceFacts( - bool Present, - uint TextureX, - uint TextureY, - uint TextureWidth, - uint TextureHeight, - uint OffsetX, - uint OffsetY, - uint Downscale, - uint SettingsRaw); - - /// The Sprite's own rect and the offset that places the crop on that canvas. - private readonly record struct SpriteCanvasFacts( - bool Present, - uint RectWidth, - uint RectHeight); - - private struct SpriteFacts - { - public SpriteSourceFacts Source; - public SpriteCanvasFacts Canvas; - public AssetCollection? SourceCollection; - public long SourcePath; - public int SourceWidth; - public int SourceHeight; - public bool Resolved; - - public readonly bool Comparable => - Resolved && SourceCollection is not null && Source.Present && Canvas.Present - && SpriteFactsReader.IsFinite(Source.TextureX) && SpriteFactsReader.IsFinite(Source.TextureY) - && SpriteFactsReader.IsFinite(Source.TextureWidth) && SpriteFactsReader.IsFinite(Source.TextureHeight) - && SpriteFactsReader.IsFinite(Source.OffsetX) && SpriteFactsReader.IsFinite(Source.OffsetY) - && SpriteFactsReader.IsFinite(Source.Downscale) - && SpriteFactsReader.IsFinite(Canvas.RectWidth) && SpriteFactsReader.IsFinite(Canvas.RectHeight) - && SpriteFactsReader.Positive(Source.TextureWidth) && SpriteFactsReader.Positive(Source.TextureHeight) - && SpriteFactsReader.Positive(Canvas.RectWidth) && SpriteFactsReader.Positive(Canvas.RectHeight) - && SpriteFactsReader.IsExportableRectangle(Source, Canvas, SourceWidth, SourceHeight); - - public readonly SpriteKey Key => new(SourceCollection!, SourcePath, Source, Canvas); - - public readonly bool IsFullTexture(int width, int height) - { - if (!Comparable || width <= 0 || height <= 0) return false; - if (!SpriteFactsReader.IsIdentityRotation(Source.SettingsRaw)) return false; - if (Source.TextureX != 0 || Source.TextureY != 0) return false; - if (Source.OffsetX != 0 || Source.OffsetY != 0) return false; - if (Source.TextureWidth != SpriteFactsReader.Bits((float)width) - || Source.TextureHeight != SpriteFactsReader.Bits((float)height)) return false; - if (Canvas.RectWidth != SpriteFactsReader.Bits((float)width) - || Canvas.RectHeight != SpriteFactsReader.Bits((float)height)) return false; - return true; - } - } - - private readonly struct SpriteKey : IEquatable - { - public SpriteKey(AssetCollection sourceCollection, long sourcePath, - SpriteSourceFacts source, SpriteCanvasFacts canvas) - => (SourceCollection, SourcePath, Source, Canvas) = (sourceCollection, sourcePath, source, canvas); - - private AssetCollection SourceCollection { get; } - private long SourcePath { get; } - private SpriteSourceFacts Source { get; } - private SpriteCanvasFacts Canvas { get; } - - public bool Equals(SpriteKey other) => - ReferenceEquals(SourceCollection, other.SourceCollection) - && SourcePath == other.SourcePath && Source == other.Source && Canvas == other.Canvas; - - public override bool Equals(object? obj) => obj is SpriteKey other && Equals(other); - - public override int GetHashCode() - { - HashCode hash = new(); - hash.Add(RuntimeHelpers.GetHashCode(SourceCollection)); - hash.Add(SourcePath); - hash.Add(Source); - hash.Add(Canvas); - return hash.ToHashCode(); - } - } - - /// - /// Reads the bounded Sprite geometry from the already-projected header. A path that is absent - /// from the schema leaves the corresponding facts absent; it is never defaulted to zero. - /// - private static class SpriteFactsReader - { - private const uint OneBits = 0x3F800000u; - private const uint KnownSettingsMask = 0x7Fu; - - public static SpriteSourceFacts ReadSource(UnityProjection fields, string prefix) - { - if (!TryFloat32(fields, prefix + "textureRect/x", out uint textureX) - || !TryFloat32(fields, prefix + "textureRect/y", out uint textureY) - || !TryFloat32(fields, prefix + "textureRect/width", out uint textureWidth) - || !TryFloat32(fields, prefix + "textureRect/height", out uint textureHeight) - || !TryFloat32(fields, prefix + "textureRectOffset/x", out uint offsetX) - || !TryFloat32(fields, prefix + "textureRectOffset/y", out uint offsetY) - || !TryFloat32(fields, prefix + "downscaleMultiplier", out uint downscale) - || !TrySettings(fields, prefix, out uint settings)) - { - return default; - } - return new SpriteSourceFacts(true, textureX, textureY, textureWidth, textureHeight, - offsetX, offsetY, downscale, settings); - } - - public static SpriteSourceFacts ReadLocalSource(UnityProjection fields) - { - if (!TryFloat32(fields, "m_RD/textureRect/x", out uint textureX) - || !TryFloat32(fields, "m_RD/textureRect/y", out uint textureY) - || !TryFloat32(fields, "m_RD/textureRect/width", out uint textureWidth) - || !TryFloat32(fields, "m_RD/textureRect/height", out uint textureHeight) - || !TryFloat32(fields, "m_RD/textureRectOffset/x", out uint offsetX) - || !TryFloat32(fields, "m_RD/textureRectOffset/y", out uint offsetY) - || !TryFloat32(fields, "m_RD/downscaleMultiplier", out uint downscale) - || !TryLocalSettings(fields, out uint settings)) - { - return default; - } - return new SpriteSourceFacts(true, textureX, textureY, textureWidth, textureHeight, - offsetX, offsetY, downscale, settings); - } - - public static SpriteCanvasFacts ReadCanvas(UnityProjection fields) - { - if (!TryFloat32(fields, "m_Rect/width", out uint rectWidth) - || !TryFloat32(fields, "m_Rect/height", out uint rectHeight)) - { - return default; - } - return new SpriteCanvasFacts(true, rectWidth, rectHeight); - } - - private static bool TryLocalSettings(UnityProjection fields, out uint settings) - { - if (fields.TryNumber("m_RD/settingsRaw", out long raw)) - { - settings = unchecked((uint)raw); - return true; - } - if (fields.TryNumber("m_RD/packingMode", out long mode) - && fields.TryNumber("m_RD/packingRotation", out long rotation)) - { - long packed = fields.TryNumber("m_RD/packed", out long packedValue) && packedValue != 0 ? 1 : 0; - settings = unchecked((uint)((packed << 0) | ((mode & 0x1) << 1) | ((rotation & 0xF) << 2))); - return true; - } - settings = 0; - return false; - } - - private static bool TrySettings(UnityProjection fields, string prefix, out uint settings) - { - if (fields.TryNumber(prefix + "settingsRaw", out long raw)) - { - settings = unchecked((uint)raw); - return true; - } - if (fields.TryNumber(prefix + "packingMode", out long mode) - && fields.TryNumber(prefix + "packingRotation", out long rotation)) - { - long packed = fields.TryNumber(prefix + "packed", out long packedValue) && packedValue != 0 ? 1 : 0; - settings = unchecked((uint)((packed << 0) | ((mode & 0x1) << 1) | ((rotation & 0xF) << 2))); - return true; - } - settings = 0; - return false; - } - - private static bool TryFloat32(UnityProjection fields, string path, out uint bits) - { - if (fields.TryFloat32(path, out bits)) return true; - bits = 0; - return false; - } - - public static uint Bits(float value) => unchecked((uint)BitConverter.SingleToInt32Bits(value)); - - public static bool IsFinite(uint bits) => float.IsFinite(BitConverter.Int32BitsToSingle(unchecked((int)bits))); - - public static bool Positive(uint bits) => BitConverter.Int32BitsToSingle(unchecked((int)bits)) > 0f; - - public static bool IsUnitDownscale(uint bits) => bits is 0u or OneBits; - - public static bool IsKnownRectangleSettings(uint settings) - { - if ((settings & ~KnownSettingsMask) != 0) return false; - uint mode = (settings >> 1) & 0x1u; - uint rotation = (settings >> 2) & 0xFu; - return mode == (uint)SpriteGeometry.ModeRectangle && rotation <= SpriteGeometry.RotationRotate90; - } - - public static bool IsIdentityRotation(uint settings) => ((settings >> 2) & 0xFu) == 0; - - public static bool IsExportableRectangle(SpriteSourceFacts source, SpriteCanvasFacts canvas, int textureWidth, int textureHeight) - { - if (!source.Present || !canvas.Present || textureWidth <= 0 || textureHeight <= 0) return false; - if (!IsKnownRectangleSettings(source.SettingsRaw) || !IsUnitDownscale(source.Downscale)) return false; - if (!SpriteGeometry.TryCropRect(textureWidth, textureHeight, - BitConverter.Int32BitsToSingle(unchecked((int)source.TextureX)), - BitConverter.Int32BitsToSingle(unchecked((int)source.TextureY)), - BitConverter.Int32BitsToSingle(unchecked((int)source.TextureWidth)), - BitConverter.Int32BitsToSingle(unchecked((int)source.TextureHeight)), - out _, out _, out int cropWidth, out int cropHeight)) return false; - if (!SpriteGeometry.TryCanvasSize(BitConverter.Int32BitsToSingle(unchecked((int)canvas.RectWidth)), - BitConverter.Int32BitsToSingle(unchecked((int)canvas.RectHeight)), Budgets.MaxTextureBytes, - out int canvasWidth, out int canvasHeight)) return false; - uint rotation = (source.SettingsRaw >> 2) & 0xFu; - int effectiveRotation = (source.SettingsRaw & 1u) != 0 ? (int)rotation : SpriteGeometry.RotationNone; - int uprightWidth = effectiveRotation == SpriteGeometry.RotationRotate90 ? cropHeight : cropWidth; - int uprightHeight = effectiveRotation == SpriteGeometry.RotationRotate90 ? cropWidth : cropHeight; - if (!SpriteGeometry.TryPlacement(BitConverter.Int32BitsToSingle(unchecked((int)source.OffsetX)), - BitConverter.Int32BitsToSingle(unchecked((int)source.OffsetY)), canvasHeight, uprightHeight, - out int x, out int y)) return false; - return x >= 0 && y >= 0 && (long)x + uprightWidth <= canvasWidth - && (long)y + uprightHeight <= canvasHeight; - } - } - - /// - /// Resolves the selected record a sprite renders from. An atlas sprite must resolve its - /// render-data key through the atlas it names, against the atlas's own collection; a missing - /// key or an unresolvable texture never silently falls back to the sprite's local render data - /// or the whole atlas. A sprite with no atlas resolves its own render data. - /// - private static bool TryResolveSpriteSource(ISprite sprite, out SpriteSource source) - { - source = default; - // A pre-2017 sprite serializes no atlas pointer at all; a present pointer with a zero path - // id is an unset reference. - if (sprite.SpriteAtlas is { } atlasPointer && !atlasPointer.IsNull()) - { - // The sprite claims an atlas, so it must resolve one. A sprite with a sprite atlas - // reference always serializes a render-data key (both arrived in Unity 2017), so the - // keyed lookup is the only resolution path and a miss is a refusal, not a fallback. - if (sprite.SpriteAtlasP is not { } atlas) - { - return false; - } - - if (!atlas.RenderDataMap.TryGetValue(sprite.RenderDataKey, out ISpriteAtlasData? atlasData)) - { - return false; - } - - ITexture2D? atlasTexture = atlasData.Texture.TryGetAsset(atlas.Collection); - if (atlasTexture is null) - { - return false; - } - - source = new SpriteSource( - atlasTexture, - fromAtlas: true, - atlasData.TextureRect.X, - atlasData.TextureRect.Y, - atlasData.TextureRect.Width, - atlasData.TextureRect.Height, - atlasData.TextureRectOffset.X, - atlasData.TextureRectOffset.Y, - atlasData.DownscaleMultiplier, - atlasData.IsPacked, - (int)atlasData.PackingMode, - (int)atlasData.PackingRotation); - return true; - } - - ITexture2D? localTexture = sprite.RD.Texture.TryGetAsset(sprite.Collection); - if (localTexture is null) - { - return false; - } - - source = new SpriteSource( - localTexture, - fromAtlas: false, - sprite.RD.TextureRect.X, - sprite.RD.TextureRect.Y, - sprite.RD.TextureRect.Width, - sprite.RD.TextureRect.Height, - sprite.RD.TextureRectOffset.X, - sprite.RD.TextureRectOffset.Y, - sprite.RD.DownscaleMultiplier, - sprite.RD.IsPacked, - (int)sprite.RD.PackingMode, - (int)sprite.RD.PackingRotation); - return true; - } - - /// - /// A downscale multiplier of exactly one (or the unused zero default) leaves the rect in the - /// texture's own pixel space. Any other value, including a negative or non-finite one, stores - /// the rect in downscaled space, which needs a resampler this adapter does not have, so it is - /// refused rather than mapped wrong. - /// - private static bool IsUnitDownscale(float downscale) - { - return downscale is 0f or 1f; - } - - private static byte[]? RenderSprite(ISprite sprite, SpriteSource source) - { - // The sprite's pixels are a crop of its source texture, so the source must decode through - // the same metadata guards an ordinary texture uses. - if (!TryDecodeTexture(source.Texture, out DirectBitmap atlasBitmap)) - { - return null; - } - - if (!IsUnitDownscale(source.Downscale)) - { - return null; - } - - if (!SpriteGeometry.TryCropRect( - atlasBitmap.Width, - atlasBitmap.Height, - source.RectX, - source.RectY, - source.RectWidth, - source.RectHeight, - out int left, - out int top, - out int cropWidth, - out int cropHeight)) - { - return null; - } - - // Crop the small region first, then normalise it to RGBA through the mature writer and - // reader, so no pixel-type switch of our own is needed. - DirectBitmap cropped = atlasBitmap.Crop(new Range(left, left + cropWidth), new Range(top, top + cropHeight)); - byte[] raw = ToRgba(cropped, out int rawWidth, out int rawHeight); - - if (!SpriteGeometry.TryUpright(rawWidth, rawHeight, raw, SpriteRotation(source), out int uprightWidth, out int uprightHeight, out byte[] upright)) - { - return null; - } - - if (!SpriteGeometry.TryCanvas(sprite.Rect.Width, sprite.Rect.Height, Budgets.MaxTextureBytes, out int canvasWidth, out int canvasHeight, out byte[] canvas)) - { - return null; - } - - if (!SpriteGeometry.TryPlacement(source.OffsetX, source.OffsetY, canvasHeight, uprightHeight, out int placedX, out int placedY)) - { - return null; - } - - if (!SpriteGeometry.TryPlace(canvasWidth, canvasHeight, placedX, placedY, uprightWidth, uprightHeight, upright, canvas)) - { - return null; - } - - // A tight sprite's rectangular crop can hold neighbouring pixels inside the bounding box. - // Only the pixels inside the mesh triangles are kept, so a tight sprite whose mesh is - // absent, malformed, non-triangular or out of range cannot produce a truthful mask and the - // export fails rather than leaking the bounding box's neighbouring pixels. - if (source.PackingMode == SpriteGeometry.ModeTight) - { - if (!TryReadSpriteTriangles(sprite, out float[] trianglePoints)) - { - return null; - } - - // A serialized pivot is used directly. A sprite that serializes none - the pre-5.6 - // shape - derives it from the rect centre plus its own offset, the pinned - // SpriteExtensions contract: pivot = (Rect.Size / 2 + Offset) / Rect.Size. No serialized - // pivot therefore does not mean a centred pivot. - float pivotX; - float pivotY; - if (sprite.Pivot is { } pivot) - { - pivotX = pivot.X; - pivotY = pivot.Y; - } - else - { - float rectWidth = sprite.Rect.Width; - float rectHeight = sprite.Rect.Height; - pivotX = ((rectWidth / 2f) + sprite.Offset.X) / rectWidth; - pivotY = ((rectHeight / 2f) + sprite.Offset.Y) / rectHeight; - } - - SpriteGeometry.ToCanvasPixels(trianglePoints, sprite.PixelsToUnits, pivotX, pivotY, sprite.Rect.Width, sprite.Rect.Height); - if (!SpriteGeometry.TryMaskTriangles(canvasWidth, canvasHeight, canvas, trianglePoints, MaxSpriteMaskOperations)) - { - return null; - } - } - - using MemoryStream stream = new(); - new DirectBitmap, byte>(canvasWidth, canvasHeight, 1, canvas).SaveAsPng(stream); - return stream.ToArray(); - } - - /// - /// The packing rotation, reported as a geometry value. The pinned SpritePackingRotation - /// values are the Unity ones, so the cast preserves them; a coverage-checked test pins that - /// agreement. An unpacked sprite ignores the rotation field entirely. - /// - private static int SpriteRotation(SpriteSource source) => source.Packed ? source.PackingRotation : SpriteGeometry.RotationNone; - - /// - /// Decodes a cropped bitmap to a top-down, four-byte RGBA buffer by writing it as a PNG with - /// the mature writer and reading that PNG back. The small crop round-trips losslessly and the - /// fixed four-byte layout keeps the geometry free of any pixel-format switch. - /// - private static byte[] ToRgba(DirectBitmap bitmap, out int width, out int height) - { - using MemoryStream stream = new(); - bitmap.SaveAsPng(stream); - ImageResult image = ImageResult.FromMemory(stream.ToArray(), ColorComponents.RedGreenBlueAlpha); - width = image.Width; - height = image.Height; - return image.Data; - } - - /// - /// Reads the sprite's tight-mesh triangles in sprite-local units, three points per triangle. - /// The modern vertex blob or the older vertex/indices list supply the positions; the pinned - /// TriangleEnumerable turns the safely decoded triangle-list indices into triangles. - /// Every index is validated against the vertex count, only the triangle-list topology is - /// accepted, and the index range is bounded, so no hostile mesh can drive unbounded work or a - /// wrong mask. - /// - private static bool TryReadSpriteTriangles(ISprite sprite, out float[] trianglePoints) - { - trianglePoints = []; - ISpriteRenderData renderData = sprite.RD; - - // Metadata-only preflight before the vertex blob is decoded, before any index or vertex - // array is copied and before the triangle list grows. - if (!TryDeclaredSpriteMesh(sprite, out _)) - { - return false; - } - - Vector3[] vertices; - if (renderData.VertexData is { Data.Length: > 0 } vertexData) - { - vertices = VertexDataBlob.Create(vertexData, sprite.Collection.Version, sprite.Collection.EndianType).ToMeshData().Vertices; - } - else - { - // The preflight proved the legacy shape carries at least one vertex, so this copy is a - // known-small allocation. - var legacyVertices = renderData.Vertices!; - Vector3[] legacy = new Vector3[legacyVertices.Count]; - for (int i = 0; i < legacy.Length; i++) - { - var position = legacyVertices[i].Pos; - legacy[i] = new Vector3(position.X, position.Y, position.Z); - } - - vertices = legacy; - } - - if (!TryReadSpriteIndices(renderData, out uint[] indices, out MeshTopology topology, out int firstIndex, out int indexCount)) - { - return false; - } - - return TryBuildTrianglePoints(vertices, indices, topology, firstIndex, indexCount, out trianglePoints); - } - - /// - /// Metadata-only preflight for a sprite's tight mesh: the declared vertex count, the bytes the - /// vertex decoder would materialise across every channel, the highest source byte it would read, - /// and the declared index, submesh and triangle counts all come from the pinned vertex-blob or - /// legacy list metadata; nothing is decoded, copied or iterated here. A sprite whose metadata is - /// absent or outside the explicit practical limits is refused before the decode runs. - /// - private static bool TryDeclaredSpriteMesh(ISprite sprite, out SpriteGeometry.DeclaredMesh mesh) - { - mesh = default; - ISpriteRenderData renderData = sprite.RD; - - if (renderData.VertexData is { Data.Length: > 0 } vertexData) - { - // Bound the raw channel list before the pinned Create walks it: it converts the channels - // into streams with a stream-major nested loop over every channel, so an untrusted - // channel-list length must be refused before that walk rather than discovered inside it. - // A sprite vertex blob always serializes its channel list. - if (!SpriteGeometry.TryDeclaredChannelCount(vertexData.Channels!.Count)) - { - return false; - } - - VertexDataBlob blob; - try - { - blob = VertexDataBlob.Create(vertexData, sprite.Collection.Version, sprite.Collection.EndianType); - } - catch (ArgumentOutOfRangeException) - { - // The pinned format helpers throw for an unknown channel format, which has no valid - // component width, so that descriptor is refused here instead of inside the decoder. - return false; - } - - // The streams are derived from the bounded channels, but a single hostile channel stream - // index would still materialise one stream per index, so the derived count is bounded too. - if (!SpriteGeometry.TryDeclaredStreamCount(blob.Streams.Count)) - { - return false; - } - - if (!TryDeclaredVertexChannels(blob, out long channelBytes, out long requiredSourceBytes)) - { - return false; - } - - // The decoder copies and iterates the complete index buffer whatever submesh is - // selected, so its whole length is bounded, not only the selected submesh's count; it - // also materialises one four-byte index per entry. The selected count is validated - // separately through the triangle work it drives. - // The inline index buffer is a byte array like the mesh's own, read through the same - // null-tolerant span the index decoder uses. - ReadOnlySpan indexBuffer = renderData.IndexBuffer; - long indexBufferCount = indexBuffer.Length / sizeof(ushort); - int declaredIndexCount = DeclaredSpriteIndexCount(renderData, out int subMeshCount); - mesh = new SpriteGeometry.DeclaredMesh( - blob.VertexCount, - channelBytes + (indexBufferCount * sizeof(uint)), - requiredSourceBytes, - blob.Data.Length, - (int)Math.Min(indexBufferCount, int.MaxValue), - declaredIndexCount, - subMeshCount, - declaredIndexCount / 3); - return SpriteGeometry.TryDeclaredMesh(mesh); - } - - if (renderData.Vertices is { Count: > 0 } legacyVertices) - { - // The legacy shape always serializes its index list (empty when the sprite carries no - // indices), so the declared count is read directly. - int declaredIndexCount = renderData.Indices!.Count; - // The legacy shape holds one three-float position per vertex, so its own list is the - // source and the decoded size is exact. The index list is copied in full and - // materialised as one four-byte index per entry. - long legacyBytes = (long)legacyVertices.Count * 12; - mesh = new SpriteGeometry.DeclaredMesh( - legacyVertices.Count, - legacyBytes + ((long)declaredIndexCount * sizeof(uint)), - legacyBytes, - (int)Math.Min(legacyBytes, int.MaxValue), - declaredIndexCount, - declaredIndexCount, - 0, - declaredIndexCount / 3); - return SpriteGeometry.TryDeclaredMesh(mesh); - } - - return false; - } - - /// - /// Validates the vertex blob's decoded channels from metadata alone and returns the safe - /// conservative decoded-byte total the vertex decoder would materialise and the highest source - /// byte it would read. A channel the decoder skips - one with a zero dimension or a clear stream - /// mask bit - is neither read nor allocated, so only decoded channels are bounded. A decoded - /// channel must carry a supported vector dimension and sit wholly inside the decoded body. - /// - private static bool TryDeclaredVertexChannels(VertexDataBlob blob, out long decodedBytes, out long requiredSourceBytes) - { - decodedBytes = 0; - requiredSourceBytes = 0; - for (int channelIndex = 0; channelIndex < blob.Channels.Count; channelIndex++) - { - ChannelInfo channel = blob.Channels[channelIndex]; - bool legacyColorOverride = blob.Version.LessThan(2018) & (channelIndex == 2) & (channel.Format == 2); - byte dimension = SpriteGeometry.ChannelDimension(channel.GetDataDimension(), legacyColorOverride); - // The blob's streams are derived from these channels, so every stream index is in range. - IStreamInfo stream = blob.Streams[channel.Stream]; - if (!SpriteGeometry.IsDecodedChannel(dimension, stream.ChannelMask, channelIndex)) - { - continue; - } - - if (dimension > SpriteGeometry.MaxMeshChannelDimension) - { - return false; - } - - // The blob was created with these channels, so every decoded channel's format already - // resolved to a valid byte width; the same width drives the source span and allocation. - int componentSize = MeshHelper.GetFormatSize(MeshHelper.ToVertexFormat(channel.Format, blob.Version)); - long vertexCount = blob.VertexCount; - long stride = stream.GetStride(); - long sourceEnd = (long)stream.Offset + channel.Offset + ((vertexCount - 1) * stride) + ((long)componentSize * dimension); - requiredSourceBytes = Math.Max(requiredSourceBytes, sourceEnd); - - // componentBytes = n*dimension*componentSize; the expanded float or int array is one - // four-byte component each; the final Vector2/3/4/Color array is at most sixteen bytes - // per vertex; a blend channel additionally rents one BoneWeight4[n] of thirty-two bytes - // per vertex. Summing every path keeps the bound conservative. - long components = vertexCount * dimension; - decodedBytes += (components * componentSize) + (components * sizeof(float)) + (vertexCount * 16) + (vertexCount * 32); - } - - return decodedBytes > 0; - } - - /// - /// Turns safely decoded triangle-list indices into sprite-local triangle points, three points per - /// triangle. The topology, the three-at-a-time index count and the submesh's index range are all - /// validated, every index is bounded by the vertex count, and only the triangle-list topology is - /// accepted, so no hostile mesh can drive an out-of-range read or a wrong mask. - /// - private static bool TryBuildTrianglePoints( - Vector3[] vertices, - uint[] indices, - MeshTopology topology, - int firstIndex, - int indexCount, - out float[] trianglePoints) - { - trianglePoints = []; - if (topology != MeshTopology.Triangles || indexCount % 3 != 0 || (long)firstIndex + indexCount > indices.Length) - { - return false; - } - - List points = []; - foreach ((uint first, uint second, uint third) in new TriangleEnumerable(topology, new ArraySegment(indices, firstIndex, indexCount))) - { - if (first >= vertices.Length || second >= vertices.Length || third >= vertices.Length) - { - return false; - } - - AddPoint(points, vertices[(int)first]); - AddPoint(points, vertices[(int)second]); - AddPoint(points, vertices[(int)third]); - } - - trianglePoints = points.ToArray(); - return true; - } - - /// - /// The declared index count of the modern shape: the selected submesh's own index count, or the - /// whole decoded index buffer when no submesh is present. Also reports the declared submesh count. - /// - private static int DeclaredSpriteIndexCount(ISpriteRenderData renderData, out int subMeshCount) - { - if (renderData.SubMeshes is { Count: > 0 } subMeshes) - { - subMeshCount = subMeshes.Count; - return (int)subMeshes[0].IndexCount; - } - - subMeshCount = 0; - ReadOnlySpan buffer = renderData.IndexBuffer; - return buffer.Length / sizeof(ushort); - } - - private static void AddPoint(List points, Vector3 vertex) - { - points.Add(vertex.X); - points.Add(vertex.Y); - } - - /// - /// Decodes the sprite mesh indices into a uint array together with its topology and the - /// submesh's index range. The modern path reads the little-endian ushort index buffer - /// and the first submesh; the older path reads the per-vertex index list as a single triangle - /// list. - /// - private static bool TryReadSpriteIndices( - ISpriteRenderData renderData, - out uint[] indices, - out MeshTopology topology, - out int firstIndex, - out int indexCount) - { - indices = []; - topology = MeshTopology.Triangles; - firstIndex = 0; - indexCount = 0; - - if (renderData.VertexData is { Data.Length: > 0 }) - { - ReadOnlySpan buffer = renderData.IndexBuffer; - if (buffer.Length == 0 || (buffer.Length & 1) != 0) - { - return false; - } - - uint[] decoded = new uint[buffer.Length / sizeof(ushort)]; - for (int i = 0; i < decoded.Length; i++) - { - decoded[i] = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(i * sizeof(ushort), sizeof(ushort))); - } - - indices = decoded; - if (renderData.SubMeshes is { Count: > 0 } subMeshes) - { - ISubMesh subMesh = subMeshes[0]; - topology = subMesh.TopologyE; - firstIndex = (int)(subMesh.FirstByte / sizeof(ushort)); - indexCount = (int)subMesh.IndexCount; - } - else - { - indexCount = decoded.Length; - } - - return true; - } - - if (renderData.Indices is { Count: > 0 } indexList) - { - uint[] decoded = new uint[indexList.Count]; - for (int i = 0; i < decoded.Length; i++) - { - decoded[i] = indexList[i]; - } - - indices = decoded; - indexCount = decoded.Length; - return true; - } - - return false; - } - - /// Upper bound on the pixel-centre tests one tight-mesh mask may perform. - private const long MaxSpriteMaskOperations = 268_435_456L; -} diff --git a/src/modules/unity/UnityProbe/UnitySource.Texture.cs b/src/modules/unity/UnityProbe/UnitySource.Texture.cs new file mode 100644 index 0000000..5174763 --- /dev/null +++ b/src/modules/unity/UnityProbe/UnitySource.Texture.cs @@ -0,0 +1,119 @@ +using System; +using System.IO; +using System.Runtime.CompilerServices; +using AssetRipper.Export.Modules.Textures; +using AssetRipper.SourceGenerated.Classes.ClassID_28; +using AssetRipper.SourceGenerated.Enums; +using AssetRipper.SourceGenerated.Extensions; +using AssetRipper.TextureDecoder.Rgb; +using AssetRipper.TextureDecoder.Rgb.Formats; +using BuildTarget = AssetRipper.IO.Files.BuildTarget; + +namespace Unpackrat.UnityProbe; + +internal static partial class UnitySource +{ + private sealed record TextureFormatAdapter(TexturePixelLayout Layout, Func Encode, bool LegacyScratch = false); + private static readonly TextureFormatAdapter CompressedTextureAdapter = + TexturePixels, byte>.Adapter with { LegacyScratch = true }; + + // The pinned converter exposes no pre-allocation layout query. Keep its type selection here + // inside the adapter; parity tests check the real decoder's PixelSize for every mapped format. + private static TextureFormatAdapter TextureAdapter(TextureFormat format) => format switch + { + TextureFormat.Alpha8 => TexturePixels, byte>.Adapter, + TextureFormat.ARGB4444 => TexturePixels.Adapter, + TextureFormat.RGB24 => TexturePixels, byte>.Adapter, + TextureFormat.RGBA32 => TexturePixels, byte>.Adapter, + TextureFormat.ARGB32 => TexturePixels, byte>.Adapter, + TextureFormat.ARGBFloat => TexturePixels, float>.Adapter, + TextureFormat.RGB565 => TexturePixels.Adapter, + TextureFormat.BGR24 => TexturePixels, byte>.Adapter, + TextureFormat.R16 => TexturePixels, ushort>.Adapter, + TextureFormat.RGBA4444 => TexturePixels.Adapter, + TextureFormat.BGRA32_14 or TextureFormat.BGRA32_37 => TexturePixels, byte>.Adapter, + TextureFormat.RHalf => TexturePixels, Half>.Adapter, + TextureFormat.RGHalf => TexturePixels, Half>.Adapter, + TextureFormat.RGBAHalf => TexturePixels, Half>.Adapter, + TextureFormat.RFloat => TexturePixels, float>.Adapter, + TextureFormat.RGFloat => TexturePixels, float>.Adapter, + TextureFormat.RGBAFloat => TexturePixels, float>.Adapter, + TextureFormat.RGB9e5Float => TexturePixels.Adapter, + TextureFormat.RG16 => TexturePixels, byte>.Adapter, + TextureFormat.R8 => TexturePixels, byte>.Adapter, + TextureFormat.RG32 => TexturePixels, ushort>.Adapter, + TextureFormat.RGB48 => TexturePixels, ushort>.Adapter, + TextureFormat.RGBA64 => TexturePixels, ushort>.Adapter, + TextureFormat.R8_SIGNED => TexturePixels, sbyte>.Adapter, + TextureFormat.RG16_SIGNED => TexturePixels, sbyte>.Adapter, + TextureFormat.RGB24_SIGNED => TexturePixels, sbyte>.Adapter, + TextureFormat.RGBA32_SIGNED => TexturePixels, sbyte>.Adapter, + TextureFormat.R16_SIGNED => TexturePixels, short>.Adapter, + TextureFormat.RG32_SIGNED => TexturePixels, short>.Adapter, + TextureFormat.RGB48_SIGNED => TexturePixels, short>.Adapter, + TextureFormat.RGBA64_SIGNED => TexturePixels, short>.Adapter, + _ => CompressedTextureAdapter, + }; + + internal static TexturePixelLayout GetTexturePixelLayout(TextureFormat format) => TextureAdapter(format).Layout; + + private static class TexturePixels + where TColor : unmanaged, IColor + where TChannel : unmanaged + { + public static readonly TextureFormatAdapter Adapter = new( + new(Unsafe.SizeOf(), TColor.HasAlphaChannel ? 4 : 3, + typeof(TColor) != typeof(ColorRGBA) && typeof(TColor) != typeof(ColorRGB)), Encode); + + private static byte[]? Encode(DirectBitmap bitmap) + { + int rows = bitmap.Height * bitmap.Depth; + if (bitmap.Width > ushort.MaxValue || rows > ushort.MaxValue) + { + using MemoryStream stream = new(); + bitmap.SaveAsPng(stream); + return stream.ToArray(); + } + + if (!Adapter.Layout.RequiresConversion) + return FastPngAdapter.Encode(bitmap.Bits, bitmap.Width, rows); + byte[] converted; + if (TColor.HasAlphaChannel) + RgbConverter.Convert, byte>(bitmap.Bits, bitmap.Width, rows, out converted); + else + RgbConverter.Convert, byte>(bitmap.Bits, bitmap.Width, rows, out converted); + return FastPngAdapter.Encode(converted, bitmap.Width, rows); + } + } + + private static bool TryDecodeTexture(ITexture2D texture, TextureFormatAdapter adapter, out DirectBitmap bitmap) + { + bitmap = DirectBitmap.Empty; + long inlineLength = texture.ImageData_C28.LongLength; + ulong source = inlineLength != 0 ? (ulong)inlineLength : texture.StreamData_C28?.Size ?? 0; + long rows = (long)texture.Height_C28 * texture.ImageCount_C28; + // Preserve the old admission corridor for paths with a different, unaudited workspace. + bool legacy = adapter.LegacyScratch || texture.Width_C28 > ushort.MaxValue || rows > ushort.MaxValue + || texture.Collection.Platform == BuildTarget.Xbox360 + || (texture.Collection.Platform == BuildTarget.Switch && texture.PlatformBlob_C28 is { Length: >= 12 }); + if (legacy) + { + if (source > TextureMemoryBudget.LegacySourceBytes + || !Budgets.TryGetTextureBytes(texture.Width_C28, texture.Height_C28, texture.ImageCount_C28, out _)) + return false; + } + else if (!TextureMemoryBudget.TryGetWorkingBytes(texture.Width_C28, texture.Height_C28, + texture.ImageCount_C28, adapter.Layout, source, out _)) + return false; + return TextureConverter.TryConvertToBitmap(texture, out bitmap); + } + + internal static bool TryDecodeTexture(ITexture2D texture, out DirectBitmap bitmap) + => TryDecodeTexture(texture, TextureAdapter(texture.Format_C28E), out bitmap); + + private static byte[]? RenderTexturePng(ITexture2D texture) + { + TextureFormatAdapter adapter = TextureAdapter(texture.Format_C28E); + return TryDecodeTexture(texture, adapter, out DirectBitmap bitmap) ? adapter.Encode(bitmap) : null; + } +} diff --git a/src/modules/unity/UnityProbe/UnitySource.cs b/src/modules/unity/UnityProbe/UnitySource.cs index acbaa4a..68363a1 100644 --- a/src/modules/unity/UnityProbe/UnitySource.cs +++ b/src/modules/unity/UnityProbe/UnitySource.cs @@ -8,7 +8,6 @@ using AssetRipper.Assets.Generics; using AssetRipper.Assets.IO; using AssetRipper.Assets.Metadata; -using AssetRipper.Export.Modules.Textures; using AssetRipper.Import.AssetCreation; using AssetRipper.Import.Configuration; using AssetRipper.Import.Structure; @@ -27,7 +26,6 @@ using AssetRipper.SourceGenerated.Classes.ClassID_329; using AssetRipper.SourceGenerated.Classes.ClassID_43; using AssetRipper.SourceGenerated.Classes.ClassID_49; -using AssetRipper.SourceGenerated.Classes.ClassID_213; using AssetRipper.SourceGenerated.Classes.ClassID_83; using AssetRipper.SourceGenerated.Extensions; @@ -149,58 +147,6 @@ private static long DeclaredTextureSourceSize(ITexture2D texture) : 0; } - /// - /// Reports whether a texture's declared source payload is within the copy cap, using the same - /// inline-first choice the converter makes: only an empty inline buffer defers to the optional - /// streamed size. Read from metadata alone, before the converter resolves or copies anything. - /// - private static bool IsTextureSourceWithinBudget(ITexture2D texture) - { - long inlineLength = texture.ImageData_C28.LongLength; - if (inlineLength > Budgets.MaxTextureSourceBytes) - { - return false; - } - - return inlineLength != 0 - || texture.StreamData_C28 is not { } streamData - || streamData.Size <= Budgets.MaxTextureSourceBytes; - } - - /// - /// Validates a texture from declared metadata alone and decodes it through the pinned - /// converter. A hostile dimension or an oversized source payload is refused before the - /// converter allocates a bitmap or copies a byte. - /// - internal static bool TryDecodeTexture(ITexture2D texture, out DirectBitmap bitmap) - { - bitmap = DirectBitmap.Empty; - if (!Budgets.TryGetTextureBytes(texture.Width_C28, texture.Height_C28, texture.ImageCount_C28, out _)) - { - return false; - } - - if (!IsTextureSourceWithinBudget(texture)) - { - return false; - } - - return TextureConverter.TryConvertToBitmap(texture, out bitmap); - } - - /// Renders a texture through the pinned decoder and writer, or null when it is refused. - private static byte[]? RenderTexturePng(ITexture2D texture) - { - if (!TryDecodeTexture(texture, out DirectBitmap bitmap)) - { - return null; - } - - using MemoryStream stream = new(); - bitmap.SaveAsPng(stream); - return stream.ToArray(); - } - /// /// Loads the Unity game at through the production import pipeline and /// exports a bounded set of resources into . @@ -244,14 +190,6 @@ public static IEnumerable Wrap(IEnumerable assets { yield return HasMeshGeometry(mesh) ? new MeshAsset(mesh) : OtherAsset.Instance; } - else if (asset is ISprite sprite) - { - // A sprite is catalogued only when it resolves a render source that is not a - // provably empty placeholder, so it can never be exported as an unrelated texture. - yield return TryResolveSpriteSource(sprite, out SpriteSource spriteSource) && !IsProvablyEmptyTexture(spriteSource.Texture) - ? new SpriteAsset(sprite, spriteSource) - : OtherAsset.Instance; - } else { yield return OtherAsset.Instance; diff --git a/src/modules/unity/VERSION b/src/modules/unity/VERSION index 7dea76e..227cea2 100644 --- a/src/modules/unity/VERSION +++ b/src/modules/unity/VERSION @@ -1 +1 @@ -1.0.1 +2.0.0 diff --git a/src/modules/unity/native/UnityObserverProbePolicy.h b/src/modules/unity/native/UnityObserverProbePolicy.h index 71fc6f3..a41ca32 100644 --- a/src/modules/unity/native/UnityObserverProbePolicy.h +++ b/src/modules/unity/native/UnityObserverProbePolicy.h @@ -140,7 +140,7 @@ namespace probe enum ResourceKind : int { kResourceNone = 0, - kResourceTexture = 1, // ".png" -> converted Texture2D/Sprite + kResourceTexture = 1, // ".png" -> converted Texture2D kResourceRawBin = 2, // ".bin" -> raw payload, byte-identical to its declared source kResourceBinary = 3, // any other ABI-safe extension -> transformed binary resource }; @@ -215,7 +215,7 @@ namespace probe // // Accepted shape (feature contract): any ordinary directory hierarchy or a // literal braced fallback folder ({Texture2D}, {TextAsset}, {Font}, {VideoClip}, - // {MovieTexture}, {AudioClip}, {Mesh}, {Sprite}) is allowed; the kind is decided + // {MovieTexture}, {AudioClip}, {Mesh}) is allowed; the kind is decided // only by the output extension, never by a directory name. The path must be // relative and must not traverse out of the tree: rooted, drive-qualified, UNC, // empty-component and ".."/"." names are rejected. A leaf is accepted when it diff --git a/src/modules/unity/native/unity_backend.h b/src/modules/unity/native/unity_backend.h index bad7c2e..c6fac72 100644 --- a/src/modules/unity/native/unity_backend.h +++ b/src/modules/unity/native/unity_backend.h @@ -76,8 +76,9 @@ extern "C" /* * Entry kind discriminator. The numeric ids are the frozen external contract: * 1 Texture2D/png, 2 TextAsset/bin, 3 Font/dynamic, 4 VideoClip/dynamic, - * 5 MovieTexture/ogv, 6 AudioClip/dynamic, 7 Mesh/glb, 8 Sprite/png. A managed - * enum may stay 0-based and convert explicitly with +1. + * 5 MovieTexture/ogv, 6 AudioClip/dynamic, 7 Mesh/glb. Id 8 is retired and + * must not be emitted or reassigned. A managed enum may stay 0-based and + * convert explicitly with +1. */ typedef enum UnityEntryKind { @@ -88,7 +89,7 @@ extern "C" UNITY_KIND_MOVIE_TEXTURE = 5, UNITY_KIND_AUDIO_CLIP = 6, UNITY_KIND_MESH = 7, - UNITY_KIND_SPRITE = 8 + UNITY_KIND_RESERVED_8 = 8 } UnityEntryKind; #pragma pack(push, 8) diff --git a/src/modules/unity/observer/UnityExtractor.cpp b/src/modules/unity/observer/UnityExtractor.cpp index df362b1..368cd69 100644 --- a/src/modules/unity/observer/UnityExtractor.cpp +++ b/src/modules/unity/observer/UnityExtractor.cpp @@ -51,11 +51,9 @@ namespace extractor static_assert(sizeof(wchar_t) == sizeof(std::uint16_t), "the Unity ABI takes UTF-16 units"); // Upper bound on the entry count the backend may report. It keeps a corrupt count - // from driving an unbounded listing allocation while leaving ample headroom. A - // selected resource bundle exposes both the Texture2D and the Sprite view of a - // catalogue, so a valid shipping catalogue reaches 281151 entries; the finite - // ceiling is twice the former 200000 budget to cover that texture-plus-sprite - // count without bounding every real catalogue. + // from driving an unbounded listing allocation while leaving ample headroom for + // large resource catalogues. The established finite ceiling stays independent + // of which supported resource kinds occur in a particular input. constexpr std::int32_t max_entries = 400'000; // Upper bound on the UTF-16 units copied for a single label. Only the leading @@ -292,8 +290,7 @@ namespace extractor [[nodiscard]] bool is_reserved_fallback_root(const std::wstring_view root) noexcept { static constexpr std::wstring_view reserved[] = { - L"{Texture2D}", L"{TextAsset}", L"{Font}", L"{VideoClip}", - L"{MovieTexture}", L"{AudioClip}", L"{Mesh}", L"{Sprite}", + L"{Texture2D}", L"{TextAsset}", L"{Font}", L"{VideoClip}", L"{MovieTexture}", L"{AudioClip}", L"{Mesh}", }; return std::ranges::any_of(reserved, [root](const std::wstring_view candidate) { return ascii_case_insensitive_equal(root, candidate); @@ -301,7 +298,7 @@ namespace extractor } // The braced fallback folder of an entry kind, or null for an unknown kind. The - // eight names are the frozen external kind table's directory fallback; a caller + // seven names are the supported external kind table's directory fallback; a caller // rejects an entry whose kind has no folder before it is used. [[nodiscard]] const wchar_t *fallback_folder(const int kind) noexcept { @@ -320,8 +317,6 @@ namespace extractor return L"{AudioClip}"; case UNITY_KIND_MESH: return L"{Mesh}"; - case UNITY_KIND_SPRITE: - return L"{Sprite}"; default: return nullptr; } @@ -333,7 +328,6 @@ namespace extractor { switch (kind) { case UNITY_KIND_TEXTURE_PNG: - case UNITY_KIND_SPRITE: return L"png"; case UNITY_KIND_TEXT_RAW: return L"bin"; diff --git a/src/modules/unity/observer/UnityExtractorTests.cpp b/src/modules/unity/observer/UnityExtractorTests.cpp index db2439d..4466685 100644 --- a/src/modules/unity/observer/UnityExtractorTests.cpp +++ b/src/modules/unity/observer/UnityExtractorTests.cpp @@ -57,14 +57,14 @@ namespace // retry call returns. It models a backend that answers the second call with changed truth. struct retry_descriptor_patch { - std::optional struct_size; - std::optional kind; - std::optional reserved; - std::optional reserved2; - std::optional source_size; - std::optional name_length; - std::optional original_length; - std::optional extension_length; + std::optional struct_size{}; + std::optional kind{}; + std::optional reserved{}; + std::optional reserved2{}; + std::optional source_size{}; + std::optional name_length{}; + std::optional original_length{}; + std::optional extension_length{}; int status = UNITY_OK; }; @@ -819,7 +819,7 @@ namespace return stub_entry{.kind = UNITY_KIND_TEXT_RAW, .name = name, .payload = std::move(payload)}; } - // The additional resource kinds use their frozen external numeric ids (3..8) from + // The additional resource kinds use their frozen external numeric ids (3..7) from // the canonical header; the first Font case used the literal 3 so it compiled // before the header gained the named constants. stub_entry font_entry(const std::u16string &name, std::string payload = {}) @@ -847,11 +847,6 @@ namespace return stub_entry{.kind = UNITY_KIND_MESH, .name = name, .payload = std::move(payload)}; } - stub_entry sprite_entry(const std::u16string &name, std::string payload = {}) - { - return stub_entry{.kind = UNITY_KIND_SPRITE, .name = name, .payload = std::move(payload)}; - } - // A distinct three-unit path component, base-62 coded, so no two entries of a synthetic // deep catalogue share an ancestor directory key. std::u16string encode_component(const std::uint32_t value) @@ -1023,7 +1018,7 @@ extern "C" int32_t UNPACKRAT_UNITY_CDECL unpackrat_unity_close(std::uint64_t ses TEST_CASE("unity adapter: module identity, signature and identity decrypt", "[unity][unit]") { const loaded_module mod; - REQUIRE(mod.version == MAKEMODULEVERSION(1, 0)); + REQUIRE(mod.version == MAKEMODULEVERSION(2, 0)); REQUIRE(mod.api_version == ACTUAL_API_VERSION); const GUID expected{0x4e91f7c6, 0x2a5d, 0x4973, {0x9b, 0x2c, 0x8f, 0x53, 0x71, 0xd6, 0x0a, 0x2e}}; REQUIRE(std::memcmp(&mod.id, &expected, sizeof(GUID)) == 0); @@ -1034,7 +1029,7 @@ TEST_CASE("unity adapter: module identity, signature and identity decrypt", "[un extractor::configure(L"ignored"); extractor::reset_settings(); const extractor::version_info version = extractor::get_version_info(); - REQUIRE(version.major_version == 1); + REQUIRE(version.major_version == 2); REQUIRE(version.minor_version == 0); extractor::extractor adapter; @@ -1390,6 +1385,39 @@ TEST_CASE("unity adapter: a Font entry lists with its braced folder and dynamic mod.api.CloseStorage(storage); } +TEST_CASE("unity adapter: retired sprite kind is rejected before listing or extraction", "[unity][unit]") +{ + stub_binding stub; + // External kind 8 used to request synthesized Sprite PNGs. It is retired rather + // than reassigned, so a stale backend must not present it as another resource. + stub->entries = {stub_entry{.kind = 8, .name = u"old-sprite", .payload = "pixels"}}; + const temp_archive archive; + const loaded_module mod; + + HANDLE storage = nullptr; + REQUIRE(module_open(mod, archive.path, &storage) == SOR_SUCCESS); + REQUIRE(mod.api.PrepareFiles(storage) == FALSE); + REQUIRE(stub->export_calls == 0); + mod.api.CloseStorage(storage); + REQUIRE(stub->close_calls == 1); +} + +TEST_CASE("unity adapter: retired sprite fallback name is an ordinary source directory", "[unity][unit]") +{ + stub_binding stub; + stub->entries = {texture_entry(u"fallback")}; + stub->original_paths = {u"{Sprite}/source.png"}; + const temp_archive archive; + const loaded_module mod; + + HANDLE storage = nullptr; + REQUIRE(module_open(mod, archive.path, &storage) == SOR_SUCCESS); + REQUIRE(mod.api.PrepareFiles(storage) == TRUE); + REQUIRE(item_path(mod, storage, 0) == L"{Sprite}\\source.png"); + REQUIRE(stub->export_calls == 0); + mod.api.CloseStorage(storage); +} + TEST_CASE("unity adapter: every resource kind lists with its own folder and output extension", "[unity][unit]") { stub_binding stub; @@ -1401,9 +1429,8 @@ TEST_CASE("unity adapter: every resource kind lists with its own folder and outp movie_entry(u"movie"), // 5 MovieTexture -> fixed ogv audio_entry(u"sfx"), // 6 AudioClip -> dynamic mesh_entry(u"mesh"), // 7 Mesh -> fixed glb - sprite_entry(u"icon"), // 8 Sprite -> fixed png }; - stub->extensions = {u"", u"", u"ttf", u"mp4", u"", u"ogg", u"", u""}; + stub->extensions = {u"", u"", u"ttf", u"mp4", u"", u"ogg", u""}; const temp_archive archive; const loaded_module mod; @@ -1413,7 +1440,7 @@ TEST_CASE("unity adapter: every resource kind lists with its own folder and outp // Every kind, fixed or dynamic, is listed by one combined metadata call; the dynamic kinds // take the token that call reports, so no scalar extension getter is ever driven. - REQUIRE(stub->metadata_calls == 8); + REQUIRE(stub->metadata_calls == 7); REQUIRE(stub->entry_extension_query_calls == 0); REQUIRE(stub->entry_extension_copy_calls == 0); REQUIRE(stub->export_calls == 0); @@ -1425,7 +1452,6 @@ TEST_CASE("unity adapter: every resource kind lists with its own folder and outp REQUIRE(item_path(mod, storage, 4) == L"{MovieTexture}\\movie.ogv"); REQUIRE(item_path(mod, storage, 5) == L"{AudioClip}\\sfx.ogg"); REQUIRE(item_path(mod, storage, 6) == L"{Mesh}\\mesh.glb"); - REQUIRE(item_path(mod, storage, 7) == L"{Sprite}\\icon.png"); mod.api.CloseStorage(storage); } @@ -2600,16 +2626,15 @@ TEST_CASE("unity adapter: backend open failures reject the archive", "[unity][un } } -TEST_CASE("unity adapter: the open count bound accepts texture-plus-sprite catalogues", "[unity][unit]") +TEST_CASE("unity adapter: the open count bound accepts large resource catalogues", "[unity][unit]") { const temp_archive archive; const loaded_module mod; - // A selected bundle exposes both the Texture2D and the Sprite view of a - // catalogue, so the real shipping game reports 281151 entries and the finite - // ceiling is 400000. Only the open-time count gate is exercised here: no + // Large resource catalogues keep the established finite ceiling of 400000. + // Only the open-time count gate is exercised here: no // listing is requested, so no high-count fake entries are materialised. - SECTION("the shipping game count 281151 opens") + SECTION("a catalogue of 281151 entries opens") { stub_binding stub; stub->count_override = 281'151; @@ -4040,4 +4065,4 @@ TEST_CASE("unity adapter: ordinary fallback suffixes skip occupied names case-in REQUIRE(stub->export_calls == 0); mod.api.CloseStorage(storage); REQUIRE(stub->close_calls == 1); -} \ No newline at end of file +} diff --git a/src/modules/unity/patches/assetripper-stream-ownership.patch b/src/modules/unity/patches/assetripper-stream-ownership.patch index 8cb3276..8747231 100644 --- a/src/modules/unity/patches/assetripper-stream-ownership.patch +++ b/src/modules/unity/patches/assetripper-stream-ownership.patch @@ -16,6 +16,7 @@ new file mode 100644 + MetadataOnly, + CompactMetadataOnly, +} + diff --git a/Source/AssetRipper.IO.Files/FileBase.cs b/Source/AssetRipper.IO.Files/FileBase.cs --- a/Source/AssetRipper.IO.Files/FileBase.cs +++ b/Source/AssetRipper.IO.Files/FileBase.cs @@ -27,6 +28,7 @@ diff --git a/Source/AssetRipper.IO.Files/FileBase.cs b/Source/AssetRipper.IO.Fil public override string? ToString() { return string.IsNullOrEmpty(NameFixed) ? NameFixed : base.ToString(); + diff --git a/Source/AssetRipper.IO.Files/IScheme.cs b/Source/AssetRipper.IO.Files/IScheme.cs --- a/Source/AssetRipper.IO.Files/IScheme.cs +++ b/Source/AssetRipper.IO.Files/IScheme.cs @@ -36,6 +38,7 @@ diff --git a/Source/AssetRipper.IO.Files/IScheme.cs b/Source/AssetRipper.IO.File FileBase Read(SmartStream stream, string filePath, string fileName); + FileBase Read(SmartStream stream, string filePath, string fileName, FileReadMode mode); } + diff --git a/Source/AssetRipper.IO.Files/Scheme.cs b/Source/AssetRipper.IO.Files/Scheme.cs --- a/Source/AssetRipper.IO.Files/Scheme.cs +++ b/Source/AssetRipper.IO.Files/Scheme.cs @@ -71,6 +74,7 @@ diff --git a/Source/AssetRipper.IO.Files/Scheme.cs b/Source/AssetRipper.IO.Files FileBase IScheme.Read(SmartStream stream, string filePath, string fileName) => Read(stream, filePath, fileName); + FileBase IScheme.Read(SmartStream stream, string filePath, string fileName, FileReadMode mode) => Read(stream, filePath, fileName, mode); } + diff --git a/Source/AssetRipper.IO.Files/SchemeReader.cs b/Source/AssetRipper.IO.Files/SchemeReader.cs --- a/Source/AssetRipper.IO.Files/SchemeReader.cs +++ b/Source/AssetRipper.IO.Files/SchemeReader.cs @@ -107,6 +111,7 @@ diff --git a/Source/AssetRipper.IO.Files/SchemeReader.cs b/Source/AssetRipper.IO } public static bool IsReadableFile(string filePath, FileSystem fileSystem) + diff --git a/Source/AssetRipper.IO.Files/FileContainer.cs b/Source/AssetRipper.IO.Files/FileContainer.cs --- a/Source/AssetRipper.IO.Files/FileContainer.cs +++ b/Source/AssetRipper.IO.Files/FileContainer.cs @@ -149,6 +154,7 @@ diff --git a/Source/AssetRipper.IO.Files/FileContainer.cs b/Source/AssetRipper.I } } } + diff --git a/Source/AssetRipper.IO.Files/CompressedFiles/CompressedFile.cs b/Source/AssetRipper.IO.Files/CompressedFiles/CompressedFile.cs --- a/Source/AssetRipper.IO.Files/CompressedFiles/CompressedFile.cs +++ b/Source/AssetRipper.IO.Files/CompressedFiles/CompressedFile.cs @@ -162,6 +168,7 @@ diff --git a/Source/AssetRipper.IO.Files/CompressedFiles/CompressedFile.cs b/Sou } } + diff --git a/Source/AssetRipper.IO.Files/Streams/OwnedReadOnlyRange.cs b/Source/AssetRipper.IO.Files/Streams/OwnedReadOnlyRange.cs new file mode 100644 --- /dev/null @@ -256,6 +263,7 @@ new file mode 100644 + base.Dispose(disposing); + } +} + diff --git a/Source/AssetRipper.IO.Files/Streams/Smart/SmartStream.cs b/Source/AssetRipper.IO.Files/Streams/Smart/SmartStream.cs --- a/Source/AssetRipper.IO.Files/Streams/Smart/SmartStream.cs +++ b/Source/AssetRipper.IO.Files/Streams/Smart/SmartStream.cs @@ -352,6 +360,7 @@ diff --git a/Source/AssetRipper.IO.Files/Streams/Smart/SmartStream.cs b/Source/A } Stream = null; } + diff --git a/Source/AssetRipper.IO.Files/Streams/Smart/SmartStream.SmartRefCount.cs b/Source/AssetRipper.IO.Files/Streams/Smart/SmartStream.SmartRefCount.cs --- a/Source/AssetRipper.IO.Files/Streams/Smart/SmartStream.SmartRefCount.cs +++ b/Source/AssetRipper.IO.Files/Streams/Smart/SmartStream.SmartRefCount.cs @@ -377,6 +386,7 @@ diff --git a/Source/AssetRipper.IO.Files/Streams/Smart/SmartStream.SmartRefCount public int RefCount { get; + diff --git a/Source/AssetRipper.IO.Files/SerializedFiles/Parser/ObjectInfo.cs b/Source/AssetRipper.IO.Files/SerializedFiles/Parser/ObjectInfo.cs --- a/Source/AssetRipper.IO.Files/SerializedFiles/Parser/ObjectInfo.cs +++ b/Source/AssetRipper.IO.Files/SerializedFiles/Parser/ObjectInfo.cs @@ -601,20 +611,55 @@ diff --git a/Source/AssetRipper.IO.Files/SerializedFiles/Parser/ObjectInfo.cs b/ } public override readonly bool Equals([NotNullWhen(true)] object? obj) + diff --git a/Source/AssetRipper.IO.Files/SerializedFiles/IO/SerializedReader.cs b/Source/AssetRipper.IO.Files/SerializedFiles/IO/SerializedReader.cs --- a/Source/AssetRipper.IO.Files/SerializedFiles/IO/SerializedReader.cs +++ b/Source/AssetRipper.IO.Files/SerializedFiles/IO/SerializedReader.cs -@@ -5,6 +5,9 @@ +@@ -5,6 +5,14 @@ internal sealed class SerializedReader : EndianReader { + // Only a bounded metadata reader supplies the full borrowed file extent. + internal long? ObjectDataSourceLength { get; init; } ++ internal long MetadataEnd ++ { ++ get; ++ init; ++ } + public SerializedReader(Stream stream, EndianType endianess, FormatVersion generation) : base(stream, endianess) { Generation = generation; -@@ -49,15 +52,30 @@ +@@ -12,7 +20,7 @@ + + public FileIdentifier[] ReadFileIdentifierArray() + { +- int count = ReadInt32(); ++ int count = ReadArrayCount(22, Generation23Metadata.MaxArrayElements); + FileIdentifier[] array = new FileIdentifier[count]; + for (int i = 0; i < count; i++) + { +@@ -25,7 +33,7 @@ + + public LocalSerializedObjectIdentifier[] ReadLocalSerializedObjectIdentifierArray() + { +- int count = ReadInt32(); ++ int count = ReadArrayCount(12, Generation23Metadata.MaxArrayElements); + LocalSerializedObjectIdentifier[] array = new LocalSerializedObjectIdentifier[count]; + for (int i = 0; i < count; i++) + { +@@ -38,7 +46,9 @@ + + public T[] ReadSerializedTypeArray(bool hasTypeTree) where T : SerializedTypeBase, new() + { +- int count = ReadInt32(); ++ // An external reference can end in three empty names (46 bytes); an ordinary ++ // typed descriptor needs a four-byte dependency count (47 bytes). Use their common minimum. ++ int count = ReadArrayCount(hasTypeTree ? 46 : 23, Generation23Metadata.MaxArrayElements); + T[] array = new T[count]; + for (int i = 0; i < count; i++) + { +@@ -49,18 +59,53 @@ return array; } @@ -622,7 +667,8 @@ diff --git a/Source/AssetRipper.IO.Files/SerializedFiles/IO/SerializedReader.cs + public ObjectInfo[] ReadObjectInfoArray(bool longFileID, ReadOnlySpan types, long dataOffset, + AssetRipper.IO.Files.Streams.Smart.SmartStream? source = null) { - int count = ReadInt32(); +- int count = ReadInt32(); ++ int count = ReadArrayCount(24, Generation23Metadata.MaxObjects); ObjectInfo[] array = new ObjectInfo[count]; for (int i = 0; i < count; i++) { @@ -630,27 +676,51 @@ diff --git a/Source/AssetRipper.IO.Files/SerializedFiles/IO/SerializedReader.cs - instance.Read(this, longFileID, types, dataOffset); + instance.Read(this, longFileID, types, dataOffset, source); array[i] = instance; -+ } -+ return array; -+ } + } + return array; + } + + internal ObjectInfo.BorrowedTable ReadBorrowedObjectInfoArray(bool longFileID, ReadOnlySpan types, long dataOffset, + AssetRipper.IO.Files.Streams.Smart.SmartStream source) + { -+ int count = ReadInt32(); ++ int count = ReadArrayCount(24, Generation23Metadata.MaxObjects); + ObjectInfo.BorrowedTable array = new(count, Generation, types); + for (int i = 0; i < count; i++) + { + ObjectInfo instance = new(); + int slot = instance.Read(this, longFileID, types, dataOffset, source); + array.Set(i, in instance, slot, source, types); - } - return array; - } ++ } ++ return array; ++ } ++ ++ private int ReadArrayCount(int minimumElementBytes, int maximumCount) ++ => Generation == FormatVersion.Unity65 ++ ? Generation23Metadata.ReadCount(this, minimumElementBytes, maximumCount) ++ : ReadInt32(); ++ ++ public new int[] ReadInt32Array() ++ { ++ if (Generation != FormatVersion.Unity65) ++ return base.ReadInt32Array(); ++ long start = BaseStream.Position; ++ _ = Generation23Metadata.ReadCount(this, sizeof(int), Generation23Metadata.MaxArrayElements); ++ BaseStream.Position = start; ++ return base.ReadInt32Array(); ++ } ++ ++ public new Utf8String ReadStringZeroTerm() ++ => Generation == FormatVersion.Unity65 ++ ? Generation23Metadata.ReadString(this) ++ : base.ReadStringZeroTerm(); + + public FormatVersion Generation { get; } + + diff --git a/Source/AssetRipper.IO.Files/SerializedFiles/Parser/SerializedFileMetadata.cs b/Source/AssetRipper.IO.Files/SerializedFiles/Parser/SerializedFileMetadata.cs --- a/Source/AssetRipper.IO.Files/SerializedFiles/Parser/SerializedFileMetadata.cs +++ b/Source/AssetRipper.IO.Files/SerializedFiles/Parser/SerializedFileMetadata.cs -@@ -44,12 +44,45 @@ +@@ -44,12 +44,47 @@ /// public static bool HasRefTypes(FormatVersion generation) => generation >= FormatVersion.SupportsRefObject; @@ -662,6 +732,10 @@ diff --git a/Source/AssetRipper.IO.Files/SerializedFiles/Parser/SerializedFileMe { bool swapEndianess = ReadSwapEndianess(stream, header); EndianType endianess = swapEndianess ? EndianType.BigEndian : EndianType.LittleEndian; +- using SerializedReader reader = new SerializedReader(stream, endianess, header.Version); +- Read(reader, header.DataOffset); ++ long metadataEnd = header.Version == FormatVersion.Unity65 ++ ? Generation23Metadata.GetMetadataEnd(stream, header) : 0; + // Format22's metadata extent excludes its48-byte header. Older layouts and + // ineligible extents retain the original reader and its acceptance rules. + // Pinned big-endian primitives report truncated words as argument errors; @@ -692,13 +766,12 @@ diff --git a/Source/AssetRipper.IO.Files/SerializedFiles/Parser/SerializedFileMe + stream.Position = start; + } + } - using SerializedReader reader = new SerializedReader(stream, endianess, header.Version); -- Read(reader, header.DataOffset); ++ using SerializedReader reader = new SerializedReader(stream, endianess, header.Version) { MetadataEnd = metadataEnd }; + Read(reader, header.DataOffset, source, compactObjects); } private bool ReadSwapEndianess(SmartStream stream, SerializedFileHeader header) -@@ -72,7 +105,7 @@ +@@ -72,7 +107,7 @@ } } @@ -707,7 +780,7 @@ diff --git a/Source/AssetRipper.IO.Files/SerializedFiles/Parser/SerializedFileMe { if (HasSignature(reader.Generation)) { -@@ -99,7 +132,14 @@ +@@ -99,7 +134,14 @@ LongFileID = reader.ReadUInt32(); } @@ -723,7 +796,7 @@ diff --git a/Source/AssetRipper.IO.Files/SerializedFiles/Parser/SerializedFileMe if (HasScriptTypes(reader.Generation)) { -@@ -183,7 +223,23 @@ +@@ -183,7 +225,23 @@ /// public uint LongFileID { get; set; } public bool SwapEndianess { get; set; } @@ -748,6 +821,7 @@ diff --git a/Source/AssetRipper.IO.Files/SerializedFiles/Parser/SerializedFileMe public LocalSerializedObjectIdentifier[] ScriptTypes { get; set; } = []; public FileIdentifier[] Externals { get; set; } = []; public Utf8String UserInformation { get; set; } = Utf8String.Empty; + diff --git a/Source/AssetRipper.IO.Files/SerializedFiles/SerializedFile.cs b/Source/AssetRipper.IO.Files/SerializedFiles/SerializedFile.cs --- a/Source/AssetRipper.IO.Files/SerializedFiles/SerializedFile.cs +++ b/Source/AssetRipper.IO.Files/SerializedFiles/SerializedFile.cs @@ -848,6 +922,7 @@ diff --git a/Source/AssetRipper.IO.Files/SerializedFiles/SerializedFile.cs b/Sou long initialPosition = stream.Position; SerializedFileHeader header = new() { + diff --git a/Source/AssetRipper.IO.Files/BundleFiles/FileStream/BundleDataStream.cs b/Source/AssetRipper.IO.Files/BundleFiles/FileStream/BundleDataStream.cs new file mode 100644 --- /dev/null @@ -950,6 +1025,7 @@ new file mode 100644 + base.Dispose(disposing); + } +} + diff --git a/Source/AssetRipper.IO.Files/BundleFiles/FileStream/BundleFileBlockReader.cs b/Source/AssetRipper.IO.Files/BundleFiles/FileStream/BundleFileBlockReader.cs --- a/Source/AssetRipper.IO.Files/BundleFiles/FileStream/BundleFileBlockReader.cs +++ b/Source/AssetRipper.IO.Files/BundleFiles/FileStream/BundleFileBlockReader.cs @@ -1081,6 +1157,7 @@ diff --git a/Source/AssetRipper.IO.Files/BundleFiles/FileStream/BundleFileBlockR private int m_cachedBlockIndex = -1; private bool m_isDisposed = false; + diff --git a/Source/AssetRipper.IO.Files/BundleFiles/FileStream/FileStreamBundleFile.cs b/Source/AssetRipper.IO.Files/BundleFiles/FileStream/FileStreamBundleFile.cs --- a/Source/AssetRipper.IO.Files/BundleFiles/FileStream/FileStreamBundleFile.cs +++ b/Source/AssetRipper.IO.Files/BundleFiles/FileStream/FileStreamBundleFile.cs @@ -1107,6 +1184,7 @@ diff --git a/Source/AssetRipper.IO.Files/BundleFiles/FileStream/FileStreamBundle { AddFailedFile(new FailedFile() { + diff --git a/Source/AssetRipper.IO.Files/BundleFiles/RawWeb/RawWebBundleFile.cs b/Source/AssetRipper.IO.Files/BundleFiles/RawWeb/RawWebBundleFile.cs --- a/Source/AssetRipper.IO.Files/BundleFiles/RawWeb/RawWebBundleFile.cs +++ b/Source/AssetRipper.IO.Files/BundleFiles/RawWeb/RawWebBundleFile.cs @@ -1127,6 +1205,7 @@ diff --git a/Source/AssetRipper.IO.Files/BundleFiles/RawWeb/RawWebBundleFile.cs foreach (RawWebNode entry in DirectoryInfo.Nodes) { byte[] buffer = new byte[entry.Size]; + diff --git a/Source/AssetRipper.IO.Files/WebFiles/WebFile.cs b/Source/AssetRipper.IO.Files/WebFiles/WebFile.cs --- a/Source/AssetRipper.IO.Files/WebFiles/WebFile.cs +++ b/Source/AssetRipper.IO.Files/WebFiles/WebFile.cs @@ -1143,6 +1222,7 @@ diff --git a/Source/AssetRipper.IO.Files/WebFiles/WebFile.cs b/Source/AssetRippe byte[] buffer = new byte[entry.Size]; stream.Position = entry.Offset + basePosition; stream.ReadExactly(buffer, 0, buffer.Length); + diff --git a/Source/AssetRipper.Assets/IO/AssetFactoryBase.cs b/Source/AssetRipper.Assets/IO/AssetFactoryBase.cs --- a/Source/AssetRipper.Assets/IO/AssetFactoryBase.cs +++ b/Source/AssetRipper.Assets/IO/AssetFactoryBase.cs @@ -1182,6 +1262,7 @@ diff --git a/Source/AssetRipper.Assets/IO/AssetFactoryBase.cs b/Source/AssetRipp + public abstract IUnityObjectBase? ReadAsset(AssetInfo assetInfo, ReadOnlyArraySegment assetData, SerializedType? assetType, IReadOnlyList referenceTypes); } + diff --git a/Source/AssetRipper.Assets/Collections/SerializedAssetCollection.cs b/Source/AssetRipper.Assets/Collections/SerializedAssetCollection.cs --- a/Source/AssetRipper.Assets/Collections/SerializedAssetCollection.cs +++ b/Source/AssetRipper.Assets/Collections/SerializedAssetCollection.cs @@ -1218,6 +1299,7 @@ diff --git a/Source/AssetRipper.Assets/Collections/SerializedAssetCollection.cs + } } } + diff --git a/Source/AssetRipper.Assets/Collections/AssetCollection.cs b/Source/AssetRipper.Assets/Collections/AssetCollection.cs --- a/Source/AssetRipper.Assets/Collections/AssetCollection.cs +++ b/Source/AssetRipper.Assets/Collections/AssetCollection.cs @@ -1280,6 +1362,7 @@ diff --git a/Source/AssetRipper.Assets/Collections/AssetCollection.cs b/Source/A { if (typeof(T).IsAssignableTo(typeof(NullObject))) { + diff --git a/Source/AssetRipper.Import/AssetCreation/GameAssetFactory.cs b/Source/AssetRipper.Import/AssetCreation/GameAssetFactory.cs --- a/Source/AssetRipper.Import/AssetCreation/GameAssetFactory.cs +++ b/Source/AssetRipper.Import/AssetCreation/GameAssetFactory.cs @@ -1384,6 +1467,7 @@ diff --git a/Source/AssetRipper.Import/AssetCreation/GameAssetFactory.cs b/Sourc if (asset is null && TypeTreeNodeStruct.TryMakeFromTpk((ClassIDType)assetInfo.ClassID, version, out TypeTreeNodeStruct releaseRoot, out TypeTreeNodeStruct editorRoot)) { //Engine assets cannot have [SerializeReference] fields, so they never need a type resolver. + diff --git a/Source/AssetRipper.Processing/Scenes/OriginalPathProcessor.cs b/Source/AssetRipper.Processing/Scenes/OriginalPathProcessor.cs --- a/Source/AssetRipper.Processing/Scenes/OriginalPathProcessor.cs +++ b/Source/AssetRipper.Processing/Scenes/OriginalPathProcessor.cs @@ -1402,6 +1486,7 @@ diff --git a/Source/AssetRipper.Processing/Scenes/OriginalPathProcessor.cs b/Sou { switch (asset) { + diff --git a/Source/AssetRipper.IO.Files/SchemeReader.cs b/Source/AssetRipper.IO.Files/SchemeReader.cs --- a/Source/AssetRipper.IO.Files/SchemeReader.cs +++ b/Source/AssetRipper.IO.Files/SchemeReader.cs @@ -1412,6 +1497,7 @@ diff --git a/Source/AssetRipper.IO.Files/SchemeReader.cs b/Source/AssetRipper.IO + using SmartStream stream = SmartStream.OpenReadMulti(filePath, fileSystem); return ReadFile(stream, MultiFileStream.GetFilePath(filePath), MultiFileStream.GetFileName(filePath)); } + diff --git a/Source/AssetRipper.Import/Structure/GameInitializer.cs b/Source/AssetRipper.Import/Structure/GameInitializer.cs --- a/Source/AssetRipper.Import/Structure/GameInitializer.cs +++ b/Source/AssetRipper.Import/Structure/GameInitializer.cs @@ -1469,6 +1555,7 @@ diff --git a/Source/AssetRipper.Import/Structure/GameInitializer.cs b/Source/Ass } } } + diff --git a/Source/AssetRipper.Import/Structure/GameInitializer.EngineResourceInjector.cs b/Source/AssetRipper.Import/Structure/GameInitializer.EngineResourceInjector.cs --- a/Source/AssetRipper.Import/Structure/GameInitializer.EngineResourceInjector.cs +++ b/Source/AssetRipper.Import/Structure/GameInitializer.EngineResourceInjector.cs @@ -1519,6 +1606,7 @@ diff --git a/Source/AssetRipper.Import/Structure/GameInitializer.EngineResourceI } } + diff --git a/Source/AssetRipper.Import/Structure/GameInitializer.VersionChanger.cs b/Source/AssetRipper.Import/Structure/GameInitializer.VersionChanger.cs --- a/Source/AssetRipper.Import/Structure/GameInitializer.VersionChanger.cs +++ b/Source/AssetRipper.Import/Structure/GameInitializer.VersionChanger.cs @@ -1670,6 +1758,7 @@ diff --git a/Source/AssetRipper.Import/Structure/GameInitializer.VersionChanger. } } } + diff --git a/Source/AssetRipper.Import/Structure/GameStructure.cs b/Source/AssetRipper.Import/Structure/GameStructure.cs --- a/Source/AssetRipper.Import/Structure/GameStructure.cs +++ b/Source/AssetRipper.Import/Structure/GameStructure.cs @@ -1796,3 +1885,227 @@ diff --git a/Source/AssetRipper.SourceGenerated.Extensions/VertexDataBlob.cs b/S { if (channels.Count == 0) { + +diff --git a/Source/AssetRipper.IO.Files/SerializedFiles/FormatVersion.cs b/Source/AssetRipper.IO.Files/SerializedFiles/FormatVersion.cs +--- a/Source/AssetRipper.IO.Files/SerializedFiles/FormatVersion.cs ++++ b/Source/AssetRipper.IO.Files/SerializedFiles/FormatVersion.cs +@@ -80,6 +80,10 @@ + /// 2020.1 to x + /// + LargeFilesSupport = 22, ++ /// ++ /// Unity 6000.5 (Unity 6.5) ++ /// ++ Unity65 = 23, + } + internal static class FormatVersionExtensions + { + +diff --git a/Source/AssetRipper.IO.Files/SerializedFiles/Parser/SerializedTypeBase.cs b/Source/AssetRipper.IO.Files/SerializedFiles/Parser/SerializedTypeBase.cs +--- a/Source/AssetRipper.IO.Files/SerializedFiles/Parser/SerializedTypeBase.cs ++++ b/Source/AssetRipper.IO.Files/SerializedFiles/Parser/SerializedTypeBase.cs +@@ -42,6 +42,16 @@ + public TypeTrees.TypeTree OldType { get; } = new(); + public Hash128 ScriptID { get; set; } + public Hash128 OldTypeHash { get; set; } ++ public Hash128 ExtendedTypeHash ++ { ++ get; ++ set; ++ } ++ public bool IsTypeTreeDefinition ++ { ++ get; ++ set; ++ } = true; + + internal void Read(SerializedReader reader, bool hasTypeTree) + { +@@ -78,7 +88,10 @@ + + if (hasTypeTree) + { +- OldType.Read(reader); ++ if (reader.Generation == FormatVersion.Unity65) ++ Generation23Metadata.ReadTypeTree(reader, this); ++ else ++ OldType.Read(reader); + if (reader.Generation < FormatVersion.HasTypeTreeHashes) + { + //OldTypeHash gets recalculated here in a complicated way on 2023. +@@ -121,7 +134,10 @@ + + if (hasTypeTree) + { +- OldType.Write(writer); ++ if (writer.Generation == FormatVersion.Unity65) ++ Generation23Metadata.WriteTypeTree(writer, this); ++ else ++ OldType.Write(writer); + if (HasTypeDependencies(writer.Generation)) + { + WriteTypeDependencies(writer); + +diff --git a/Source/AssetRipper.IO.Files/SerializedFiles/Parser/Generation23Metadata.cs b/Source/AssetRipper.IO.Files/SerializedFiles/Parser/Generation23Metadata.cs +new file mode 100644 +--- /dev/null ++++ b/Source/AssetRipper.IO.Files/SerializedFiles/Parser/Generation23Metadata.cs +@@ -0,0 +1,114 @@ ++using AssetRipper.IO.Files.SerializedFiles.IO; ++ ++namespace AssetRipper.IO.Files.SerializedFiles.Parser; ++ ++/// The bounded generation-23 envelope around the established type-tree reader/writer. ++internal static class Generation23Metadata ++{ ++ internal const long MaxMetadataBytes = 64L * 1024 * 1024; ++ internal const int MaxFrameBytes = 4 * 1024 * 1024; ++ internal const int MaxNodes = 65_536; ++ internal const int MaxStringBytes = 1024 * 1024; ++ internal const int MaxObjects = 1_000_000; ++ internal const int MaxArrayElements = 65_536; ++ private const uint TypeTreeMagic = 0x7474686d; ++ private const int EnvelopeBytes = 20; ++ private const int FrameHeaderBytes = 16; ++ private const int NodeBytes = 32; ++ ++ // These are metadata allocation/iteration limits, not limits on archive payloads. The ++ // qualified Unity 6000.5 corpus reaches 2306 nodes, 2382 string bytes and a 76190-byte ++ // frame. OldType expands every NUL-delimited string into a dictionary, so the string ++ // cap bounds retained dictionary work as well as the source byte buffer. ++ internal static long GetMetadataEnd(Stream stream, SerializedFileHeader header) ++ { ++ long start = stream.Position; ++ long size = header.MetadataSize; ++ // Positive size also rejects start beyond Length; subtracting non-negative positions cannot overflow. ++ if (size <= 0 || size > MaxMetadataBytes || size > stream.Length - start || ++ start > header.DataOffset || size > header.DataOffset - start) ++ throw new InvalidDataException("Generation-23 metadata is outside its bounded source span."); ++ return checked(start + size); ++ } ++ ++ internal static int ReadCount(SerializedReader reader, int minimumElementBytes, int maximumCount) ++ { ++ RequireAvailable(reader, sizeof(int)); ++ int count = reader.ReadInt32(); ++ long remaining = reader.MetadataEnd - reader.BaseStream.Position; ++ if (count < 0 || count > maximumCount || count > remaining / minimumElementBytes) ++ throw new InvalidDataException("Generation-23 metadata array is outside its resource bounds."); ++ return count; ++ } ++ ++ internal static Utf8String ReadString(SerializedReader reader) ++ { ++ long remaining = reader.MetadataEnd - reader.BaseStream.Position; ++ if (remaining <= 0 || !reader.ReadStringZeroTerm((int)Math.Min(remaining, MaxStringBytes), out Utf8String? value)) ++ throw new InvalidDataException("Generation-23 metadata string exceeds its bounded source span."); ++ return value; ++ } ++ ++ internal static void ReadTypeTree(SerializedReader reader, SerializedTypeBase type) ++ { ++ RequireAvailable(reader, EnvelopeBytes); ++ SerializedTypeBase.Hash128 hash = SerializedTypeBase.Hash128.Read(reader); ++ int size = reader.ReadInt32(); ++ if (size == 0) ++ { ++ type.OldType.Nodes.Clear(); ++ type.OldType.StringBuffer = []; ++ type.ExtendedTypeHash = hash; ++ type.IsTypeTreeDefinition = false; ++ return; ++ } ++ if (size < FrameHeaderBytes || size > MaxFrameBytes) ++ throw new InvalidDataException("Generation-23 type-tree frame exceeds its resource bounds."); ++ RequireAvailable(reader, size); ++ long end = checked(reader.BaseStream.Position + size); ++ if (reader.ReadUInt32() != TypeTreeMagic || reader.ReadUInt32() != (uint)FormatVersion.Unity65) ++ throw new InvalidDataException("Invalid generation-23 type-tree frame header."); ++ long nodeStart = reader.BaseStream.Position; ++ int nodes = reader.ReadInt32(); ++ int strings = reader.ReadInt32(); ++ if (GetFrameSize(nodes, strings) != size) ++ throw new InvalidDataException("Generation-23 type-tree frame length does not match its tables."); ++ reader.BaseStream.Position = nodeStart; ++ type.OldType.Read(reader); ++ if (reader.BaseStream.Position != end) ++ throw new InvalidDataException("Generation-23 type-tree reader did not consume its exact frame."); ++ type.ExtendedTypeHash = hash; ++ type.IsTypeTreeDefinition = true; ++ } ++ ++ internal static void WriteTypeTree(SerializedWriter writer, SerializedTypeBase type) ++ { ++ if (!type.IsTypeTreeDefinition) ++ { ++ type.ExtendedTypeHash.Write(writer); ++ writer.Write(0); ++ return; ++ } ++ int size = checked((int)GetFrameSize(type.OldType.Nodes.Count, type.OldType.StringBuffer.Length)); ++ type.ExtendedTypeHash.Write(writer); ++ writer.Write(size); ++ writer.Write(TypeTreeMagic); ++ writer.Write((uint)FormatVersion.Unity65); ++ type.OldType.Write(writer); ++ } ++ ++ private static long GetFrameSize(int nodes, int strings) ++ { ++ if (nodes < 0 || nodes > MaxNodes || strings < 0 || strings > MaxStringBytes) ++ throw new InvalidDataException("Generation-23 type-tree tables exceed their resource bounds."); ++ return checked(FrameHeaderBytes + (long)NodeBytes * nodes + strings); ++ } ++ ++ private static void RequireAvailable(SerializedReader reader, long bytes) ++ { ++ long position = reader.BaseStream.Position; ++ // Every request is positive, so a negative remaining span is rejected by the same comparison. ++ if (bytes > reader.MetadataEnd - position) ++ throw new InvalidDataException("Generation-23 metadata read exceeds its declared source span."); ++ } ++} + +diff --git a/Source/AssetRipper.IO.Files/SerializedFiles/Parser/SerializedType.cs b/Source/AssetRipper.IO.Files/SerializedFiles/Parser/SerializedType.cs +--- a/Source/AssetRipper.IO.Files/SerializedFiles/Parser/SerializedType.cs ++++ b/Source/AssetRipper.IO.Files/SerializedFiles/Parser/SerializedType.cs +@@ -39,6 +39,8 @@ + && OldType.Equals(other.OldType) + && ScriptID == other.ScriptID + && OldTypeHash == other.OldTypeHash ++ && ExtendedTypeHash == other.ExtendedTypeHash ++ && IsTypeTreeDefinition == other.IsTypeTreeDefinition + && TypeDependencies.AsSpan().SequenceEqual(other.TypeDependencies); + } + +@@ -51,6 +53,8 @@ + hash.Add(OldType); + hash.Add(ScriptID); + hash.Add(OldTypeHash); ++ hash.Add(ExtendedTypeHash); ++ hash.Add(IsTypeTreeDefinition); + foreach (int i in TypeDependencies) + { + hash.Add(i); + +diff --git a/Source/AssetRipper.IO.Files/SerializedFiles/Parser/SerializedTypeReference.cs b/Source/AssetRipper.IO.Files/SerializedFiles/Parser/SerializedTypeReference.cs +--- a/Source/AssetRipper.IO.Files/SerializedFiles/Parser/SerializedTypeReference.cs ++++ b/Source/AssetRipper.IO.Files/SerializedFiles/Parser/SerializedTypeReference.cs +@@ -57,6 +57,8 @@ + && OldType.Equals(other.OldType) + && ScriptID == other.ScriptID + && OldTypeHash == other.OldTypeHash ++ && ExtendedTypeHash == other.ExtendedTypeHash ++ && IsTypeTreeDefinition == other.IsTypeTreeDefinition + && ClassName == other.ClassName + && Namespace == other.Namespace + && AsmName == other.AsmName; +@@ -71,6 +73,8 @@ + hash.Add(OldType); + hash.Add(ScriptID); + hash.Add(OldTypeHash); ++ hash.Add(ExtendedTypeHash); ++ hash.Add(IsTypeTreeDefinition); + hash.Add(ClassName); + hash.Add(Namespace); + hash.Add(AsmName);