diff --git a/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index bd73fb6..53a0989 100644 --- a/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -209,13 +209,18 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests bridge, bridge, assetManifestDocuments: null, - receiptSource: bridge); + receiptSource: bridge, + fallbackAssetProvider: transport); var initial = runtime.Connect(); Assert.That(initial.ActiveEntities, Is.GreaterThan(0)); Assert.That(runtime.ActiveWorld.PlayerEntityId, Is.Not.Empty); var playerId = runtime.ActiveWorld.PlayerEntityId; var marker = FindMarker(root, playerId); + Assert.That( + marker.GetComponentsInChildren(includeInactive: true).Length, + Is.GreaterThan(1), + "The generic client substituted its primitive fallback instead of the provider-authored AssetBundle prefab."); var initialPosition = marker.transform.position; var initialVersion = runtime.ActiveVersion; var request = runtime.SubmitMoveVectorIntent(playerId, 1f, 0f, 1f); @@ -248,15 +253,19 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests camera.backgroundColor = new Color(0.035f, 0.055f, 0.08f, 1f); foreach (var entityMarker in root.GetComponentsInChildren()) { - var renderer = entityMarker.GetComponent(); - renderer.material.color = entityMarker.Controllable - ? new Color(0.18f, 0.75f, 1f) - : entityMarker.EntityKind == "enemy" - ? new Color(1f, 0.25f, 0.2f) - : new Color(1f, 0.78f, 0.15f); + foreach (var renderer in entityMarker.GetComponentsInChildren()) + { + renderer.material.color = entityMarker.Controllable + ? new Color(0.18f, 0.75f, 1f) + : entityMarker.EntityKind == "enemy" + ? new Color(1f, 0.25f, 0.2f) + : new Color(1f, 0.78f, 0.15f); + } } var playerMarker = FindMarker(root, playerId); - var playerBounds = playerMarker.GetComponent().bounds; + var playerRenderer = playerMarker.GetComponentInChildren(); + Assert.That(playerRenderer, Is.Not.Null, "The provider-authored player prefab has no renderable visual."); + var playerBounds = playerRenderer.bounds; var cameraDistance = Mathf.Max(8f, playerBounds.extents.magnitude * 4f); camera.transform.position = playerBounds.center + new Vector3(1f, 0.8f, -1.2f).normalized * cameraDistance; camera.transform.LookAt(playerBounds.center); diff --git a/TestProject/Packages/manifest.json b/TestProject/Packages/manifest.json index aa1f4b3..9ac9aad 100644 --- a/TestProject/Packages/manifest.json +++ b/TestProject/Packages/manifest.json @@ -1,6 +1,7 @@ { "dependencies": { "com.unity.test-framework": "1.6.0", + "com.unity.modules.assetbundle": "1.0.0", "com.unity.modules.imageconversion": "1.0.0", "com.unity.modules.physics": "1.0.0", "org.gamecult.cultlib": "file:../../../CultLib/artifacts/unity/org.gamecult.cultlib", diff --git a/TestProject/Packages/packages-lock.json b/TestProject/Packages/packages-lock.json index 0cfe881..c23969d 100644 --- a/TestProject/Packages/packages-lock.json +++ b/TestProject/Packages/packages-lock.json @@ -39,6 +39,12 @@ "org.gamecult.eve.surface": "0.1.0" } }, + "com.unity.modules.assetbundle": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": {} + }, "com.unity.modules.imageconversion": { "version": "1.0.0", "depth": 0, diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs index 00b097e..b5bb58c 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -2,11 +2,13 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Security.Cryptography; using GameCult.Caching; using GameCult.Caching.MessagePack; using GameCult.Eve.Surface; using GameCult.Mesh; using GameCult.Networking; +using UnityEngine; #nullable enable @@ -14,6 +16,7 @@ namespace GameCult.Eve.UnityScene { public sealed class EveUnityCultMeshLiveProviderTransport : IEveUnitySceneLiveProviderTransport, + IEveUnityGameObjectAssetProvider, IDisposable { private const string RemoteShardId = "provider"; @@ -23,7 +26,10 @@ namespace GameCult.Eve.UnityScene typeof(EveProviderAdvertisementDocument), typeof(EveSurfaceDocument), typeof(EveSurfaceCommandRequest), - typeof(EveCommandReceiptDocument) + typeof(EveCommandReceiptDocument), + typeof(EveAssetCatalogDocument), + typeof(CultMeshCdnArtifactManifest), + typeof(CultMeshCdnArtifactChunk) }; private readonly string _replicaPath; @@ -33,6 +39,8 @@ namespace GameCult.Eve.UnityScene private readonly string _runtimeId; private readonly HashSet _pendingCommandIds = new HashSet(StringComparer.Ordinal); private readonly HashSet _publishedReceiptIds = new HashSet(StringComparer.Ordinal); + private readonly Dictionary _prefabs = new Dictionary(StringComparer.Ordinal); + private readonly List _assetBundles = new List(); private CultMeshNode? _node; private CultMeshSnapshotEndpoint? _snapshot; private CultNetDocumentRegistry? _networkRegistry; @@ -95,6 +103,7 @@ namespace GameCult.Eve.UnityScene EnsureOpen(); ResolveAdvertisement(); RefreshSurface(); + RefreshAssetCatalog(); RefreshReceipts(); } @@ -135,6 +144,9 @@ namespace GameCult.Eve.UnityScene public void Dispose() { + foreach (var bundle in _assetBundles) bundle.Unload(unloadAllLoadedObjects: false); + _assetBundles.Clear(); + _prefabs.Clear(); _node?.Dispose(); _node = null; _snapshot = null; @@ -144,6 +156,12 @@ namespace GameCult.Eve.UnityScene _publishedReceiptIds.Clear(); } + public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) + { + if (asset == null) throw new ArgumentNullException(nameof(asset)); + return _prefabs.TryGetValue(asset.AssetRef, out var prefab) ? prefab : null; + } + private void EnsureOpen() { if (_node != null) @@ -194,7 +212,7 @@ namespace GameCult.Eve.UnityScene ResponseTimeout = TimeSpan.FromSeconds(10), MessageIdPrefix = "eve-unity", RudpRuntimeId = _runtimeId, - RudpMaxFragmentBytes = 1200 + RudpMaxFragmentBytes = 2048 } }); } @@ -244,6 +262,132 @@ namespace GameCult.Eve.UnityScene AssetManifestDocumentAvailable?.Invoke(CurrentAssetManifestDocument); } + private void RefreshAssetCatalog() + { + var interaction = RequireWorldInteraction(); + if (string.IsNullOrWhiteSpace(interaction.AssetManifestRecordRef)) + return; + + var catalog = _snapshot! + .FetchDocumentsAsync( + recordKeys: new[] { interaction.AssetManifestRecordRef }, + schemaIds: new[] { EveAssetCatalogDocument.SchemaId }) + .GetAwaiter() + .GetResult() + .FirstOrDefault(); + if (catalog == null || catalog.Version == CurrentAssetCatalogVersion) + return; + + foreach (var bundle in _assetBundles) bundle.Unload(unloadAllLoadedObjects: false); + _assetBundles.Clear(); + _prefabs.Clear(); + var selected = catalog.Assets + .Select(asset => new + { + Asset = asset, + Variant = asset.Variants.FirstOrDefault(variant => + string.Equals(variant.RuntimeId, "unity-scene", StringComparison.Ordinal) && + string.Equals(variant.Platform, CurrentBundlePlatform(), StringComparison.Ordinal)) + }) + .Where(selection => selection.Variant != null) + .ToArray(); + foreach (var group in selected.GroupBy(selection => selection.Variant!.Uri, StringComparer.Ordinal)) + { + var variant = group.First().Variant!; + var manifest = _snapshot.FetchDocumentsAsync( + recordKeys: new[] { variant.Uri }, + schemaIds: new[] { CultMeshCdnSchemaVersions.ArtifactManifest }) + .GetAwaiter() + .GetResult(); + var descriptor = manifest.FirstOrDefault(); + if (descriptor == null) + throw new InvalidOperationException($"Provider asset bundle manifest '{variant.Uri}' was not available."); + var bytes = ReadCachedBundle(descriptor, variant); + VerifyBundle(bytes, variant); + var bundle = AssetBundle.LoadFromMemory(bytes); + if (bundle == null) + throw new InvalidOperationException($"Unity could not load provider asset bundle '{variant.Uri}'."); + _assetBundles.Add(bundle); + foreach (var selection in group) + { + var prefab = bundle.LoadAsset(selection.Variant!.AssetKey); + if (prefab != null) + _prefabs[selection.Asset.AssetRef] = prefab; + } + } + + CurrentAssetCatalogVersion = catalog.Version; + } + + private long CurrentAssetCatalogVersion { get; set; } = -1; + + private static string CurrentBundlePlatform() + { + return Application.platform == RuntimePlatform.WindowsEditor || + Application.platform == RuntimePlatform.WindowsPlayer + ? "StandaloneWindows64" + : Application.platform.ToString(); + } + + private byte[] ReadBundle(CultMeshCdnArtifactManifest manifest) + { + var bytes = new byte[checked((int)manifest.SizeBytes)]; + foreach (var reference in manifest.Chunks.OrderBy(chunk => chunk.Offset)) + { + var chunk = _snapshot! + .FetchDocumentsAsync( + recordKeys: new[] { reference.RecordKey }, + schemaIds: new[] { CultMeshCdnSchemaVersions.ArtifactChunk }) + .GetAwaiter() + .GetResult() + .FirstOrDefault(); + if (chunk == null) + throw new InvalidOperationException($"Provider asset chunk '{reference.RecordKey}' was not available."); + if (chunk.SizeBytes != reference.SizeBytes || chunk.Payload.Length != reference.SizeBytes) + throw new InvalidDataException($"Provider asset chunk '{reference.RecordKey}' size did not match its manifest."); + Buffer.BlockCopy(chunk.Payload, 0, bytes, checked((int)reference.Offset), reference.SizeBytes); + } + return bytes; + } + + private byte[] ReadCachedBundle(CultMeshCdnArtifactManifest manifest, EveAssetVariant variant) + { + var cacheRoot = Environment.GetEnvironmentVariable("EVEUNITY_ASSET_CACHE_PATH"); + if (string.IsNullOrWhiteSpace(cacheRoot)) + cacheRoot = Path.Combine(Application.persistentDataPath, "EveUnity", "assets"); + Directory.CreateDirectory(cacheRoot); + var hash = variant.ContentHash.StartsWith("sha256:", StringComparison.Ordinal) + ? variant.ContentHash.Substring("sha256:".Length) + : variant.ContentHash; + var cachePath = Path.Combine(cacheRoot, hash + ".bundle"); + if (File.Exists(cachePath)) + { + var cached = File.ReadAllBytes(cachePath); + VerifyBundle(cached, variant); + return cached; + } + + var bytes = ReadBundle(manifest); + VerifyBundle(bytes, variant); + var temporaryPath = cachePath + ".tmp"; + File.WriteAllBytes(temporaryPath, bytes); + if (File.Exists(cachePath)) File.Delete(cachePath); + File.Move(temporaryPath, cachePath); + return bytes; + } + + private static void VerifyBundle(byte[] bytes, EveAssetVariant variant) + { + if (bytes.LongLength != variant.SizeBytes) + throw new InvalidOperationException($"Provider asset bundle '{variant.Uri}' size did not match its catalog."); + byte[] digest; + using (var sha256 = SHA256.Create()) + digest = sha256.ComputeHash(bytes); + var actual = "sha256:" + string.Concat(digest.Select(value => value.ToString("x2"))); + if (!string.Equals(actual, variant.ContentHash, StringComparison.Ordinal)) + throw new InvalidOperationException($"Provider asset bundle '{variant.Uri}' hash did not match its catalog."); + } + private void RefreshReceipts() { if (_pendingCommandIds.Count == 0) diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/GameCult.Eve.UnityScene.CultMesh.asmdef b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/GameCult.Eve.UnityScene.CultMesh.asmdef index 034cdf8..405df3f 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/GameCult.Eve.UnityScene.CultMesh.asmdef +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/GameCult.Eve.UnityScene.CultMesh.asmdef @@ -3,7 +3,8 @@ "rootNamespace": "GameCult.Eve.UnityScene", "references": [ "GameCult.Eve.Surface", - "GameCult.Eve.UnityScene" + "GameCult.Eve.UnityScene", + "UnityEngine.AssetBundleModule" ], "includePlatforms": [], "excludePlatforms": [], diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs index 9256609..cd640e8 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs @@ -78,9 +78,19 @@ namespace GameCult.Eve.UnityScene private GameObject InstantiateEntity(EveUnityPlayableWorldEntity entity, EveUnityPlayableWorldAssetBinding asset) { var prefab = _assetProvider.ResolvePrefab(asset); - var instance = prefab != null - ? UnityEngine.Object.Instantiate(prefab, _root) - : GameObject.CreatePrimitive(PrimitiveType.Capsule); + GameObject instance; + if (prefab != null) + { + instance = new GameObject(entity.EntityId); + instance.transform.SetParent(_root, false); + var visual = UnityEngine.Object.Instantiate(prefab, instance.transform); + visual.transform.localPosition = Vector3.zero; + visual.transform.localRotation = Quaternion.identity; + } + else + { + instance = GameObject.CreatePrimitive(PrimitiveType.Capsule); + } if (instance.transform.parent != _root) instance.transform.SetParent(_root, false); diff --git a/scripts/run-aetheria-daemon-world-witness.ps1 b/scripts/run-aetheria-daemon-world-witness.ps1 index ab8c534..a846a4a 100644 --- a/scripts/run-aetheria-daemon-world-witness.ps1 +++ b/scripts/run-aetheria-daemon-world-witness.ps1 @@ -3,7 +3,8 @@ param( [string] $AetheriaRoot = "E:\Projects\Aetheria", [string] $CultLibRoot = "E:\Projects\CultLib", [int] $Port = 3076, - [string] $OutputDirectory = "artifacts\aetheria-daemon" + [string] $OutputDirectory = "artifacts\aetheria-daemon", + [switch] $SkipAssetBundleBuild ) $ErrorActionPreference = "Stop" @@ -12,9 +13,11 @@ $projectRoot = Join-Path $repoRoot "TestProject" $outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) { $OutputDirectory } else { Join-Path $repoRoot $OutputDirectory } $resultsPath = Join-Path $outputRoot "results.xml" $unityLogPath = Join-Path $outputRoot "unity.log" +$bundleBuildLogPath = Join-Path $outputRoot "asset-bundle-build.log" $daemonLogPath = Join-Path $outputRoot "aetheria-daemon.log" $capturePath = Join-Path $outputRoot "aetheria-daemon-world.png" $replicaPath = Join-Path $outputRoot "eve-unity-replica.cc" +$assetCachePath = Join-Path $outputRoot "asset-cache" $statePath = Join-Path $outputRoot "aetheria-witness-state.cc" $daemonProject = Join-Path $AetheriaRoot "Aetheria.State.Daemon\Aetheria.State.Daemon.csproj" @@ -40,6 +43,25 @@ foreach ($ephemeralPath in @( powershell -ExecutionPolicy Bypass -File (Join-Path $CultLibRoot "scripts\build-unity-package.ps1") if ($LASTEXITCODE -ne 0) { throw "CultLib Unity package build failed with exit code $LASTEXITCODE" } +if (-not $SkipAssetBundleBuild) { + $bundleBuilder = Start-Process -FilePath $UnityExe -ArgumentList @( + "-batchmode", "-quit", "-projectPath", $AetheriaRoot, + "-executeMethod", "Aetheria.Editor.EveAssetBundleBuilder.BuildWindows", + "-logFile", $bundleBuildLogPath + ) -PassThru -WindowStyle Hidden + Write-Host "AssetBundle builder PID: $($bundleBuilder.Id)" + Write-Host "AssetBundle build log: $bundleBuildLogPath" + Write-Host "Poll: Get-Content '$bundleBuildLogPath' -Tail 20" + if (-not $bundleBuilder.WaitForExit(240000)) { + Stop-Process -Id $bundleBuilder.Id -Force -ErrorAction SilentlyContinue + throw "Aetheria AssetBundle build exceeded 240 seconds. See $bundleBuildLogPath" + } + if ($bundleBuilder.ExitCode -ne 0) { + Get-Content $bundleBuildLogPath -Tail 160 + throw "Aetheria AssetBundle build failed with exit code $($bundleBuilder.ExitCode)" + } +} + $daemonArguments = @( "run", "--project", $daemonProject, "--", "--root", $AetheriaRoot, @@ -71,6 +93,7 @@ try { $env:EVEUNITY_SURFACE_ID = "aetheria.game" $env:EVEUNITY_REPLICA_PATH = $replicaPath $env:EVEUNITY_AETHERIA_CAPTURE_PATH = $capturePath + $env:EVEUNITY_ASSET_CACHE_PATH = $assetCachePath $arguments = @( "-batchmode", "-projectPath", $projectRoot, "-runTests", "-testPlatform", "PlayMode", @@ -79,9 +102,9 @@ try { "-testResults", $resultsPath, "-logFile", $unityLogPath ) $unity = Start-Process -FilePath $UnityExe -ArgumentList $arguments -PassThru -WindowStyle Hidden - if (-not $unity.WaitForExit(90000)) { + if (-not $unity.WaitForExit(300000)) { Stop-Process -Id $unity.Id -Force -ErrorAction SilentlyContinue - throw "Unity witness exceeded 90 seconds. See $unityLogPath" + throw "Unity witness exceeded 300 seconds. See $unityLogPath" } if ($unity.ExitCode -ne 0) { Get-Content $unityLogPath -Tail 160; throw "Unity witness failed with exit code $($unity.ExitCode)" } [xml] $results = Get-Content $resultsPath -Raw @@ -93,6 +116,6 @@ try { } finally { if ($null -ne $daemon -and -not $daemon.HasExited) { Stop-Process -Id $daemon.Id -Force } - Remove-Item Env:EVEUNITY_PROVIDER_ENDPOINT, Env:EVEUNITY_PROVIDER_ID, Env:EVEUNITY_SURFACE_ID, Env:EVEUNITY_REPLICA_PATH, Env:EVEUNITY_AETHERIA_CAPTURE_PATH -ErrorAction SilentlyContinue + Remove-Item Env:EVEUNITY_PROVIDER_ENDPOINT, Env:EVEUNITY_PROVIDER_ID, Env:EVEUNITY_SURFACE_ID, Env:EVEUNITY_REPLICA_PATH, Env:EVEUNITY_AETHERIA_CAPTURE_PATH, Env:EVEUNITY_ASSET_CACHE_PATH -ErrorAction SilentlyContinue Remove-Item Env:AETHERIA_TRACE_EVE_SNAPSHOTS -ErrorAction SilentlyContinue }