Add Unity GameObject playable world sink

This commit is contained in:
Meta Vens 2026-07-09 18:19:13 +01:00
commit c89224aec1
10 changed files with 229 additions and 14 deletions

View file

@ -617,11 +617,14 @@ Recently cut:
`EveUnityPlayableWorldPresenter` now maps provider-authored entity rows and
asset refs into scene sink operations, including removal of entities absent
from later provider snapshots, without importing Aetheria entity/prefab
classes. The Unity client boundary is generic before it learns live transport.
classes. `EveUnityGameObjectPlayableWorldSceneSink` now provides the first
Unity-native sink body: generic `GameObject` creation/update/removal,
transform/radius application, entity markers, and a swappable asset-provider
hook. The Unity client boundary is generic before it learns live transport.
The next blocker is the live generic Unity player loop: implement that
CultMesh/CultNet adapter, provide Unity `GameObject`/prefab-backed scene sink
and asset resolver implementations, and wait for provider receipts/next frames
rather than renderer-local simulation.
CultMesh/CultNet adapter, resolve provider assets through CultMesh/CultCache
manifests, and wait for provider receipts/next frames rather than
renderer-local simulation.
- EveElectron capture lifecycle now carries the same kind of structured pending
capture contract. `captureContract` names the Electron shell runtime, capture
kind, PNG artifact pattern, conformance attachment point, required Aetheria

View file

@ -487,9 +487,13 @@ types. It also declares `providerSurfaceSource` through
runtime-owned port to implement. The runtime now also declares
`playableWorldScenePresentation`: `EveUnityPlayableWorldPresenter` maps
provider-authored entity rows and asset refs into scene sink operations while
removing entities absent from later provider snapshots. The remaining blocker is
that live adapter, Unity `GameObject`/prefab-backed scene sink and asset
resolver, and Unity screenshot or frame-capture PNG production from EveUnity.
removing entities absent from later provider snapshots. The runtime now also
declares `unityGameObjectSceneSink`: `EveUnityGameObjectPlayableWorldSceneSink`
creates or updates generic `GameObject` instances, attaches
`EveUnityPlayableWorldEntityMarker`, and resolves prefabs through a swappable
asset-provider hook. The remaining blocker is the live adapter, provider asset
resolution against CultMesh/CultCache manifests, and Unity screenshot or
frame-capture PNG production from EveUnity.
Runtime capture probe coverage is exported as `runtimeCaptureProbeCoverage[]`.
Each record joins runtime status, capture owner, lifecycle capture contract,

View file

@ -167,14 +167,18 @@ while `IEveUnitySceneCommandSink` receives command envelopes.
`EveUnityPlayableWorldPresenter` maps the provider-authored entity rows and
asset refs into scene operations through `IEveUnityPlayableWorldSceneSink` and
`IEveUnityPlayableWorldAssetResolver`, including removal of entities that
disappear from later provider snapshots. Aetheria-specific names appear only as
disappear from later provider snapshots. `EveUnityGameObjectPlayableWorldSceneSink`
is the first Unity-native implementation: it creates or updates generic
`GameObject` instances, applies transforms/radii, attaches an
`EveUnityPlayableWorldEntityMarker`, and resolves provider asset refs through an
`IEveUnityGameObjectAssetProvider` hook. Aetheria-specific names appear only as
provider-authored data and command ids; the Unity client code does not import
Aetheria runtime types, prefab classes, or apply movement locally.
This is not yet the final playable client. The remaining cut is a live
CultMesh/CultNet provider subscription adapter implementing those source/sink
ports, Unity `GameObject`/prefab-backed implementations of the scene sink and
asset resolver, and proof that daemon receipts drive the next frame rather than
ports, provider asset resolution against CultMesh/CultCache manifests, and proof
that daemon receipts drive the next rendered Unity frame rather than
renderer-local state.
## Non-Goals

View file

@ -0,0 +1,181 @@
using System;
using System.Collections.Generic;
using UnityEngine;
#nullable enable
namespace GameCult.Eve.UnityScene
{
public interface IEveUnityGameObjectAssetProvider
{
GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset);
}
public sealed class EveUnityGameObjectPlayableWorldSceneSink : IEveUnityPlayableWorldSceneSink
{
private readonly Transform _root;
private readonly IEveUnityGameObjectAssetProvider _assetProvider;
private readonly Dictionary<string, GameObject> _instances = new Dictionary<string, GameObject>(StringComparer.Ordinal);
public EveUnityGameObjectPlayableWorldSceneSink(
Transform root,
IEveUnityGameObjectAssetProvider assetProvider)
{
_root = root != null ? root : throw new ArgumentNullException(nameof(root));
_assetProvider = assetProvider ?? throw new ArgumentNullException(nameof(assetProvider));
}
public int ActiveEntityCount => _instances.Count;
public void ConfigureWorld(EveUnityPlayableWorldProjection world)
{
if (world == null) throw new ArgumentNullException(nameof(world));
if (!string.IsNullOrWhiteSpace(world.WorldRootId))
_root.name = world.WorldRootId;
}
public void UpsertEntity(EveUnityPlayableWorldEntity entity, EveUnityPlayableWorldAssetBinding asset)
{
if (entity == null) throw new ArgumentNullException(nameof(entity));
if (asset == null) throw new ArgumentNullException(nameof(asset));
if (string.IsNullOrWhiteSpace(entity.EntityId))
return;
GameObject instance;
if (!_instances.TryGetValue(entity.EntityId, out instance) || instance == null)
{
instance = InstantiateEntity(entity, asset);
_instances[entity.EntityId] = instance;
}
else
{
var marker = instance.GetComponent<EveUnityPlayableWorldEntityMarker>();
if (marker != null && !string.Equals(marker.AssetRef, asset.AssetRef, StringComparison.Ordinal))
{
DestroyInstance(instance);
instance = InstantiateEntity(entity, asset);
_instances[entity.EntityId] = instance;
}
}
ApplyEntity(instance, entity, asset);
}
public void RemoveEntity(string entityId)
{
if (string.IsNullOrWhiteSpace(entityId))
return;
GameObject instance;
if (!_instances.TryGetValue(entityId, out instance))
return;
_instances.Remove(entityId);
if (instance != null)
DestroyInstance(instance);
}
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);
if (instance.transform.parent != _root)
instance.transform.SetParent(_root, false);
return instance;
}
private static void ApplyEntity(
GameObject instance,
EveUnityPlayableWorldEntity entity,
EveUnityPlayableWorldAssetBinding asset)
{
instance.name = string.IsNullOrWhiteSpace(entity.Label)
? entity.EntityId
: $"{entity.EntityId} ({entity.Label})";
instance.transform.localPosition = new Vector3(entity.PositionX, entity.PositionY, entity.PositionZ);
instance.transform.localRotation = Quaternion.Euler(0f, entity.RotationY, 0f);
if (entity.Radius > 0f)
instance.transform.localScale = Vector3.one * entity.Radius;
var marker = instance.GetComponent<EveUnityPlayableWorldEntityMarker>();
if (marker == null)
marker = instance.AddComponent<EveUnityPlayableWorldEntityMarker>();
marker.Apply(entity, asset);
}
private static void DestroyInstance(GameObject instance)
{
if (Application.isPlaying)
UnityEngine.Object.Destroy(instance);
else
UnityEngine.Object.DestroyImmediate(instance);
}
}
public sealed class EveUnityResourcesAssetProvider : IEveUnityGameObjectAssetProvider
{
public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset)
{
if (asset == null) throw new ArgumentNullException(nameof(asset));
var resourcesPath = NormalizeResourcesPath(asset.AssetRef);
return string.IsNullOrWhiteSpace(resourcesPath)
? null
: Resources.Load<GameObject>(resourcesPath);
}
private static string NormalizeResourcesPath(string assetRef)
{
if (string.IsNullOrWhiteSpace(assetRef))
return "";
var path = assetRef.Trim();
foreach (var prefix in new[] { "resources://", "resource://", "Resources/" })
{
if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
path = path.Substring(prefix.Length);
break;
}
}
if (path.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase))
path = path.Substring(0, path.Length - ".prefab".Length);
return path;
}
}
public sealed class EveUnityPlayableWorldEntityMarker : MonoBehaviour
{
public string EntityId = "";
public string NodeId = "";
public string EntityKind = "";
public string Label = "";
public string Faction = "";
public string AssetRef = "";
public string PresentationKind = "";
public bool Selectable;
public bool Controllable;
public void Apply(EveUnityPlayableWorldEntity entity, EveUnityPlayableWorldAssetBinding asset)
{
if (entity == null) throw new ArgumentNullException(nameof(entity));
if (asset == null) throw new ArgumentNullException(nameof(asset));
EntityId = entity.EntityId;
NodeId = entity.NodeId;
EntityKind = entity.EntityKind;
Label = entity.Label;
Faction = entity.Faction;
AssetRef = asset.AssetRef;
PresentationKind = asset.PresentationKind;
Selectable = entity.Selectable;
Controllable = entity.Controllable;
}
}
}

View file

@ -21,6 +21,7 @@
"sceneGraphProjection",
"playableWorldProjection",
"playableWorldScenePresentation",
"unityGameObjectSceneSink",
"embeddedDocuments"
],
"supportedPlugins": [
@ -65,6 +66,7 @@
"runtimes/incubating/eve-unity-scene/Runtime/EveUnitySceneClientSession.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnitySceneProviderConnection.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnityPlayableWorldPresenter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs",
"runtimes/incubating/eve-unity-scene/Runtime/SaiVisualNovelUnitySceneProjectionAdapter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/NornGraphUnitySceneProjectionAdapter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/TeXMathUnitySceneProjectionAdapter.cs",
@ -114,6 +116,7 @@
"runtimes/incubating/eve-unity-scene/Runtime/EveUnitySceneClientSession.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnitySceneProviderConnection.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnityPlayableWorldPresenter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs",
"runtimes/incubating/eve-unity-scene/Runtime/SaiVisualNovelUnitySceneProjectionAdapter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/NornGraphUnitySceneProjectionAdapter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/TeXMathUnitySceneProjectionAdapter.cs",
@ -152,6 +155,7 @@
"runtimes/incubating/eve-unity-scene/Runtime/EveUnitySceneClientSession.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnitySceneProviderConnection.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnityPlayableWorldPresenter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs",
"runtimes/incubating/eve-unity-scene/Runtime/SaiVisualNovelUnitySceneProjectionAdapter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/NornGraphUnitySceneProjectionAdapter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/TeXMathUnitySceneProjectionAdapter.cs",

View file

@ -19,6 +19,7 @@
"runtimes/incubating/eve-unity-scene/Runtime/EveUnitySceneClientSession.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnitySceneProviderConnection.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnityPlayableWorldPresenter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs",
"runtimes/incubating/eve-unity-scene/Runtime/SaiVisualNovelUnitySceneProjectionAdapter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/NornGraphUnitySceneProjectionAdapter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/TeXMathUnitySceneProjectionAdapter.cs",
@ -38,6 +39,7 @@
"runtimes/incubating/eve-unity-scene/Runtime/EveUnitySceneClientSession.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnitySceneProviderConnection.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnityPlayableWorldPresenter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs",
"runtimes/incubating/eve-unity-scene/Tests/Editor/GameCult.Eve.UnityScene.Tests.asmdef",
"tools/eveunity/eveunity-release-contract.mjs",
"tools/eveunity/eveunity-release-contract.test.mjs",
@ -57,6 +59,7 @@
"runtimes/incubating/eve-unity-scene/Runtime/EveUnitySceneClientSession.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnitySceneProviderConnection.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnityPlayableWorldPresenter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs",
"runtimes/incubating/eve-unity-scene/Runtime/SaiVisualNovelUnitySceneProjectionAdapter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/NornGraphUnitySceneProjectionAdapter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/TeXMathUnitySceneProjectionAdapter.cs",
@ -64,7 +67,7 @@
"runtimes/incubating/eve-unity-scene/eve-runtime-capability.json"
],
"destinationOwner": "EveUnity",
"replacementProof": "EveUnity claims the provider-advertised unity-scene lowering target with provider-agnostic playable-world projection, scene graph projection, provider snapshot session, playable-world scene presentation, and command boundary evidence; live transport and capture remain separate lifecycle proofs."
"replacementProof": "EveUnity claims the provider-advertised unity-scene lowering target with provider-agnostic playable-world projection, scene graph projection, provider snapshot session, playable-world scene presentation, Unity GameObject scene sink, and command boundary evidence; live transport and capture remain separate lifecycle proofs."
},
{
"id": "unity-scene-sai-plugin-projection",
@ -154,6 +157,7 @@
"EveUnity owns a provider-snapshot Unity scene client session that lowers Aetheria's surface and emits command intents without importing Aetheria project code.",
"EveUnity owns provider surface source and command sink ports that a live CultMesh/CultNet adapter can implement without changing scene lowering.",
"EveUnity owns a playable-world presenter that maps provider-authored entity rows and asset refs into Unity scene operations without importing Aetheria entity or prefab classes.",
"EveUnity owns a generic Unity GameObject scene sink and asset-provider hook for playable-world rows without importing Aetheria entity or prefab classes.",
"EveUnity owns the provider-agnostic playable-world scene lowerer outside Eve incubation.",
"EveUnity owns the Unity scene Sai projection adapter outside Eve incubation without importing Sai story state or VN/Ink internals.",
"EveUnity owns the Unity scene Norn projection adapter outside Eve incubation without importing Norn implementation state.",

View file

@ -39,7 +39,7 @@ if ($manifest.incubation.splitHandoff.manifestPath -ne "runtimes/incubating/eve-
throw "EveUnity scene manifest missing split handoff path"
}
foreach ($feature in @("providerAdvertisements", "commandTransport", "providerSurfaceSession", "providerSurfaceSource", "sceneGraphProjection", "playableWorldProjection", "playableWorldScenePresentation", "embeddedDocuments")) {
foreach ($feature in @("providerAdvertisements", "commandTransport", "providerSurfaceSession", "providerSurfaceSource", "sceneGraphProjection", "playableWorldProjection", "playableWorldScenePresentation", "unityGameObjectSceneSink", "embeddedDocuments")) {
if (-not (@($manifest.supportedFeatures) -contains $feature)) {
throw "EveUnity scene manifest missing provider-shell feature: $feature"
}

View file

@ -54,7 +54,7 @@ foreach ($evidencePath in @($worldSurfaceLoweringClaim.evidencePaths)) {
}
}
foreach ($feature in @("providerAdvertisements", "commandTransport", "providerSurfaceSession", "providerSurfaceSource", "sceneGraphProjection", "playableWorldProjection", "playableWorldScenePresentation")) {
foreach ($feature in @("providerAdvertisements", "commandTransport", "providerSurfaceSession", "providerSurfaceSource", "sceneGraphProjection", "playableWorldProjection", "playableWorldScenePresentation", "unityGameObjectSceneSink")) {
if (-not (@($manifest.supportedFeatures) -contains $feature)) {
throw "EveUnity scene provider shell missing supported feature: $feature"
}
@ -67,6 +67,7 @@ $expectedFiles = @(
"runtimes\incubating\eve-unity-scene\Runtime\EveUnitySceneClientSession.cs",
"runtimes\incubating\eve-unity-scene\Runtime\EveUnitySceneProviderConnection.cs",
"runtimes\incubating\eve-unity-scene\Runtime\EveUnityPlayableWorldPresenter.cs",
"runtimes\incubating\eve-unity-scene\Runtime\EveUnityGameObjectPlayableWorldSceneSink.cs",
"runtimes\incubating\eve-unity-scene\Runtime\SaiVisualNovelUnitySceneProjectionAdapter.cs",
"runtimes\incubating\eve-unity-scene\Runtime\NornGraphUnitySceneProjectionAdapter.cs",
"runtimes\incubating\eve-unity-scene\Runtime\TeXMathUnitySceneProjectionAdapter.cs",
@ -109,6 +110,13 @@ foreach ($symbol in @("IEveUnityPlayableWorldSceneSink", "IEveUnityPlayableWorld
}
}
$gameObjectSceneSinkSource = Get-Content -LiteralPath (Join-Path $projectRoot "runtimes\incubating\eve-unity-scene\Runtime\EveUnityGameObjectPlayableWorldSceneSink.cs") -Raw
foreach ($symbol in @("EveUnityGameObjectPlayableWorldSceneSink", "IEveUnityGameObjectAssetProvider", "EveUnityResourcesAssetProvider", "EveUnityPlayableWorldEntityMarker", "GameObject", "Transform", "Resources.Load", "PrimitiveType.Capsule", "DestroyImmediate", "localPosition", "localRotation")) {
if (-not $gameObjectSceneSinkSource.Contains($symbol)) {
throw "EveUnity GameObject playable world scene sink missing symbol: $symbol"
}
}
$saiAdapterSource = Get-Content -LiteralPath (Join-Path $projectRoot "runtimes\incubating\eve-unity-scene\Runtime\SaiVisualNovelUnitySceneProjectionAdapter.cs") -Raw
foreach ($symbol in @("SaiVisualNovelUnitySceneProjectionAdapter", "sai.vn", "vn.stage", "story.choose", "story.continue", "story.jump", "gamecult.eve.plugin_abi.v1", "sidecar-advertised-plugin-abi", "Sai")) {
if (-not $saiAdapterSource.Contains($symbol)) {

View file

@ -69,6 +69,7 @@ node $consumerScript $consumerExport `
--expect-runtime-feature unity-scene:sceneGraphProjection `
--expect-runtime-feature unity-scene:playableWorldProjection `
--expect-runtime-feature unity-scene:playableWorldScenePresentation `
--expect-runtime-feature unity-scene:unityGameObjectSceneSink `
--expect-runtime-feature electron-shell:providerAdvertisements `
--expect-runtime-feature electron-shell:commandTransport `
--expect-runtime-feature electron-shell:surfaceTreeProjection `

View file

@ -1040,7 +1040,7 @@
"splitHandoffPath": "runtimes/incubating/eve-unity-scene/eveunity-scene-split-handoff.json",
"requiredFixtures": ["aetheria-world", "eve-world-smoke"],
"pluginFixtures": ["sai-vn"],
"supportedFeatures": ["providerAdvertisements", "commandTransport", "providerSurfaceSession", "providerSurfaceSource", "sceneGraphProjection", "playableWorldProjection", "playableWorldScenePresentation", "embeddedDocuments"],
"supportedFeatures": ["providerAdvertisements", "commandTransport", "providerSurfaceSession", "providerSurfaceSource", "sceneGraphProjection", "playableWorldProjection", "playableWorldScenePresentation", "unityGameObjectSceneSink", "embeddedDocuments"],
"supportedPlugins": [
{
"pluginId": "sai.vn",
@ -1071,6 +1071,7 @@
"runtimes/incubating/eve-unity-scene/Runtime/EveUnitySceneClientSession.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnitySceneProviderConnection.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnityPlayableWorldPresenter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs",
"runtimes/incubating/eve-unity-scene/Runtime/SaiVisualNovelUnitySceneProjectionAdapter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/NornGraphUnitySceneProjectionAdapter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/TeXMathUnitySceneProjectionAdapter.cs",
@ -1087,6 +1088,7 @@
"runtimes/incubating/eve-unity-scene/Runtime/EveUnitySceneClientSession.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnitySceneProviderConnection.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnityPlayableWorldPresenter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs",
"runtimes/incubating/eve-unity-scene/Runtime/SaiVisualNovelUnitySceneProjectionAdapter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/NornGraphUnitySceneProjectionAdapter.cs",
"runtimes/incubating/eve-unity-scene/Runtime/TeXMathUnitySceneProjectionAdapter.cs",
@ -1126,6 +1128,10 @@
"path": "runtimes/incubating/eve-unity-scene/Runtime/EveUnityPlayableWorldPresenter.cs",
"contains": ["IEveUnityPlayableWorldSceneSink", "IEveUnityPlayableWorldAssetResolver", "EveUnityPlayableWorldPresenter", "EveUnityPlayableWorldAssetBinding", "EveUnityPlayableWorldPresentation", "EveUnityAssetRefResolver", "ConfigureWorld", "UpsertEntity", "RemoveEntity", "provider-asset-ref", "unity-generated-placeholder"]
},
{
"path": "runtimes/incubating/eve-unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs",
"contains": ["EveUnityGameObjectPlayableWorldSceneSink", "IEveUnityGameObjectAssetProvider", "EveUnityResourcesAssetProvider", "EveUnityPlayableWorldEntityMarker", "GameObject", "Transform", "Resources.Load", "PrimitiveType.Capsule", "DestroyImmediate", "localPosition", "localRotation"]
},
{
"path": "runtimes/incubating/eve-unity-scene/Runtime/SaiVisualNovelUnitySceneProjectionAdapter.cs",
"contains": ["SaiVisualNovelUnitySceneProjectionAdapter", "sai.vn", "vn.stage", "story.choose", "story.continue", "story.jump", "gamecult.eve.plugin_abi.v1", "sidecar-advertised-plugin-abi", "Sai"]