Load provider world assets in generic Unity

This commit is contained in:
Meta Vens 2026-07-10 06:00:36 +01:00
commit 100dfea51a
7 changed files with 212 additions and 18 deletions

View file

@ -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<Transform>(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<EveUnityPlayableWorldEntityMarker>())
{
var renderer = entityMarker.GetComponent<Renderer>();
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>())
{
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<Renderer>().bounds;
var playerRenderer = playerMarker.GetComponentInChildren<Renderer>();
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);

View file

@ -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",

View file

@ -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,

View file

@ -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<string> _pendingCommandIds = new HashSet<string>(StringComparer.Ordinal);
private readonly HashSet<string> _publishedReceiptIds = new HashSet<string>(StringComparer.Ordinal);
private readonly Dictionary<string, GameObject> _prefabs = new Dictionary<string, GameObject>(StringComparer.Ordinal);
private readonly List<AssetBundle> _assetBundles = new List<AssetBundle>();
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<EveAssetCatalogDocument>(
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<CultMeshCdnArtifactManifest>(
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<GameObject>(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<CultMeshCdnArtifactChunk>(
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)

View file

@ -3,7 +3,8 @@
"rootNamespace": "GameCult.Eve.UnityScene",
"references": [
"GameCult.Eve.Surface",
"GameCult.Eve.UnityScene"
"GameCult.Eve.UnityScene",
"UnityEngine.AssetBundleModule"
],
"includePlatforms": [],
"excludePlatforms": [],

View file

@ -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);

View file

@ -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
}