From ac04236f07d42db59f84f99d41ecfe7227aebfba Mon Sep 17 00:00:00 2001 From: AXiX_Official Date: Sat, 7 Nov 2026 21:23:58 +0800 Subject: [PATCH] Add NativeAOT support with compile-time NodeData adapters --- .github/workflows/tests.yml | 69 ++++---- AOT.md | 47 ++++++ Directory.Build.props | 3 + Directory.Build.targets | 10 ++ README.md | 4 +- .../Compiler/Helper.cs | 3 + .../NodeAdapterGenerator.cs | 153 ++++++++++++++++++ ...tyAsset.NET.Variant.SourceGenerator.csproj | 3 +- UnityAsset.NET.sln | 11 ++ UnityAsset.NET/NodeData.cs | 3 + UnityAsset.NET/Types/NodeObject.cs | 86 ++++++++++ UnityAsset.NET/Types/NodeTypeRegistry.cs | 41 +++++ UnityAsset.NET/Types/TypeRegistry.cs | 4 +- UnityAsset.NET/UnityAsset.NET.csproj | 6 +- benchmarks/Compare.ps1 | 28 ++++ .../UnityAsset.NET.AotSmoke/AdapterChecks.cs | 81 ++++++++++ tests/UnityAsset.NET.AotSmoke/Program.cs | 89 ++++++++++ .../UnityAsset.NET.AotSmoke.csproj | 8 + 18 files changed, 613 insertions(+), 36 deletions(-) create mode 100644 AOT.md create mode 100644 Directory.Build.targets create mode 100644 UnityAsset.NET.Variant.SourceGenerator/NodeAdapterGenerator.cs create mode 100644 UnityAsset.NET/Types/NodeObject.cs create mode 100644 UnityAsset.NET/Types/NodeTypeRegistry.cs create mode 100644 benchmarks/Compare.ps1 create mode 100644 tests/UnityAsset.NET.AotSmoke/AdapterChecks.cs create mode 100644 tests/UnityAsset.NET.AotSmoke/Program.cs create mode 100644 tests/UnityAsset.NET.AotSmoke/UnityAsset.NET.AotSmoke.csproj diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 87c849d..6a34686 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,51 +1,58 @@ name: tests - on: push: branches: [master, main] pull_request: workflow_dispatch: - env: DOTNET_NOLOGO: true DOTNET_CLI_TELEMETRY_OPTOUT: true - jobs: test: - name: build and test + name: JIT (NodeData=${{ matrix.nodedata }}) runs-on: ubuntu-latest timeout-minutes: 20 - + strategy: + matrix: + nodedata: [false, true] steps: - uses: actions/checkout@v4 - - # The test project targets net10.0 and the libraries multi-target down to netstandard2.1, so the newest SDK in the - # matrix is the one that has to be present. - - name: Set up .NET - uses: actions/setup-dotnet@v4 + - uses: actions/setup-dotnet@v4 with: - dotnet-version: 11.0.x - - # Restore and build separately so a compile error is reported before any test host is started. - - name: Restore - run: dotnet restore UnityAsset.NET.sln - - - name: Build - run: dotnet build UnityAsset.NET.sln --no-restore --configuration Release - + dotnet-version: | + 10.0.x + 11.0.x + - name: Build libraries and tests + run: dotnet build tests/UnityAsset.NET.Tests -c Release -p:UnityAssetNodeData=${{ matrix.nodedata }} - name: Test - run: > - dotnet test tests/UnityAsset.NET.Tests/UnityAsset.NET.Tests.csproj - --no-build - --configuration Release - --logger "trx;LogFileName=results.trx" - --results-directory TestResults - - - name: Upload test results - # Always: a failed run is exactly when the report is worth having. + run: dotnet test tests/UnityAsset.NET.Tests -c Release --no-build --logger "trx;LogFileName=results.trx" --results-directory TestResults + - name: Smoke and synthetic benchmark + run: dotnet run --project tests/UnityAsset.NET.AotSmoke -c Release -p:UnityAssetNodeData=${{ matrix.nodedata }} -- --benchmark + - name: Build interface generator + if: matrix.nodedata == false + run: dotnet build UnityAsset.NET.UnityTypeGen -c Release + - uses: actions/upload-artifact@v4 if: always() - uses: actions/upload-artifact@v4 with: - name: test-results + name: test-results-${{ matrix.nodedata }} path: TestResults - if-no-files-found: ignore \ No newline at end of file + if-no-files-found: ignore + native-aot: + name: NativeAOT smoke + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 10.0.x + 11.0.x + - name: Publish (automatically selects NodeData) + run: dotnet publish tests/UnityAsset.NET.AotSmoke -c Release -r linux-x64 -p:PublishAot=true -o artifacts/aot-smoke + - name: Native smoke and synthetic benchmark + run: ./artifacts/aot-smoke/UnityAsset.NET.AotSmoke --benchmark + - uses: actions/upload-artifact@v4 + with: + name: native-aot-smoke + path: artifacts/aot-smoke diff --git a/AOT.md b/AOT.md new file mode 100644 index 0000000..4f14156 --- /dev/null +++ b/AOT.md @@ -0,0 +1,47 @@ +# AOT and NodeData + +Build the library from source with `-p:UnityAssetNodeData=true` to select the NodeData backend on a normal JIT runtime. `-p:PublishAot=true` automatically selects it. The corresponding C# symbol is `UNITYASSET_NODEDATA`, which can also be defined directly when compiling the library. These are library build settings: defining a symbol in an application does not change an already compiled NuGet DLL. Publish the application using a project reference to this source tree, or package a library built with the fallback setting. + +The ordinary build keeps runtime code generation. The fallback build excludes the runtime compiler/assembly-loader implementation of `TypeRegistry`. Roslyn remains a build dependency for the interface generator; unreachable compiler helpers are removed by NativeAOT trimming. No runtime interface proxies, reflection-based constructors, or blanket linker roots are used by the fallback backend. + +`NodeAdapterGenerator` generates concrete interface implementations and typed conversion functions during compilation. It consumes the checked-in interfaces, including handwritten partial declarations and generic interfaces, so there is no second TPK-dependent adapter generation step. Run the existing UnityTypeGen tool when updating the interface definitions. Unknown root types retain schema-based reading/writing; named roots also implement `INamedObject`. + +Handwritten predefined types (such as GameObject, SpriteAtlas and the vector types) retain their existing direct parsers. MonoBehaviour retains its existing NodeData implementation. The new fallback replaces the types that previously needed runtime-generated code. Typed arrays and nested objects are cached and flushed into their schema nodes before writing, so in-place mutations are preserved. Fields absent in a Unity version keep the optional interface behavior; fields absent from the interface remain in the node tree and survive serialization. Unity maps retain ordering and duplicate keys. RefSum alternatives are selected from the schema, including empty and jagged arrays. + +## Validation + +```powershell +# Add -p:UnityAssetTargetFramework=net10.0 if only the .NET 10 SDK is installed. +dotnet run --project tests/UnityAsset.NET.AotSmoke -c Release -- --benchmark +dotnet run --project tests/UnityAsset.NET.AotSmoke -c Release -p:UnityAssetNodeData=true -- --benchmark +dotnet publish tests/UnityAsset.NET.AotSmoke -c Release -r win-x64 -p:PublishAot=true -o artifacts/aot-smoke +./artifacts/aot-smoke/UnityAsset.NET.AotSmoke.exe --benchmark --tpk C:/path/uncompressed.tpk +``` + +The smoke executable checks typed reads, setters, optional fields, preservation of unknown fields, duplicate map keys, nested mutation, empty arrays and version-dependent RefSum array ranks, signedness changes, pointer variants, sanitized names and per-pair map alignment. It can run as an ordinary executable or a native binary without a test runner. CI runs the existing unit suite in both JIT configurations and publishes/runs a Linux NativeAOT binary. The interface generator is built separately as a development tool. + +## Benchmarks + +```powershell +./benchmarks/Compare.ps1 -Tpk C:/path/uncompressed.tpk +# Add a serialized file or bundle for the dataset benchmark: +./benchmarks/Compare.ps1 -Tpk C:/path/uncompressed.tpk -Dataset C:/path/assets.bundle +``` + +The script publishes three independent Release outputs and runs five fresh processes per mode. Results are saved under `artifacts/benchmarks/{jit,nodedata,aot}/results.txt`. Use `-Runtime linux-x64` on a Linux host with the NativeAOT prerequisites installed. Cross-compiling between operating systems is not supported by this script. + +The synthetic workload uses the same TextAsset schema and payload in every mode. It reports cold registry setup plus first parse, time per parsed object after warmup, managed allocations per object, and a checksum. The factory is invoked directly, bypassing Asset.Value caching. The optional dataset path reports load time separately from one parse/write pass over every asset. TPK is a schema database, not a real asset dataset. Synthetic results should not be interpreted as representative game-asset throughput; use multiple process samples and a real dataset before drawing performance conclusions. + +Local validation used .NET 10 on Windows x64 and the supplied TPK (366 roots for 2017.1.0b1). Regenerating interfaces with that TPK and minimum version produced no differences. The local workspace also contains an untracked AssetModificationTests.cs from an earlier branch that references a removed API; that file is preserved and excluded only from local unit-test invocations, not from CI. + +## Local synthetic results + +Windows x64, .NET 10, Release; median of five fresh processes, 10,000 warmup parses and 100,000 measured parses per process. These are TextAsset microbenchmarks, not real-dataset results. Timing includes parsing and reading two string properties; the cold column includes registry setup and the first parse. + +| Mode | Cold setup + first parse (ms) | Parse + access (ns/object) | Managed bytes/object | +| --- | ---: | ---: | ---: | +| jit | 1046.909 | 585.9 | 472 | +| nodedata | 20.810 | 910.2 | 1272 | +| aot | 0.189 | 444.1 | 1168 | + +Final local checks: 565/565 unit tests passed in each JIT configuration. All three Release smoke executables passed, including NativeAOT with dynamic code disabled and TPK loading. The final publish emitted no IL trimming/AOT warnings. .NET 11 and Linux CI jobs are configured but were not executed locally. diff --git a/Directory.Build.props b/Directory.Build.props index 95aac88..0746104 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,4 +1,7 @@ + + true + AXiX AXiX diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..7b06f1a --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,10 @@ + + + true + $(DefineConstants);UNITYASSET_NODEDATA + + + + $(UnityAssetTargetFramework) + + diff --git a/README.md b/README.md index 97034b5..4b1f495 100644 --- a/README.md +++ b/README.md @@ -95,4 +95,6 @@ This project stands on the shoulders of these amazing open-source projects: - [AssetStudio](https://github.com/aelurum/AssetStudio) by [aelurum](https://github.com/aelurum) - [AssetsTools.NET](https://github.com/nesrak1/AssetsTools.NET) by [nesrak1](https://github.com/nesrak1) - [UnityPy](https://github.com/K0lb3/UnityPy) by [K0lb3](https://github.com/K0lb3) -- [RustyAssetBundleEXtractor](https://github.com/UniversalGameExtraction/RustyAssetBundleEXtractor) by [UniversalGameExtraction](https://github.com/UniversalGameExtraction) \ No newline at end of file +- [RustyAssetBundleEXtractor](https://github.com/UniversalGameExtraction/RustyAssetBundleEXtractor) by [UniversalGameExtraction](https://github.com/UniversalGameExtraction) + +See [AOT and NodeData](AOT.md) for NativeAOT builds, fallback configuration, tests and benchmarks. diff --git a/UnityAsset.NET.TypeTreeHelper/Compiler/Helper.cs b/UnityAsset.NET.TypeTreeHelper/Compiler/Helper.cs index de89d3e..ff7ad91 100644 --- a/UnityAsset.NET.TypeTreeHelper/Compiler/Helper.cs +++ b/UnityAsset.NET.TypeTreeHelper/Compiler/Helper.cs @@ -1,5 +1,6 @@ using Microsoft.CodeAnalysis.CSharp; using System.Reflection; +using System.Diagnostics.CodeAnalysis; using System.Text; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -225,6 +226,7 @@ public static bool IsNamedAsset(TypeTreeRepr current) private const string NamedObjectInterfaceName = "INamedObject"; + [RequiresUnreferencedCode("Reflection-based schema inspection is a build-time operation; use InterfaceSchemaTable at runtime.")] public static bool DerivesFromNamedObject(Type? interfaceType) => interfaceType is not null && (interfaceType.Name == NamedObjectInterfaceName @@ -402,6 +404,7 @@ public static bool IsNullable(Type csharpType) return csharpType.IsGenericType && csharpType.GetGenericTypeDefinition() == typeof(Nullable<>); } + [RequiresUnreferencedCode("Reflection-based schema inspection requires the complete interface hierarchy. Use InterfaceSchemaTable at runtime.")] public static Dictionary GetAllInterfaceProperties(Type type) { var properties = new Dictionary(); diff --git a/UnityAsset.NET.Variant.SourceGenerator/NodeAdapterGenerator.cs b/UnityAsset.NET.Variant.SourceGenerator/NodeAdapterGenerator.cs new file mode 100644 index 0000000..63805a7 --- /dev/null +++ b/UnityAsset.NET.Variant.SourceGenerator/NodeAdapterGenerator.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace UnityAsset.NET.Variant.SourceGenerator; + +[Generator] +public sealed class NodeAdapterGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) => + context.RegisterSourceOutput(context.CompilationProvider, (output, compilation) => + { + if (compilation.AssemblyName != "UnityAsset.NET") return; + var definitions = compilation.SyntaxTrees.SelectMany(t => t.GetRoot().DescendantNodes().OfType()) + .Where(i => i.SyntaxTree.FilePath.Replace('\\', '/').Contains("Types/PreDefined/")) + .GroupBy(i => i.Identifier.Text).ToDictionary(g => g.Key, g => g.ToArray()); + var generator = new Builder(definitions); + output.AddSource("NodeAdapters.g.cs", generator.Build()); + }); + + private sealed class Builder(Dictionary definitions) + { + private readonly Dictionary ids = new(); + private readonly List pending = new(); + private readonly StringBuilder code = new("#nullable enable\nusing System;\nusing System.Linq;\nusing UnityAsset.NET.IO;\nusing UnityAsset.NET.TypeTreeHelper;\nusing UnityAsset.NET.Types.PreDefined;\nusing UnityAsset.NET.Types.PreDefined.Types;\nusing UnityAsset.NET.Types.PreDefined.Interfaces;\nnamespace UnityAsset.NET.Types;\ninternal static class NodeAdapters {\n"); + private int Id(string type) + { + type = SyntaxFactory.ParseTypeName(type).WithoutTrivia().ToString(); + if (ids.TryGetValue(type, out var id)) return id; + id = ids.Count; ids.Add(type, id); pending.Add(type); return id; + } + public string Build() + { + code.Append("internal static IUnityAsset Create(string name, object v, TypeTreeRepr s, IReader r) => name switch {\n"); + foreach (var entry in definitions.OrderBy(x => x.Key)) + { + if (entry.Value.Any(x => x.TypeParameterList != null) || entry.Key is "IUnityObject" or "IUnityAsset" or "IPreDefinedInterface" or "IPreDefinedObject" or "INamedObject" or "Renderer") continue; + var id = Id(entry.Key); + code.Append('"').Append(entry.Key.StartsWith("I") ? entry.Key.Substring(1) : entry.Key).Append("\" => new Adapter").Append(id).Append("(v,s,r),\n"); + } + code.Append("_ => s.IsNamed ? new NamedNodeObject(v,s,r) : new NodeObject(v,s,r) };\n"); + for (var index = 0; index < pending.Count; index++) Emit(pending[index], index); + return code.Append("}\n").ToString(); + } + private IEnumerable<(string Name, string Type)> Properties(string type) + { + var syntax = SyntaxFactory.ParseTypeName(type); + var name = syntax is GenericNameSyntax g ? g.Identifier.Text : type; + if (!definitions.TryGetValue(name, out var parts)) yield break; + foreach (var part in parts) + { + var parameters = part.TypeParameterList?.Parameters.Select(x => x.Identifier.Text).ToArray() ?? Array.Empty(); + var args = syntax is GenericNameSyntax gen ? gen.TypeArgumentList.Arguments.Select(x => x.ToString()).ToArray() : Array.Empty(); + string Substitute(TypeSyntax t) + { + var result = t; + foreach (var pair in parameters.Zip(args, (p, a) => (p, a))) + result = result.ReplaceNodes(result.DescendantNodesAndSelf().OfType().Where(x => x.Identifier.Text == pair.p), (n, _) => SyntaxFactory.ParseTypeName(pair.a)); + return result.ToString(); + } + if (part.BaseList != null) + foreach (var b in part.BaseList.Types) + foreach (var prop in Properties(Substitute(b.Type))) yield return prop; + foreach (var prop in part.Members.OfType()) + if (prop.Identifier.ValueText != "ClassName") yield return (prop.Identifier.ValueText, Substitute(prop.Type)); + } + } + private void Emit(string type, int id) + { + var syntax = SyntaxFactory.ParseTypeName(type); + string read, write, match; + if (syntax is NullableTypeSyntax nullable) + { + var inner = nullable.ElementType.ToString(); var n = Id(inner); + read = $"Read{n}(v,s,r)"; + write = $"Write{n}(({inner})v!,s)"; + match = $"Match{n}(s)"; + } + else if (syntax is ArrayTypeSyntax array) + { + // Removing one rank handles jagged arrays. + var inner = array.RankSpecifiers.Count == 1 ? array.ElementType.ToString() : array.WithRankSpecifiers(array.RankSpecifiers.RemoveAt(0)).ToString(); + var n = Id(inner); + read = $"NodeObject.Elements(v).Select(x => Read{n}(x,NodeObject.Element(s),r)).ToArray()"; + write = $"NodeObject.ArrayValue(v.Select(x => Write{n}(x,NodeObject.Element(s))).ToArray(),s)"; + match = $"s.SubNodes.Length == 1 && s.SubNodes[0].TypeName == \"Array\" && Match{n}(NodeObject.Element(s))"; + } + else if (syntax is GenericNameSyntax generic && generic.Identifier.Text == "RefSum") + { + var args = generic.TypeArgumentList.Arguments.Select(x => x.ToString()).ToArray(); + var ns = args.Select(Id).ToArray(); + read = string.Concat(ns.Select((n, i) => (n, i)).OrderBy(x => args[x.i].Contains("PPtr") ? 1 : 0).Select(x => $"Match{x.n}(s) ? new {type}({x.i},Read{x.n}(v,s,r)) : ")) + "throw new NotSupportedException($\"No variant matches {s.TypeName}.\")"; + write = string.Concat(ns.Select((n, i) => $"v.Value is {args[i]} a{i} ? Write{n}(a{i},s) : ")) + "throw new NotSupportedException(\"Invalid variant.\")"; + match = string.Join(" || ", ns.Select(n => $"Match{n}(s)")); + } + else if (syntax is GenericNameSyntax tuple && tuple.Identifier.Text == "ValueTuple") + { + var a = Id(tuple.TypeArgumentList.Arguments[0].ToString()); var b = Id(tuple.TypeArgumentList.Arguments[1].ToString()); + read = $"new {type}(Read{a}(NodeObject.PairItem(v,\"first\"),s.SubNodes[0],r),Read{b}(NodeObject.PairItem(v,\"second\"),s.SubNodes[1],r))"; + write = $"NodeObject.Pair(Write{a}(v.Item1,s.SubNodes[0]),Write{b}(v.Item2,s.SubNodes[1]),s)"; + match = "s.TypeName == \"pair\""; + } + else + { + var name = syntax is GenericNameSyntax g ? g.Identifier.Text : type; + if (definitions.ContainsKey(name)) + { + read = $"new Adapter{id}(v,s,r)"; write = "NodeObject.WriteConcrete(v,s)"; + var properties = Properties(type).GroupBy(x => x.Name).Select(x => x.First()).ToArray(); + code.Append($"private sealed class Adapter{id}(object v, TypeTreeRepr s, IReader r) : NodeObject(v,s,r), {type} {{\n"); + foreach (var prop in properties) + { + var n = Id(prop.Type); + code.Append($"public {prop.Type} @{prop.Name} {{ get => Get<{prop.Type}>(\"{prop.Name}\",Read{n},Write{n}); set => Set(\"{prop.Name}\",value,Write{n}); }}\n"); + } + code.Append("}\n"); + } + else if (syntax is PredefinedTypeSyntax || name == "TypelessData") + { read = $"({type})v"; write = "v"; } + else + { read = $"NodeObject.ReadConcrete(v,s,r,x => new {type}(x))"; write = "NodeObject.WriteConcrete(v,s)"; } + var unityName = name.StartsWith("I") && definitions.ContainsKey(name) ? name.Substring(1) : type.Replace(" ", ""); + if (name == "PPtr") unityName = "PPtr<" + (((GenericNameSyntax)syntax).TypeArgumentList.Arguments[0].ToString() == "IUnityObject" ? "Object" : ((GenericNameSyntax)syntax).TypeArgumentList.Arguments[0].ToString().TrimStart('I')) + ">"; + var names = name switch { + "sbyte" => new[]{"SInt8"}, "byte" => new[]{"UInt8"}, "short" => new[]{"short","SInt16"}, "ushort" => new[]{"UInt16","unsigned short"}, + "int" => new[]{"int","SInt32"}, "uint" => new[]{"UInt32","unsigned int","Type*"}, "long" => new[]{"SInt64","long long"}, "ulong" => new[]{"UInt64","unsigned long long","FileSize"}, + _ => new[]{unityName.Split('<')[0]} }; + match = type == "PPtr" ? "s.TypeName.StartsWith(\"PPtr<\", StringComparison.Ordinal)" : name == "PPtr" ? $"s.TypeName == \"{unityName}\"" : string.Join(" || ", names.Select(x => $"s.TypeName == \"{x}\"")); + } + // Interface schemas unify signed/unsigned integers of equal width. Preserve their bits, + // and restore the schema's original signedness when writing NodeData. + var integers = new Dictionary { + ["byte"] = ("sbyte", new[]{"SInt8"}), ["sbyte"] = ("byte", new[]{"UInt8"}), + ["ushort"] = ("short", new[]{"short","SInt16"}), ["short"] = ("ushort", new[]{"UInt16","unsigned short"}), + ["uint"] = ("int", new[]{"int","SInt32"}), ["int"] = ("uint", new[]{"UInt32","unsigned int","Type*"}), + ["ulong"] = ("long", new[]{"SInt64","long long"}), ["long"] = ("ulong", new[]{"UInt64","unsigned long long","FileSize"}) }; + if (integers.TryGetValue(type, out var numeric)) + { + var alternate = string.Join(" || ", numeric.Names.Select(n => $"s.TypeName == \"{n}\"")); + read = $"v is {numeric.Other} n ? unchecked(({type})n) : ({type})v"; + write = $"({alternate}) ? (object)unchecked(({numeric.Other})v) : v"; + match = $"({match}) || ({alternate})"; + } + code.Append($"private static {type} Read{id}(object v, TypeTreeRepr s, IReader r) => {read};\n"); + code.Append($"private static object Write{id}({type} v, TypeTreeRepr s) => {write};\n"); + code.Append($"private static bool Match{id}(TypeTreeRepr s) => {match};\n"); + } + } +} diff --git a/UnityAsset.NET.Variant.SourceGenerator/UnityAsset.NET.Variant.SourceGenerator.csproj b/UnityAsset.NET.Variant.SourceGenerator/UnityAsset.NET.Variant.SourceGenerator.csproj index 40725a6..a3d8ef4 100644 --- a/UnityAsset.NET.Variant.SourceGenerator/UnityAsset.NET.Variant.SourceGenerator.csproj +++ b/UnityAsset.NET.Variant.SourceGenerator/UnityAsset.NET.Variant.SourceGenerator.csproj @@ -1,6 +1,7 @@ - + + false netstandard2.0 latest true diff --git a/UnityAsset.NET.sln b/UnityAsset.NET.sln index e8d963d..ae07ce7 100644 --- a/UnityAsset.NET.sln +++ b/UnityAsset.NET.sln @@ -17,6 +17,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnityAsset.NET.BundleFiles" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnityAsset.NET.Tests", "tests\UnityAsset.NET.Tests\UnityAsset.NET.Tests.csproj", "{05E80A46-B2D2-40D0-9FFF-94DA9A2D5D9E}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnityAsset.NET.AotSmoke", "tests\UnityAsset.NET.AotSmoke\UnityAsset.NET.AotSmoke.csproj", "{BCA350D8-C367-41C2-8BF1-56C279E3F06A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -51,8 +55,15 @@ Global {05E80A46-B2D2-40D0-9FFF-94DA9A2D5D9E}.Debug|Any CPU.Build.0 = Debug|Any CPU {05E80A46-B2D2-40D0-9FFF-94DA9A2D5D9E}.Release|Any CPU.ActiveCfg = Release|Any CPU {05E80A46-B2D2-40D0-9FFF-94DA9A2D5D9E}.Release|Any CPU.Build.0 = Release|Any CPU + {BCA350D8-C367-41C2-8BF1-56C279E3F06A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BCA350D8-C367-41C2-8BF1-56C279E3F06A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BCA350D8-C367-41C2-8BF1-56C279E3F06A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BCA350D8-C367-41C2-8BF1-56C279E3F06A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {BCA350D8-C367-41C2-8BF1-56C279E3F06A} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection EndGlobal diff --git a/UnityAsset.NET/NodeData.cs b/UnityAsset.NET/NodeData.cs index 5ff1938..3fe1296 100644 --- a/UnityAsset.NET/NodeData.cs +++ b/UnityAsset.NET/NodeData.cs @@ -7,6 +7,7 @@ namespace UnityAsset.NET; public class NodeData { + internal NodeData(string type, string name, object value) { Type = type; Name = name; Value = value; } public string Type; public string Name; public object Value; @@ -87,6 +88,7 @@ public static object ReadValue(IReader reader, TypeTreeRepr current) for (int j = 0; j < size; j++) { dic.Add(new KeyValuePair(ReadValue(reader, first), ReadValue(reader, second))); + if (pair.RequiresAlign) reader.Align(4); } value = dic; break; @@ -201,6 +203,7 @@ public static void WriteValue(IWriter writer, TypeTreeRepr current, object value { WriteValue(writer, first, entry.Key); WriteValue(writer, second, entry.Value); + if (pair.RequiresAlign) writer.Align(4); } break; } diff --git a/UnityAsset.NET/Types/NodeObject.cs b/UnityAsset.NET/Types/NodeObject.cs new file mode 100644 index 0000000..93bdf18 --- /dev/null +++ b/UnityAsset.NET/Types/NodeObject.cs @@ -0,0 +1,86 @@ +using UnityAsset.NET.IO; +using UnityAsset.NET.IO.Reader; +using UnityAsset.NET.IO.Writer; +using UnityAsset.NET.TypeTreeHelper; +using UnityAsset.NET.Types.PreDefined; +namespace UnityAsset.NET.Types; +/// Schema-backed object with typed mutable values flushed before serialization. +public class NodeObject : IUnityAsset +{ + protected readonly TypeTreeRepr Schema; + protected readonly IReader Reader; + private readonly Dictionary _members; + private readonly Dictionary _cache = new(); + public string ClassName { get; internal set; } + public NodeObject(object value, TypeTreeRepr schema, IReader reader) + { _members = (Dictionary)value; Schema = schema; Reader = reader; ClassName = schema.TypeName; } + protected T Get(string name, Func read, Func write) + { + if (_cache.TryGetValue(name, out var cached)) return (T)cached.Value!; + var schema = FindMember(name); + if (schema is null) return default!; + var node = _members[schema.Name]; + var value = read(node.Value, schema, Reader); + _cache[name] = (value, () => node.Value = write(value, schema)); + return value; + } + protected void Set(string name, T value, Func write) + { + var schema = FindMember(name); + if (schema is null) return; + var node = _members[schema.Name]; + node.Value = write(value, schema); + _cache[name] = (value, () => node.Value = write(value, schema)); + } + private TypeTreeRepr? FindMember(string name) => Schema.SubNodes.FirstOrDefault(x => x.Name == name) + ?? Schema.SubNodes.FirstOrDefault(x => Identifier(x.Name) == name); + // Match UnityTypeGen's identifier normalization; @ is a C# escape, not part of the stored name. + private static string Identifier(string name) => (name.Length > 0 && char.IsDigit(name[0]) ? "_" : "") + + new string(name.Select(c => char.IsLetterOrDigit(c) || c == '_' ? c : '_').ToArray()); + internal object Flush() { foreach (var entry in _cache.Values) entry.Flush(); return _members; } + public void Write(IWriter writer) => NodeData.WriteValue(writer, Schema, Flush()); + public string ToPlainText() => NodeData.ObjectToString(Flush()); + public AssetNode ToAssetNode(string name = "Base") => ToNode(Flush(), Schema, name); + private static AssetNode ToNode(object value, TypeTreeRepr schema, string name) + { + var result = new AssetNode { Name = name, TypeName = schema.TypeName }; + if (value is Dictionary members) + foreach (var child in schema.SubNodes) result.Children.Add(ToNode(members[child.Name].Value, child, child.Name)); + else if (value is NodeData[] items) + foreach (var item in items) result.Children.Add(ToNode(item.Value, Element(schema), item.Name)); + else result.Value = value; + return result; + } + internal static TypeTreeRepr Element(TypeTreeRepr s) => s.SubNodes[0].SubNodes[1]; + internal static object[] Elements(object v) => v is NodeData[] a ? a.Select(x => x.Value).ToArray() + : ((List>)v).Select(x => (object)x).ToArray(); + internal static object ArrayValue(object[] values, TypeTreeRepr s) => s.TypeName == "map" + ? values.Select(x => (Dictionary)x).Select(x => new KeyValuePair(x["first"].Value, x["second"].Value)).ToList() + : values.Select(x => new NodeData(Element(s).TypeName, Element(s).Name, x)).ToArray(); + internal static object PairItem(object v, string name) => v is KeyValuePair p + ? (name == "first" ? p.Key : p.Value) : ((Dictionary)v)[name].Value; + internal static object Pair(object a, object b, TypeTreeRepr s) => new Dictionary + { ["first"] = new(s.SubNodes[0].TypeName, "first", a), ["second"] = new(s.SubNodes[1].TypeName, "second", b) }; + internal static T ReadConcrete(object value, TypeTreeRepr schema, IReader reader, Func create) + { + using var stream = new MemoryStream(); + using (var writer = new CustomStreamWriter(stream, reader.Endian, leaveOpen: true)) NodeData.WriteValue(writer, schema, value); + var bytes = stream.ToArray(); + IReader source = new MemoryReader(bytes, endian: reader.Endian); + if (reader is AssetReader asset) source = new AssetReader(new BufferProvider(bytes), 0, (ulong)bytes.Length, asset.AssetsFile, reader.Endian); + return create(source); + } + internal static object WriteConcrete(IUnityObject value, TypeTreeRepr schema) + { + if (value is NodeObject node) return node.Flush(); + using var stream = new MemoryStream(); + using (var writer = new CustomStreamWriter(stream, leaveOpen: true)) value.Write(writer); + return NodeData.ReadValue(new MemoryReader(stream.ToArray()), schema); + } + private sealed class BufferProvider(byte[] bytes) : IReaderProvider + { public IReader CreateReader(Endianness endian = Endianness.BigEndian) => new MemoryReader(bytes, endian: endian); } +} +internal sealed class NamedNodeObject(object value, TypeTreeRepr schema, IReader reader) : NodeObject(value, schema, reader), INamedObject +{ + public string m_Name { get => Get("m_Name", (v, _, _) => (string)v, (v, _) => v); set => Set("m_Name", value, (v, _) => v); } +} diff --git a/UnityAsset.NET/Types/NodeTypeRegistry.cs b/UnityAsset.NET/Types/NodeTypeRegistry.cs new file mode 100644 index 0000000..f4c3a6f --- /dev/null +++ b/UnityAsset.NET/Types/NodeTypeRegistry.cs @@ -0,0 +1,41 @@ +#if UNITYASSET_NODEDATA +using UnityAsset.NET.Files.SerializedFiles; +using UnityAsset.NET.IO; +using UnityAsset.NET.TypeTreeHelper; +using UnityAsset.NET.Types.PreDefined; +namespace UnityAsset.NET.Types; +public sealed class TypeRegistryOptions +{ + public bool WriteGeneratedSource { get; init; } + public string? CacheDirectory { get; init; } +} +public sealed class TypeRegistry : IDisposable +{ + public const string AssemblyNameSpace = "UnityAsset.NET.RuntimeTypes"; + private IReadOnlyDictionary _catalog = new Dictionary(); + private bool _disposed; + public TypeRegistry(TypeRegistryOptions? options = null) { } + public string? ArtifactDirectory => null; + public string? GeneratedSourcePath => null; + public void LoadTypes(IReadOnlyDictionary typesToGenerate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(typesToGenerate); + _catalog = new Dictionary(typesToGenerate); + } + public Func GetFactory(SerializedType type) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(type); + if (PreDefinedTypeTable.ByUnityTypeName.TryGetValue(type.ToTypeName(), out var predefined)) return predefined.Create; + if (!_catalog.TryGetValue(type.TypeHash, out var source)) throw new NotSupportedException($"Type {type.Describe()} is not in this session's catalog."); + return reader => + { + var result = (NodeObject)NodeAdapters.Create(source.ClassName, new NodeData(reader, source.TypeTree).Value, source.TypeTree, reader); + result.ClassName = source.ClassName; + return result; + }; + } + public void Dispose() { _disposed = true; _catalog = new Dictionary(); } +} +#endif diff --git a/UnityAsset.NET/Types/TypeRegistry.cs b/UnityAsset.NET/Types/TypeRegistry.cs index 927ecf9..fa69f89 100644 --- a/UnityAsset.NET/Types/TypeRegistry.cs +++ b/UnityAsset.NET/Types/TypeRegistry.cs @@ -1,3 +1,4 @@ +#if !UNITYASSET_NODEDATA using System.Collections.Concurrent; using System.Globalization; using System.Reflection; @@ -331,4 +332,5 @@ private static string ToHex(Hash128 hash) private static void PersistSource(string directory, string generatedSource) => File.WriteAllText(Path.Combine(directory, GeneratedSourceFileName), generatedSource); -} \ No newline at end of file +} +#endif diff --git a/UnityAsset.NET/UnityAsset.NET.csproj b/UnityAsset.NET/UnityAsset.NET.csproj index 1af2ae0..9f69417 100644 --- a/UnityAsset.NET/UnityAsset.NET.csproj +++ b/UnityAsset.NET/UnityAsset.NET.csproj @@ -24,8 +24,8 @@ - - + + @@ -39,10 +39,12 @@ + diff --git a/benchmarks/Compare.ps1 b/benchmarks/Compare.ps1 new file mode 100644 index 0000000..21a4d21 --- /dev/null +++ b/benchmarks/Compare.ps1 @@ -0,0 +1,28 @@ +param( + [string]$Runtime = 'win-x64', + [string]$Framework = 'net10.0', + [string]$Tpk, + [string]$Dataset, + [int]$Repeats = 5 +) +$ErrorActionPreference = 'Stop' +$repo = Split-Path $PSScriptRoot -Parent +$project = Join-Path $repo 'tests/UnityAsset.NET.AotSmoke' +foreach ($mode in @('jit', 'nodedata', 'aot')) { + $output = Join-Path $repo "artifacts/benchmarks/$mode" + $fallback = if ($mode -eq 'jit') { 'false' } else { 'true' } + $native = if ($mode -eq 'aot') { 'true' } else { 'false' } + & dotnet publish $project -c Release -r $Runtime -m:1 "-p:UnityAssetTargetFramework=$Framework" "-p:UnityAssetNodeData=$fallback" "-p:PublishAot=$native" -o $output + if ($LASTEXITCODE -ne 0) { throw "Publish failed: $mode" } + $runArgs = @('--benchmark') + if ($Tpk) { $runArgs += @('--tpk', $Tpk) } + if ($Dataset) { $runArgs += @('--dataset', $Dataset) } + $exe = Join-Path $output ('UnityAsset.NET.AotSmoke' + $(if ($Runtime.StartsWith('win-')) { '.exe' } else { '' })) + $results = for ($i = 0; $i -lt $Repeats; $i++) { + $result = & $exe @runArgs + if ($LASTEXITCODE -ne 0) { throw "Smoke/benchmark failed: $mode" } + $result + } + $results | Set-Content (Join-Path $output 'results.txt') + $results +} diff --git a/tests/UnityAsset.NET.AotSmoke/AdapterChecks.cs b/tests/UnityAsset.NET.AotSmoke/AdapterChecks.cs new file mode 100644 index 0000000..c15dd00 --- /dev/null +++ b/tests/UnityAsset.NET.AotSmoke/AdapterChecks.cs @@ -0,0 +1,81 @@ +using UnityAsset.NET; +using UnityAsset.NET.IO; +using UnityAsset.NET.IO.Reader; +using UnityAsset.NET.IO.Writer; +using UnityAsset.NET.Types; +using UnityAsset.NET.Types.PreDefined.Interfaces; +using UnityAsset.NET.TypeTreeHelper; + +internal static class AdapterChecks +{ + static TypeTreeRepr Leaf(string name, string type) => TypeTreeRepr.Create(name, type, [], false); + static TypeTreeRepr Vector(string name, TypeTreeRepr item) => TypeTreeRepr.Create(name, "vector", [TypeTreeRepr.Create("Array", "Array", [Leaf("size", "int"), item], false)], false); + static byte[] Bytes(Action action) + { + using var stream = new MemoryStream(); + using (var writer = new CustomStreamWriter(stream, leaveOpen: true)) action(writer); + return stream.ToArray(); + } + static T Read(string name, TypeTreeRepr schema, byte[] bytes) + { + var reader = new MemoryReader(bytes); + return (T)NodeAdapters.Create(name, new NodeData(reader, schema).Value, schema, reader); + } + public static void Run() + { + // Same interface, different array ranks in different Unity versions, including an empty array. + foreach (var nested in new[] { false, true }) + { + var item = nested ? Vector("data", Leaf("data", "UInt32")) : Leaf("data", "UInt32"); + var schema = TypeTreeRepr.Create("Base", "Shader", [Vector("offsets", item)], false); + var bytes = Bytes(w => { w.WriteInt32(1); if (nested) w.WriteInt32(1); w.WriteUInt32(7); }); + var shader = Read("Shader", schema, bytes); + if (nested) ((uint[][])shader.offsets.Value)[0][0] = 9; + else ((uint[])shader.offsets.Value)[0] = 9; + var roundtrip = Read("Shader", schema, Bytes(shader.Write)); + var actual = nested ? ((uint[][])roundtrip.offsets.Value)[0][0] : ((uint[])roundtrip.offsets.Value)[0]; + if (actual != 9) throw new Exception("Variant array mutation lost."); + _ = Read("Shader", schema, Bytes(w => w.WriteInt32(0))).offsets; + } + // Duplicate keys must survive: Unity maps are sequences, not dictionaries. + var pair = TypeTreeRepr.Create("data", "pair", [Leaf("first", "string"), Leaf("second", "string")], true); + var map = TypeTreeRepr.Create("m_SceneHashes", "map", Vector("unused", pair).SubNodes, false); + var bundleSchema = TypeTreeRepr.Create("Base", "AssetBundle", [map], false); + var bundle = Read("AssetBundle", bundleSchema, Bytes(w => { + w.WriteInt32(2); w.WriteSizedString("key"); w.WriteSizedString("one"); w.Align(4); w.WriteSizedString("key"); w.WriteSizedString("two"); w.Align(4); + })); + var hashes = bundle.m_SceneHashes!; + hashes[1] = ("key", "edited"); + var copy = Read("AssetBundle", bundleSchema, Bytes(bundle.Write)).m_SceneHashes!; + if (copy.Length != 2 || copy[0].Item2 != "one" || copy[1].Item2 != "edited") throw new Exception("Map round trip failed."); + // Nested interface mutation must flush without assigning the parent property again. + var settings = TypeTreeRepr.Create("m_TextureSettings", "GLTextureSettings", [Leaf("m_FilterMode", "int")], false); + var textureSchema = TypeTreeRepr.Create("Base", "Texture2D", [settings], false); + var texture = Read("Texture2D", textureSchema, Bytes(w => w.WriteInt32(1))); + texture.m_TextureSettings.m_FilterMode = 2; + if (Read("Texture2D", textureSchema, Bytes(texture.Write)).m_TextureSettings.m_FilterMode != 2) throw new Exception("Nested interface mutation lost."); + var imageSchema = TypeTreeRepr.Create("Base", "Texture2D", [Leaf("image data", "TypelessData")], false); + var image = Read("Texture2D", imageSchema, Bytes(w => { w.WriteInt32(2); w.WriteBytes(new byte[] { 1, 2 }); })); + image.image_data.data[1] = 3; + if (Read("Texture2D", imageSchema, Bytes(image.Write)).image_data.data[1] != 3) + throw new Exception("Sanitized field name did not map to the serialized field."); + var signedSchema = TypeTreeRepr.Create("Base", "Texture2D", [Leaf("m_CompleteImageSize", "int")], false); + var signed = Read("Texture2D", signedSchema, Bytes(w => w.WriteInt32(-1))); + if (signed.m_CompleteImageSize != uint.MaxValue) throw new Exception("Signed-to-unsigned conversion lost bits."); + signed.m_CompleteImageSize = 0x80000000u; + var signedBytes = Bytes(signed.Write); + if (new NodeData(new MemoryReader(signedBytes), signedSchema).As>()["m_CompleteImageSize"].As() != int.MinValue) + throw new Exception("Original signed schema was not preserved."); + foreach (var target in new[] { "Component", "SomeUnknownClass" }) + { + var pointerSchema = TypeTreeRepr.Create("Base", "PrefabModification", [Vector("m_RemovedComponents", Leaf("data", $"PPtr<{target}>"))], false); + var prefab = Read("PrefabModification", pointerSchema, Bytes(w => w.WriteInt32(0))); + var value = prefab.m_RemovedComponents.Value; + if (target == "Component" && value is not UnityAsset.NET.Types.PreDefined.Types.PPtr[]) + throw new Exception("Specific pointer variant was shadowed by the generic pointer."); + if (target != "Component" && value is not UnityAsset.NET.Types.PreDefined.Types.PPtr[]) + throw new Exception("Unknown pointer target did not fall back to IUnityObject."); + } + Console.WriteLine("PASS: variant ranks, empty arrays, duplicate map keys, nested mutations, signedness and pointer variants."); + } +} diff --git a/tests/UnityAsset.NET.AotSmoke/Program.cs b/tests/UnityAsset.NET.AotSmoke/Program.cs new file mode 100644 index 0000000..20ab3a8 --- /dev/null +++ b/tests/UnityAsset.NET.AotSmoke/Program.cs @@ -0,0 +1,89 @@ +using System.Diagnostics; +using UnityAsset.NET; +using UnityAsset.NET.Enums; +using UnityAsset.NET.Files.SerializedFiles; +using UnityAsset.NET.IO; +using UnityAsset.NET.IO.Reader; +using UnityAsset.NET.IO.Writer; +using UnityAsset.NET.Types; +using UnityAsset.NET.Types.PreDefined.Interfaces; +using UnityAsset.NET.TypeTreeHelper; + +var schema = TypeTreeRepr.Create("Base", "TextAsset", [ + TypeTreeRepr.Create("m_Name", "string", [], true), + TypeTreeRepr.Create("m_Script", "string", [], true), + TypeTreeRepr.Create("futureField", "int", [], false)], false); +var hash = new Hash128(Enumerable.Repeat((byte)42, 16).ToArray()); +var type = new SerializedType((int)AssetClassID.TextAsset, false, -1, null, hash, false, [], null, null, null); +var catalog = new Dictionary { [hash] = new("TextAsset", "2021.3.0f1", schema) }; +byte[] Serialize(Action action) +{ + using var stream = new MemoryStream(); + using (var writer = new CustomStreamWriter(stream, leaveOpen: true)) action(writer); + return stream.ToArray(); +} +var bytes = Serialize(w => { w.WriteSizedString("name"); w.Align(4); w.WriteSizedString("payload"); w.Align(4); w.WriteInt32(123); }); +var cold = Stopwatch.StartNew(); +using var registry = new TypeRegistry(); +registry.LoadTypes(catalog); +var factory = registry.GetFactory(type); +var asset = (ITextAsset)factory(new MemoryReader(bytes)); +cold.Stop(); +if (asset.m_Name != "name" || asset.m_Script != "payload" || asset.m_PathName != null) throw new Exception("Typed read failed."); +asset.m_Name = "edited"; +asset.m_Script = "changed payload"; +asset.m_PathName = "absent"; +var changed = Serialize(asset.Write); +var reread = (ITextAsset)factory(new MemoryReader(changed)); +if (reread.m_Name != "edited" || reread.m_Script != "changed payload" || reread.m_PathName != null) throw new Exception("Mutation failed."); +var raw = new NodeData(new MemoryReader(changed), schema).As>(); +if (raw["futureField"].As() != 123) throw new Exception("Unknown field was lost."); +AdapterChecks.Run(); +Console.WriteLine("PASS: typed read, optional field, edits, unknown-field preservation, round trip."); +if (args.Contains("--benchmark")) +{ + const int iterations = 100000; + for (var i = 0; i < 10000; i++) _ = factory(new MemoryReader(bytes)); + var allocated = GC.GetAllocatedBytesForCurrentThread(); + var watch = Stopwatch.StartNew(); + long checksum = 0; + for (var i = 0; i < iterations; i++) + { + var value = (ITextAsset)factory(new MemoryReader(bytes)); + checksum += value.m_Name.Length + value.m_Script.Length; + } + watch.Stop(); + allocated = GC.GetAllocatedBytesForCurrentThread() - allocated; +#if UNITYASSET_NODEDATA + const string backend = "NodeData"; +#else + const string backend = "Runtime codegen"; +#endif + Console.WriteLine($"backend={backend}; dynamic={System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported}; cold_ms={cold.Elapsed.TotalMilliseconds:F3}; ns_per_object={watch.Elapsed.TotalNanoseconds / iterations:F1}; bytes_per_object={allocated / iterations}; checksum={checksum}"); +} + +var tpkIndex = Array.IndexOf(args, "--tpk"); +if (tpkIndex >= 0) +{ + using var tpk = TpkTypeTreeCatalog.Open(args[tpkIndex + 1]); + var roots = tpk.GetRootTypeNodes("2017.1.0b1"); + if (!roots.ContainsKey("TextAsset")) throw new Exception("TPK lookup failed."); + Console.WriteLine($"PASS: TPK catalog ({roots.Count} roots)."); +} +var datasetIndex = Array.IndexOf(args, "--dataset"); +if (datasetIndex >= 0) +{ + using var manager = new AssetManager(options: new UnitySessionOptions { TpkFilePath = tpkIndex >= 0 ? args[tpkIndex + 1] : null }); + var watch = Stopwatch.StartNew(); + await manager.LoadAsync([args[datasetIndex + 1]]); + var loadMs = watch.Elapsed.TotalMilliseconds; + watch.Restart(); + long totalBytes = 0; + foreach (var entry in manager.LoadedAssets) + { + // Factory bypasses Asset.Value's weak-reference cache on every iteration. + var value = manager.Factory!.Create(entry.Info.Type, entry.DataReader); + totalBytes += Serialize(value.Write).Length; + } + Console.WriteLine($"dataset_assets={manager.LoadedAssets.Count}; load_ms={loadMs:F3}; parse_write_ms={watch.Elapsed.TotalMilliseconds:F3}; serialized_bytes={totalBytes}"); +} diff --git a/tests/UnityAsset.NET.AotSmoke/UnityAsset.NET.AotSmoke.csproj b/tests/UnityAsset.NET.AotSmoke/UnityAsset.NET.AotSmoke.csproj new file mode 100644 index 0000000..5c107aa --- /dev/null +++ b/tests/UnityAsset.NET.AotSmoke/UnityAsset.NET.AotSmoke.csproj @@ -0,0 +1,8 @@ + + + Exe + net10.0 + false + + +