diff --git a/.gitignore b/.gitignore index 2c9c34e..975deb4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ TestProject/UserSettings/ obj/ *.csproj *.sln +packages/org.gamecult.eve.surface/bin~/ +packages/org.gamecult.eve.surface/obj~/ +packages/org.gamecult.eve.surface/Directory.Build.props.meta diff --git a/README.md b/README.md index 3e36f56..2f76872 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,10 @@ assembly is part of the client. `EveUnityCultMeshPlayableWorldProvider` is the shipped composition root. Its provider, surface, and Verse filters default to empty, so a client can select by -the generic `interactive-world` contract alone. +the generic `interactive-world` contract alone. After selection, Unity retains +the rendezvous address, Verse ID, and stable provider identity only. `CultMeshClient` owns +physical route discovery, reconnection, typed live-resource leases, command +submission, and content/body sessions. ```powershell powershell -ExecutionPolicy Bypass -File .\scripts\run-package-tests.ps1 diff --git a/ReleaseConsumerProject/Assets/DefaultVolumeProfile.asset b/ReleaseConsumerProject/Assets/DefaultVolumeProfile.asset new file mode 100644 index 0000000..5393bfe --- /dev/null +++ b/ReleaseConsumerProject/Assets/DefaultVolumeProfile.asset @@ -0,0 +1,15 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d7fd9488000d3734a9e00ee676215985, type: 3} + m_Name: DefaultVolumeProfile + m_EditorClassIdentifier: Unity.RenderPipelines.Core.Runtime::UnityEngine.Rendering.VolumeProfile + components: [] diff --git a/ReleaseConsumerProject/Assets/DefaultVolumeProfile.asset.meta b/ReleaseConsumerProject/Assets/DefaultVolumeProfile.asset.meta new file mode 100644 index 0000000..408ba9a --- /dev/null +++ b/ReleaseConsumerProject/Assets/DefaultVolumeProfile.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5b8b49b0bf6036a4a823021e69f80309 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/ReleaseConsumerProject/Assets/Editor.meta b/ReleaseConsumerProject/Assets/Editor.meta new file mode 100644 index 0000000..1d8dc57 --- /dev/null +++ b/ReleaseConsumerProject/Assets/Editor.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 42bb4a36b1c94282ba7a4cad2b073bbc +folderAsset: yes diff --git a/ReleaseConsumerProject/Assets/Editor/GenericEveUnityBuild.cs b/ReleaseConsumerProject/Assets/Editor/GenericEveUnityBuild.cs new file mode 100644 index 0000000..3c6f3db --- /dev/null +++ b/ReleaseConsumerProject/Assets/Editor/GenericEveUnityBuild.cs @@ -0,0 +1,20 @@ +using System.IO; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +public static class GenericEveUnityBuild +{ + public static void BuildWindows() + { + const string scenePath = "Assets/GenericEveUnity.unity"; + var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + new GameObject("Generic EveUnity Client").AddComponent(); + EditorSceneManager.SaveScene(scene, scenePath); + Directory.CreateDirectory("Build/Windows"); + var report = BuildPipeline.BuildPlayer(new[] { scenePath }, "Build/Windows/EveUnity.exe", BuildTarget.StandaloneWindows64, BuildOptions.Development); + if (report.summary.result != UnityEditor.Build.Reporting.BuildResult.Succeeded) + throw new System.InvalidOperationException("Generic EveUnity Windows build failed: " + report.summary.result); + } +} diff --git a/ReleaseConsumerProject/Assets/Editor/GenericEveUnityBuild.cs.meta b/ReleaseConsumerProject/Assets/Editor/GenericEveUnityBuild.cs.meta new file mode 100644 index 0000000..9683f45 --- /dev/null +++ b/ReleaseConsumerProject/Assets/Editor/GenericEveUnityBuild.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8fcd5fd63f21489eb5a70306d22ed4a4 diff --git a/ReleaseConsumerProject/Assets/Editor/GenericEveUnityRenderPipeline.cs b/ReleaseConsumerProject/Assets/Editor/GenericEveUnityRenderPipeline.cs new file mode 100644 index 0000000..8431736 --- /dev/null +++ b/ReleaseConsumerProject/Assets/Editor/GenericEveUnityRenderPipeline.cs @@ -0,0 +1,50 @@ +using System.Linq; +using GameCult.Eve.UnityScene; +using UnityEditor; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.Rendering.Universal; + +public static class GenericEveUnityRenderPipeline +{ + private const string PostProcessDataGuid = "41439944d30ece34e96484bdb6645b55"; + private const string RendererPath = "Assets/GenericEveUnityRenderer.asset"; + private const string PipelinePath = "Assets/GenericEveUnityRenderPipeline.asset"; + + public static void Configure() + { + var renderer = AssetDatabase.LoadAssetAtPath(RendererPath); + if (renderer == null) + { + renderer = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(renderer, RendererPath); + } + + renderer.postProcessData = AssetDatabase.LoadAssetAtPath( + AssetDatabase.GUIDToAssetPath(PostProcessDataGuid)); + if (!renderer.rendererFeatures.OfType().Any()) + { + var feature = ScriptableObject.CreateInstance(); + feature.name = "Eve Unity World Effects"; + AssetDatabase.AddObjectToAsset(feature, renderer); + renderer.rendererFeatures.Add(feature); + EditorUtility.SetDirty(feature); + } + renderer.SetDirty(); + EditorUtility.SetDirty(renderer); + + var pipeline = AssetDatabase.LoadAssetAtPath(PipelinePath); + if (pipeline == null) + { + pipeline = UniversalRenderPipelineAsset.Create(renderer); + AssetDatabase.CreateAsset(pipeline, PipelinePath); + } + + GraphicsSettings.defaultRenderPipeline = pipeline; + QualitySettings.renderPipeline = pipeline; + EditorUtility.SetDirty(pipeline); + AssetDatabase.SaveAssetIfDirty(renderer); + AssetDatabase.SaveAssetIfDirty(pipeline); + AssetDatabase.SaveAssets(); + } +} diff --git a/ReleaseConsumerProject/Assets/Editor/GenericEveUnityRenderPipeline.cs.meta b/ReleaseConsumerProject/Assets/Editor/GenericEveUnityRenderPipeline.cs.meta new file mode 100644 index 0000000..4b499ca --- /dev/null +++ b/ReleaseConsumerProject/Assets/Editor/GenericEveUnityRenderPipeline.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c07b26ad876001846ba5ea9d3743352e \ No newline at end of file diff --git a/ReleaseConsumerProject/Assets/GenericEveUnityLauncher.cs b/ReleaseConsumerProject/Assets/GenericEveUnityLauncher.cs new file mode 100644 index 0000000..0ae0bb7 --- /dev/null +++ b/ReleaseConsumerProject/Assets/GenericEveUnityLauncher.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections; +using System.IO; +using GameCult.Eve.UnityUIToolkit; +using GameCult.Eve.UnityScene; +using UnityEngine; +using UnityEngine.UIElements; + +public sealed class GenericEveUnityLauncher : MonoBehaviour +{ + private EveUnityCultMeshPlayableWorldProvider _provider; + private EveUnityPlayableWorldClientBootstrap _bootstrap; + private EveUiToolkitSurfaceLowerer _surfaceLowerer; + private UIDocument _surfaceDocument; + private PanelSettings _panelSettings; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + private static void StartClient() + { + if (string.Equals(Environment.GetEnvironmentVariable("EVEUNITY_DISABLE_AUTO_LAUNCHER"), "1", StringComparison.Ordinal)) + return; + if (FindObjectOfType() != null) return; + new GameObject("Generic EveUnity Client").AddComponent(); + } + + private void Awake() + { + Application.runInBackground = true; + var endpoint = Environment.GetEnvironmentVariable("EVEUNITY_RENDEZVOUS_ENDPOINT") ?? "rudp://127.0.0.1:3076"; + var surface = Environment.GetEnvironmentVariable("EVEUNITY_SURFACE_ID") ?? "aetheria.pilot"; + var replica = Path.Combine(Application.persistentDataPath, "eve-unity-client.cc"); + + var cameraObject = new GameObject("Main Camera") { tag = "MainCamera" }; + var camera = cameraObject.AddComponent(); + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = new Color(0.025f, 0.035f, 0.055f); + camera.nearClipPlane = 0.05f; + camera.farClipPlane = 10000f; + + _provider = gameObject.AddComponent(); + _provider.Configure(endpoint, replica, surfaceId: surface, requiredSurfaceKind: "interactive-world", clientRuntimeId: "eve-unity-interactive"); + _provider.DocumentAvailable += OnSurfaceAvailable; + _surfaceLowerer = new EveUiToolkitSurfaceLowerer(); + _panelSettings = ScriptableObject.CreateInstance(); + _panelSettings.scaleMode = PanelScaleMode.ScaleWithScreenSize; + _panelSettings.referenceResolution = new Vector2Int(1920, 1080); + _panelSettings.sortingOrder = 20; + _surfaceDocument = gameObject.AddComponent(); + _surfaceDocument.panelSettings = _panelSettings; + _bootstrap = gameObject.AddComponent(); + _bootstrap.ConfigureProvider(_provider); + StartCoroutine(Connect()); + } + + private IEnumerator Connect() + { + while (true) + { + var connected = false; + var preparation = _provider.PrepareAsync(); + while (!preparation.IsCompleted) + yield return null; + try + { + preparation.GetAwaiter().GetResult(); + var presentation = _bootstrap.Mount(); + LowerSurface(_provider.CurrentDocument); + Debug.Log($"Connected generic Eve client: {_provider.Selection?.ProviderId} / {_provider.Selection?.SurfaceId} / {presentation.ActiveEntities} entities"); + connected = true; + } + catch (Exception ex) + { + Debug.Log("Waiting for Eve provider: " + ex.Message); + } + if (connected) yield break; + yield return new WaitForSecondsRealtime(0.5f); + } + } + + private void OnSurfaceAvailable(EveUnitySceneProviderSurfaceDocument document) + { + LowerSurface(document); + } + + private void LowerSurface(EveUnitySceneProviderSurfaceDocument document) + { + if (_surfaceDocument == null || _surfaceLowerer == null || document == null) return; + var root = _surfaceDocument.rootVisualElement; + root.Clear(); + var lowered = _surfaceLowerer.Lower(document.SurfaceDocument, _provider.Submit); + lowered.style.flexGrow = 1; + root.Add(lowered); + } + + private void OnDestroy() + { + if (_provider != null) _provider.DocumentAvailable -= OnSurfaceAvailable; + if (_panelSettings != null) Destroy(_panelSettings); + } +} diff --git a/ReleaseConsumerProject/Assets/GenericEveUnityLauncher.cs.meta b/ReleaseConsumerProject/Assets/GenericEveUnityLauncher.cs.meta new file mode 100644 index 0000000..dde265b --- /dev/null +++ b/ReleaseConsumerProject/Assets/GenericEveUnityLauncher.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5c2c8060cff846cda741810156b0558a diff --git a/ReleaseConsumerProject/Assets/GenericEveUnityRenderPipeline.asset b/ReleaseConsumerProject/Assets/GenericEveUnityRenderPipeline.asset new file mode 100644 index 0000000..e93f394 --- /dev/null +++ b/ReleaseConsumerProject/Assets/GenericEveUnityRenderPipeline.asset @@ -0,0 +1,143 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: bf2edee5c58d82540a51f03df9d42094, type: 3} + m_Name: GenericEveUnityRenderPipeline + m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.UniversalRenderPipelineAsset + k_AssetVersion: 13 + k_AssetPreviousVersion: 13 + m_RendererType: 1 + m_RendererData: {fileID: 0} + m_RendererDataList: + - {fileID: 11400000, guid: cbe5d2831ab649e42b9fb94644e029a9, type: 2} + m_DefaultRendererIndex: 0 + m_RequireDepthTexture: 0 + m_RequireOpaqueTexture: 0 + m_OpaqueDownsampling: 1 + m_SupportsTerrainHoles: 1 + m_SupportsHDR: 1 + m_HDRColorBufferPrecision: 0 + m_MSAA: 1 + m_RenderScale: 1 + m_UpscalingFilter: 0 + m_FsrOverrideSharpness: 0 + m_FsrSharpness: 0.92 + m_EnableLODCrossFade: 1 + m_LODCrossFadeDitheringType: 1 + m_ShEvalMode: 0 + m_LightProbeSystem: 0 + m_ProbeVolumeMemoryBudget: 1024 + m_ProbeVolumeBlendingMemoryBudget: 256 + m_SupportProbeVolumeGPUStreaming: 0 + m_SupportProbeVolumeDiskStreaming: 0 + m_SupportProbeVolumeScenarios: 0 + m_SupportProbeVolumeScenarioBlending: 0 + m_ProbeVolumeSHBands: 1 + m_MainLightRenderingMode: 1 + m_MainLightShadowsSupported: 1 + m_MainLightShadowmapResolution: 2048 + m_AdditionalLightsRenderingMode: 1 + m_AdditionalLightsPerObjectLimit: 4 + m_AdditionalLightShadowsSupported: 0 + m_AdditionalLightsShadowmapResolution: 2048 + m_AdditionalLightsShadowResolutionTierLow: 256 + m_AdditionalLightsShadowResolutionTierMedium: 512 + m_AdditionalLightsShadowResolutionTierHigh: 1024 + m_ReflectionProbeBlending: 0 + m_ReflectionProbeBoxProjection: 0 + m_ReflectionProbeAtlas: 1 + m_ShadowDistance: 50 + m_ShadowCascadeCount: 1 + m_Cascade2Split: 0.25 + m_Cascade3Split: {x: 0.1, y: 0.3} + m_Cascade4Split: {x: 0.067, y: 0.2, z: 0.467} + m_CascadeBorder: 0.2 + m_ShadowDepthBias: 1 + m_ShadowNormalBias: 1 + m_AnyShadowsSupported: 1 + m_SoftShadowsSupported: 0 + m_ConservativeEnclosingSphere: 1 + m_NumIterationsEnclosingSphere: 64 + m_SoftShadowQuality: 2 + m_AdditionalLightsCookieResolution: 2048 + m_AdditionalLightsCookieFormat: 3 + m_UseSRPBatcher: 1 + m_SupportsDynamicBatching: 0 + m_MixedLightingSupported: 1 + m_SupportsLightCookies: 1 + m_SupportsLightLayers: 0 + m_DebugLevel: 0 + m_StoreActionsOptimization: 0 + m_UseAdaptivePerformance: 1 + m_ColorGradingMode: 0 + m_ColorGradingLutSize: 32 + m_AllowPostProcessAlphaOutput: 0 + m_UseFastSRGBLinearConversion: 0 + m_SupportDataDrivenLensFlare: 1 + m_SupportScreenSpaceLensFlare: 1 + m_GPUResidentDrawerMode: 0 + m_SmallMeshScreenPercentage: 0 + m_GPUResidentDrawerEnableOcclusionCullingInCameras: 0 + m_ShadowType: 1 + m_LocalShadowsSupported: 0 + m_LocalShadowsAtlasResolution: 256 + m_MaxPixelLights: 0 + m_ShadowAtlasResolution: 256 + m_VolumeFrameworkUpdateMode: 0 + m_VolumeProfile: {fileID: 0} + apvScenesData: + obsoleteSceneBounds: + m_Keys: [] + m_Values: [] + obsoleteHasProbeVolumes: + m_Keys: [] + m_Values: + m_PrefilteringModeMainLightShadows: 1 + m_PrefilteringModeAdditionalLight: 4 + m_PrefilteringModeAdditionalLightShadows: 1 + m_PrefilterXRKeywords: 0 + m_PrefilteringModeForwardPlus: 1 + m_PrefilteringModeDeferredRendering: 1 + m_PrefilteringModeScreenSpaceOcclusion: 1 + m_PrefilterDebugKeywords: 0 + m_PrefilterWriteRenderingLayers: 0 + m_PrefilterHDROutput: 0 + m_PrefilterAlphaOutput: 0 + m_PrefilterSSAODepthNormals: 0 + m_PrefilterSSAOSourceDepthLow: 0 + m_PrefilterSSAOSourceDepthMedium: 0 + m_PrefilterSSAOSourceDepthHigh: 0 + m_PrefilterSSAOInterleaved: 0 + m_PrefilterSSAOBlueNoise: 0 + m_PrefilterSSAOSampleCountLow: 0 + m_PrefilterSSAOSampleCountMedium: 0 + m_PrefilterSSAOSampleCountHigh: 0 + m_PrefilterDBufferMRT1: 0 + m_PrefilterDBufferMRT2: 0 + m_PrefilterDBufferMRT3: 0 + m_PrefilterSoftShadowsQualityLow: 0 + m_PrefilterSoftShadowsQualityMedium: 0 + m_PrefilterSoftShadowsQualityHigh: 0 + m_PrefilterSoftShadows: 0 + m_PrefilterScreenCoord: 0 + m_PrefilterScreenSpaceIrradiance: 0 + m_PrefilterNativeRenderPass: 0 + m_PrefilterUseLegacyLightmaps: 0 + m_PrefilterBicubicLightmapSampling: 0 + m_PrefilterReflectionProbeRotation: 0 + m_PrefilterReflectionProbeBlending: 0 + m_PrefilterReflectionProbeBoxProjection: 0 + m_PrefilterReflectionProbeAtlas: 0 + m_ShaderVariantLogLevel: 0 + m_ShadowCascades: 0 + m_Textures: + blueNoise64LTex: {fileID: 2800000, guid: e3d24661c1e055f45a7560c033dbb837, type: 3} + bayerMatrixTex: {fileID: 2800000, guid: f9ee4ed84c1d10c49aabb9b210b0fc44, type: 3} diff --git a/ReleaseConsumerProject/Assets/GenericEveUnityRenderPipeline.asset.meta b/ReleaseConsumerProject/Assets/GenericEveUnityRenderPipeline.asset.meta new file mode 100644 index 0000000..266335c --- /dev/null +++ b/ReleaseConsumerProject/Assets/GenericEveUnityRenderPipeline.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 43f32b840bd83ce47af920dadd59d4b3 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/ReleaseConsumerProject/Assets/GenericEveUnityRenderer.asset b/ReleaseConsumerProject/Assets/GenericEveUnityRenderer.asset new file mode 100644 index 0000000..d63c193 --- /dev/null +++ b/ReleaseConsumerProject/Assets/GenericEveUnityRenderer.asset @@ -0,0 +1,69 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: de640fe3d0db1804a85f9fc8f5cadab6, type: 3} + m_Name: GenericEveUnityRenderer + m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.UniversalRendererData + debugShaders: + debugReplacementPS: {fileID: 4800000, guid: cf852408f2e174538bcd9b7fda1c5ae7, type: 3} + hdrDebugViewPS: {fileID: 4800000, guid: 573620ae32aec764abd4d728906d2587, type: 3} + probeVolumeSamplingDebugComputeShader: {fileID: 7200000, guid: 53626a513ea68ce47b59dc1299fe3959, type: 3} + probeVolumeResources: + probeVolumeDebugShader: {fileID: 0} + probeVolumeFragmentationDebugShader: {fileID: 0} + probeVolumeOffsetDebugShader: {fileID: 0} + probeVolumeSamplingDebugShader: {fileID: 0} + probeSamplingDebugMesh: {fileID: 0} + probeSamplingDebugTexture: {fileID: 0} + probeVolumeBlendStatesCS: {fileID: 0} + m_RendererFeatures: + - {fileID: 7891961340342901043} + m_RendererFeatureMap: + m_UseNativeRenderPass: 0 + postProcessData: {fileID: 11400000, guid: 41439944d30ece34e96484bdb6645b55, type: 2} + m_AssetVersion: 3 + m_PrepassLayerMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_OpaqueLayerMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_TransparentLayerMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_DefaultStencilState: + overrideStencilState: 0 + stencilReference: 0 + stencilCompareFunction: 8 + passOperation: 2 + failOperation: 0 + zFailOperation: 0 + m_ShadowTransparentReceive: 1 + m_RenderingMode: 0 + m_DepthPrimingMode: 0 + m_CopyDepthMode: 1 + m_DepthAttachmentFormat: 0 + m_DepthTextureFormat: 0 + m_AccurateGbufferNormals: 0 + m_IntermediateTextureMode: 1 +--- !u!114 &7891961340342901043 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 340385338d304b5aaf069562d4e7b457, type: 3} + m_Name: Eve Unity World Effects + m_EditorClassIdentifier: GameCult.Eve.UnityScene::GameCult.Eve.UnityScene.EveUnityRendererFeature + m_Active: 1 diff --git a/ReleaseConsumerProject/Assets/GenericEveUnityRenderer.asset.meta b/ReleaseConsumerProject/Assets/GenericEveUnityRenderer.asset.meta new file mode 100644 index 0000000..5837417 --- /dev/null +++ b/ReleaseConsumerProject/Assets/GenericEveUnityRenderer.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: cbe5d2831ab649e42b9fb94644e029a9 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/ReleaseConsumerProject/Assets/Tests/PlayMode/GameCult.EveUnity.GenericClient.PlayModeTests.asmdef b/ReleaseConsumerProject/Assets/Tests/PlayMode/GameCult.EveUnity.GenericClient.PlayModeTests.asmdef index 4d21cb3..e7db487 100644 --- a/ReleaseConsumerProject/Assets/Tests/PlayMode/GameCult.EveUnity.GenericClient.PlayModeTests.asmdef +++ b/ReleaseConsumerProject/Assets/Tests/PlayMode/GameCult.EveUnity.GenericClient.PlayModeTests.asmdef @@ -4,8 +4,10 @@ "references": [ "GameCult.CultLib", "GameCult.Eve.Surface", + "GameCult.Eve.UnityUIToolkit", "GameCult.Eve.UnityScene", - "GameCult.Eve.UnityScene.CultMesh" + "GameCult.Eve.UnityScene.CultMesh", + "Unity.RenderPipelines.Universal.Runtime" ], "includePlatforms": [], "excludePlatforms": [], @@ -14,6 +16,7 @@ "GameCult.Caching.dll", "GameCult.Caching.MessagePack.dll", "GameCult.Mesh.dll", + "GameCult.Mesh.Quic.Native.dll", "GameCult.Networking.dll" ], "autoReferenced": false, diff --git a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index 2a00a34..8d8cef4 100644 --- a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -3,13 +3,23 @@ using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; using GameCult.Eve.Surface; +using GameCult.Eve.UnityUIToolkit; using GameCult.Eve.UnityScene; +using GameCult.Eve.UnityScene.Fields; +using GameCult.Mesh; +using GameCult.Mesh.Quic.Native; using NUnit.Framework; using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.Rendering.Universal; using UnityEngine.TestTools; +using UnityEngine.UIElements; +using Stopwatch = System.Diagnostics.Stopwatch; namespace GameCult.EveUnity.GenericClient.PlayModeTests { @@ -172,44 +182,110 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests var providerId = Environment.GetEnvironmentVariable("EVEUNITY_PROVIDER_ID") ?? ""; var surfaceId = Environment.GetEnvironmentVariable("EVEUNITY_SURFACE_ID") ?? ""; - var replicaPath = Environment.GetEnvironmentVariable("EVEUNITY_REPLICA_PATH") ?? - Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}.cc"); + var cacheDirectory = Environment.GetEnvironmentVariable("EVEUNITY_CACHE_DIRECTORY") ?? + Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}"); var capturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_CAPTURE_PATH") ?? Path.Combine(Application.temporaryCachePath, "aetheria-daemon-world.png"); + var mapCapturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_MAP_CAPTURE_PATH") ?? + Path.Combine(Application.temporaryCachePath, "aetheria-daemon-map.png"); var root = new GameObject("Generic Eve World Projection"); var cameraObject = new GameObject("Generic Eve Capture Camera"); - var lightObject = new GameObject("Generic Eve World Light"); + var mapCameraObject = new GameObject("Generic Eve Map Camera"); RenderTexture target = null; Texture2D pixels = null; EveUnityCultMeshPlayableWorldProvider provider = null; + EveUnityPlayableWorldClientHost host = null; EveUnityPlayableWorldRuntime runtime = null; WitnessReceipt movementReceipt = null; + WitnessReceipt lookReceipt = null; WitnessReceipt focusReceipt = null; + WitnessReceipt tractorReceipt = null; + WitnessReceipt tractorReleaseReceipt = null; WitnessReceipt actionReceipt = null; - WitnessReceipt tradeReceipt = null; - EveUnityShotReceipt combatShot = null; - string combatActionId = ""; - string collectedPickupId = ""; - bool pickupCollected = false; - bool docked = false; - bool undocked = false; - string stationObservedHostileId = ""; + WitnessReceipt tradePurchaseReceipt = null; + WitnessReceipt tradeSaleReceipt = null; + bool tradeRoundTrip = false; + string tradeItemKey = ""; + EveUnityShotReceipt observedShot = null; + EveUnityCombatPresentation observedCombat = null; + float shieldRatioBefore = 0f; + float hullRatioBefore = 0f; long initialVersion = 0; float movementDistance = 0f; - int cargoQuantityBeforePickup = 0; - int cargoQuantityAfterPickup = 0; - var commandReceipts = new Dictionary(StringComparer.Ordinal); + float aimDotDistance = 0f; + int cockpitProgressCount = 0; + float tractorBeamPower = 0f; + float tractorReleasedPower = 1f; + int tractorBeamParticleSystemCount = 0; + EveUnityFeedbackEvent pickupCollection = null; + EveUnityFeedbackEvent pickupRejection = null; + int pickupCollectionEventCount = 0; + int pickupRejectionEventCount = 0; + var pickupRejectionEventIds = new HashSet(StringComparer.Ordinal); + int initialPickupEntityCount = 0; + int finalPickupEntityCount = -1; + bool destructionPickupObserved = false; + int maximumTrajectoryCount = 0; + bool hitMarkerObserved = false; + var witnessProfile = Environment.GetEnvironmentVariable("EVEUNITY_WITNESS_PROFILE") ?? + "full-session-gameplay"; + Assert.That(witnessProfile, + Is.EqualTo("cold-start-lowering").Or.EqualTo("full-session-gameplay"), + "The witness profile must name an explicit proof boundary."); + var requireSessionGameplay = string.Equals( + witnessProfile, + "full-session-gameplay", + StringComparison.Ordinal); + var gameplayScenario = Environment.GetEnvironmentVariable("EVEUNITY_WITNESS_GAMEPLAY_SCENARIO") ?? + "released-client-proof"; + Assert.That(gameplayScenario, + Is.EqualTo("released-client-proof").Or.EqualTo("cargo-capacity-rejection-proof"), + "The live witness must select an explicit daemon-owned gameplay scenario."); + var expectCargoRejection = string.Equals( + gameplayScenario, + "cargo-capacity-rejection-proof", + StringComparison.Ordinal); + EveUnityAimPresentationRenderer aimRenderer = null; + EveUnityBeamPresentation beamPresentation = null; + EveUnityBeamPresentationRenderer beamRenderer = null; + EveUnityShotTrajectoryRenderer trajectories = null; try { + var providerReadyPath = Environment.GetEnvironmentVariable("EVEUNITY_PROVIDER_READY_PATH"); + if (!string.IsNullOrWhiteSpace(providerReadyPath)) + File.WriteAllText(providerReadyPath, DateTimeOffset.UtcNow.ToString("O")); provider = root.AddComponent(); provider.Configure( rendezvousEndpoint, - replicaPath, + cacheDirectory, providerId, surfaceId, requiredSurfaceKind: "interactive-world", clientRuntimeId: $"eve-unity-test-{Guid.NewGuid():N}"); + var preparationDeadline = Time.realtimeSinceStartup + 30f; + while (true) + { + var preparation = provider.PrepareAsync(); + while (!preparation.IsCompleted && Time.realtimeSinceStartup < preparationDeadline) + yield return new WaitForSecondsRealtime(0.1f); + if (!preparation.IsCompleted) + throw new TimeoutException("Timed out preparing the advertised Eve provider."); + var prepared = false; + try + { + preparation.GetAwaiter().GetResult(); + prepared = true; + } + catch (InvalidOperationException) when (Time.realtimeSinceStartup < preparationDeadline) + { + } + catch (TimeoutException) when (Time.realtimeSinceStartup < preparationDeadline) + { + } + if (prepared) break; + yield return new WaitForSecondsRealtime(0.1f); + } var publicationDeadline = Time.realtimeSinceStartup + 30f; while (true) { @@ -222,6 +298,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests catch (InvalidOperationException) when (Time.realtimeSinceStartup < publicationDeadline) { } + catch (TimeoutException) when (Time.realtimeSinceStartup < publicationDeadline) + { + } if (published) break; yield return new WaitForSecondsRealtime(0.1f); } @@ -229,528 +308,1084 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests Assert.That(provider.Selection.VerseId, Is.Not.Empty); Assert.That(provider.Selection.ProviderId, Is.Not.Empty); Assert.That(provider.Selection.SurfaceId, Is.Not.Empty); - runtime = EveUnityPlayableWorldRuntime.CreateForGameObjectScene( + var quicProof = ReceiveAdvertisedRealtimeFrameAsync( + rendezvousEndpoint, + provider.Selection.VerseId, + provider.Selection.AuthorityRuntimeId); + var quicDeadline = Time.realtimeSinceStartup + 15f; + while (!quicProof.IsCompleted && Time.realtimeSinceStartup < quicDeadline) + yield return new WaitForSecondsRealtime(0.05f); + if (!quicProof.IsCompleted) + throw new TimeoutException("Timed out receiving advertised CultMesh QUIC state in Unity."); + var quicFrame = quicProof.GetAwaiter().GetResult(); + Assert.That(quicFrame.Delivery, Is.EqualTo(CultMeshRealtimeDelivery.LatestOnly)); + Assert.That(quicFrame.BodyId, Is.Not.Empty); + Assert.That(quicFrame.SchemaId, Is.Not.Empty); + Assert.That(provider.CurrentAssetBodyTransportKind, Is.Null, + "Publishing the provider catalog eagerly transferred an asset bundle."); + host = root.AddComponent(); + host.Configure( root.transform, provider, provider, - null, + provider, provider, provider); - runtime.ReceiptAvailable += receipt => commandReceipts[receipt.CommandId] = receipt; - var initial = runtime.Connect(); + host.Connect(); + var loweredSurface = new EveUiToolkitSurfaceLowerer().Lower( + provider.CurrentDocument.SurfaceDocument, + provider.Submit); + var cockpit = loweredSurface.Q("aetheria.daemon.game.cockpit"); + Assert.That(cockpit, Is.Not.Null, "The provider did not publish a generic cockpit overlay."); + cockpitProgressCount = cockpit.Query().ToList().Count; + Assert.That(cockpitProgressCount, Is.GreaterThanOrEqualTo(7)); + Assert.That(loweredSurface.Q("aetheria.daemon.game.frame").style.display.value, + Is.EqualTo(DisplayStyle.None), "Daemon diagnostics leaked into the pilot UI."); + trajectories = root.GetComponent(); + Assert.That(trajectories, Is.Not.Null); + host.ShotAvailable += shot => + { + observedShot = shot; + maximumTrajectoryCount = Math.Max(maximumTrajectoryCount, trajectories.ActiveTrajectoryCount); + }; + runtime = host.Runtime; + Assert.That(runtime, Is.Not.Null); + Assert.That(runtime.ActiveWorld.PlayerEntityId, Is.Not.Empty); var entityDeadline = Time.realtimeSinceStartup + 12f; - while (initial.ActiveEntities == 0 && Time.realtimeSinceStartup < entityDeadline) + while (Time.realtimeSinceStartup < entityDeadline && + (host.PresentedEntities?.CurrentGeneration == null || + host.PresentedEntities.CurrentGeneration.Entities.Count == 0)) { yield return new WaitForSecondsRealtime(0.1f); - initial = runtime.Refresh(); + host.Refresh(); } - Assert.That(initial.ActiveEntities, Is.GreaterThan(0)); - Assert.That(runtime.ActiveWorld.PlayerEntityId, Is.Not.Empty); - + Assert.That(host.PresentedEntities?.CurrentGeneration, Is.Not.Null); + Assert.That(host.PresentedEntities.CurrentGeneration.Entities.Count, Is.GreaterThan(0)); + initialPickupEntityCount = host.PresentedEntities.CurrentGeneration.Entities.Count(entity => + string.Equals(entity.EntityKind, "pickup", StringComparison.Ordinal)); + if (requireSessionGameplay) + Assert.That(initialPickupEntityCount, Is.Zero, + "The witness scenario must obtain salvage from daemon-owned destruction, not a boot-seeded pickup."); var playerId = runtime.ActiveWorld.PlayerEntityId; + var playerFact = host.PresentedEntities.CurrentGeneration.Entities + .Single(entity => entity.EntityId == playerId); + runtime.FeedbackAvailable += feedback => + { + if (feedback.TargetEntityIndex != playerFact.SourceIndex) return; + if (string.Equals(feedback.Kind, "pickup.collected", StringComparison.Ordinal)) + { + pickupCollectionEventCount++; + pickupCollection = feedback; + } + else if (string.Equals(feedback.Kind, "pickup.rejected", StringComparison.Ordinal)) + { + pickupRejectionEventCount++; + pickupRejectionEventIds.Add(feedback.EventId); + pickupRejection = feedback; + } + }; + var marker = FindMarker(root, playerId); - var seededPickup = root.GetComponentsInChildren() - .SingleOrDefault(candidate => string.Equals(candidate.EntityKind, "pickup", StringComparison.Ordinal)); - Assert.That(seededPickup, Is.Not.Null, "The daemon-authored salvage pickup was not lowered through the Eve entity view."); - collectedPickupId = seededPickup.EntityId; - Assert.That( - seededPickup.GetComponentsInChildren(includeInactive: true).Length, - Is.GreaterThan(1), - "The generic client did not instantiate the provider-authored pickup asset."); - var dockAction = provider.CurrentInputCapability.Actions - .Single(candidate => string.Equals(candidate.ActionId, "pilot.dock", StringComparison.Ordinal)); - var undockAction = provider.CurrentInputCapability.Actions - .Single(candidate => string.Equals(candidate.ActionId, "pilot.undock", StringComparison.Ordinal)); - var realtimeAction = provider.CurrentInputCapability?.Actions - .FirstOrDefault(action => string.Equals(action.ActionId, "simulation.rate.realtime", StringComparison.Ordinal)); - Assert.That(realtimeAction, Is.Not.Null, "Terminus did not advertise its user-controlled simulation clock."); - var pauseAction = provider.CurrentInputCapability.Actions - .Single(candidate => string.Equals(candidate.ActionId, "simulation.pause", StringComparison.Ordinal)); - var scoopAction = provider.CurrentInputCapability.Actions - .Single(candidate => string.Equals(candidate.ActionId, "pilot.scoop", StringComparison.Ordinal)); - var stepAction = provider.CurrentInputCapability.Actions - .Single(candidate => string.Equals(candidate.ActionId, "simulation.step", StringComparison.Ordinal)); + + if (requireSessionGameplay) + { + EveUnitySceneCommandReceipt observedDockReceipt = null; + string dockCommandId = null; + runtime.ReceiptAvailable += receipt => + { + if (receipt.CommandId == dockCommandId) + observedDockReceipt = receipt; + }; + host.SubmitAdvertisedActionIntent(playerId, "simulation.step"); + var dockVersion = runtime.ActiveVersion; + var dock = host.SubmitAdvertisedActionIntent(playerId, "pilot.dock"); + dockCommandId = dock.CommandId; + var dockDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < dockDeadline && + (observedDockReceipt == null || + !string.Equals(observedDockReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < observedDockReceipt.SourceVersion || + !(host.InputCapability?.Actions ?? Array.Empty()).Any(candidate => + candidate != null && + candidate.ActionId.StartsWith("trade.buy.", StringComparison.Ordinal) && + string.Equals(candidate.Availability, "available", StringComparison.OrdinalIgnoreCase)))) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(observedDockReceipt, dock.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, dockVersion); + var buyAction = (host.InputCapability?.Actions ?? Array.Empty()) + .FirstOrDefault(candidate => candidate != null && + candidate.ActionId.StartsWith("trade.buy.", StringComparison.Ordinal) && + string.Equals(candidate.Availability, "available", StringComparison.OrdinalIgnoreCase)); + Assert.That(buyAction, Is.Not.Null, + "Docking did not expose an available provider-authored purchase action."); + Assert.That(buyAction.Payload.TryGetValue("itemKey", out tradeItemKey), Is.True); + Assert.That(tradeItemKey, Is.Not.Empty); + + var purchaseVersion = runtime.ActiveVersion; + var purchase = host.SubmitAdvertisedActionIntent(playerId, buyAction.ActionId); + var purchaseDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < purchaseDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != purchase.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion || + !(host.InputCapability?.Actions ?? Array.Empty()).Any(candidate => + candidate != null && + candidate.ActionId.StartsWith("trade.sell.", StringComparison.Ordinal) && + candidate.Payload.TryGetValue("itemKey", out var itemKey) && + string.Equals(itemKey, tradeItemKey, StringComparison.Ordinal) && + string.Equals(candidate.Availability, "available", StringComparison.OrdinalIgnoreCase)))) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, purchase.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, purchaseVersion); + tradePurchaseReceipt = WitnessReceipt.From("trade-purchase", runtime.LastReceipt); + var sellAction = (host.InputCapability?.Actions ?? Array.Empty()) + .FirstOrDefault(candidate => candidate != null && + candidate.ActionId.StartsWith("trade.sell.", StringComparison.Ordinal) && + candidate.Payload.TryGetValue("itemKey", out var itemKey) && + string.Equals(itemKey, tradeItemKey, StringComparison.Ordinal) && + string.Equals(candidate.Availability, "available", StringComparison.OrdinalIgnoreCase)); + Assert.That(sellAction, Is.Not.Null, + "The authoritative purchase did not expose the purchased cargo as an available sale action."); + + var saleCapabilityVersion = host.InputCapability.Version; + var saleVersion = runtime.ActiveVersion; + var sale = host.SubmitAdvertisedActionIntent(playerId, sellAction.ActionId); + var saleDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < saleDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != sale.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion || + host.InputCapability.Version <= saleCapabilityVersion || + (host.InputCapability.Actions ?? Array.Empty()).Any(candidate => + candidate != null && + candidate.ActionId.StartsWith("trade.sell.", StringComparison.Ordinal) && + candidate.Payload.TryGetValue("itemKey", out var itemKey) && + string.Equals(itemKey, tradeItemKey, StringComparison.Ordinal)))) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, sale.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, saleVersion); + tradeSaleReceipt = WitnessReceipt.From("trade-sale", runtime.LastReceipt); + tradeRoundTrip = !(host.InputCapability.Actions ?? Array.Empty()).Any(candidate => + candidate != null && + candidate.ActionId.StartsWith("trade.sell.", StringComparison.Ordinal) && + candidate.Payload.TryGetValue("itemKey", out var itemKey) && + string.Equals(itemKey, tradeItemKey, StringComparison.Ordinal)); + Assert.That(tradeRoundTrip, Is.True, + "The sold unit remained in the daemon-authored cargo action catalog."); + + var resumeVersion = runtime.ActiveVersion; + var resume = host.SubmitAdvertisedActionIntent(playerId, "simulation.rate.realtime"); + var resumeDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < resumeDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != resume.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, resume.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, resumeVersion); + + var undockVersion = runtime.ActiveVersion; + var undock = host.SubmitAdvertisedActionIntent(playerId, "pilot.undock"); + var undockDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < undockDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != undock.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion || + (host.InputCapability?.Actions ?? Array.Empty()).Any(candidate => + candidate != null && candidate.ActionId.StartsWith("trade.", StringComparison.Ordinal)))) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, undock.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, undockVersion); + } + + var playerPrefab = provider.ResolvePrefab(new EveUnityPlayableWorldAssetBinding( + playerFact.AssetRef, + playerFact.EntityKind, + "provider-asset-ref")); + Assert.That(playerPrefab, Is.Not.Null, + $"Provider asset catalog did not resolve player AssetRef '{playerFact.AssetRef}'."); + Assert.That(provider.CurrentAssetBodyTransportKind, + Is.EqualTo(CultMeshBodyTransportKind.SharedFileMapping), + "The first requested provider bundle was not negotiated as a mapped body."); Assert.That( marker.GetComponentsInChildren(includeInactive: true).Length, Is.GreaterThan(1), "The generic client substituted its primitive fallback instead of the provider-authored AssetBundle prefab."); - runtime.SubmitMoveVectorIntent(playerId, 0f, 0f, 0f); - var dock = runtime.SubmitActionIntent(playerId, dockAction.ActionId); - var resumeDock = runtime.SubmitActionIntent(playerId, stepAction.ActionId); - var dockDeadline = Time.realtimeSinceStartup + 12f; - while (Time.realtimeSinceStartup < dockDeadline && - (!commandReceipts.TryGetValue(dock.CommandId, out var observedDock) || - runtime.ActiveVersion < observedDock.SourceVersion || - !string.Equals(runtime.ActiveWorld.PresentationMode, "docked", StringComparison.Ordinal))) - { - yield return new WaitForSecondsRealtime(0.1f); - runtime.Refresh(); - } - Assert.That(commandReceipts.TryGetValue(resumeDock.CommandId, out var resumeDockReceipt), Is.True); - Assert.That(resumeDockReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), resumeDockReceipt.Message); - Assert.That(commandReceipts.TryGetValue(dock.CommandId, out var dockReceipt), Is.True); - Assert.That(dockReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), dockReceipt.Message); - Assert.That(runtime.ActiveWorld.PresentationMode, Is.EqualTo("docked")); - Assert.That(runtime.ActiveWorld.CameraTargetEntityId, Is.Not.Empty.And.Not.EqualTo(playerId)); - Assert.That(FindMarker(root, runtime.ActiveWorld.CameraTargetEntityId), Is.Not.Null, - "The daemon did not include the authoritative dock parent in the docked SoA view."); - stationObservedHostileId = provider.CurrentEntityView.Identities - .FirstOrDefault(identity => string.Equals(identity.Faction, "raider", StringComparison.Ordinal))?.EntityId ?? ""; - Assert.That(stationObservedHostileId, Is.Not.Empty, - "The docked pilot did not inherit the station's sensor-derived hostile contacts."); - Assert.That(runtime.ActiveWorld.SubjectVisible, Is.False); - Assert.That(runtime.ActiveWorld.MovementEnabled, Is.False); - Assert.That(FindMarker(root, playerId, includeInactive: true).gameObject.activeSelf, Is.False); - docked = true; - var servicePause = runtime.SubmitActionIntent(playerId, pauseAction.ActionId); - var servicePauseDeadline = Time.realtimeSinceStartup + 12f; - while (Time.realtimeSinceStartup < servicePauseDeadline && - (!commandReceipts.TryGetValue(servicePause.CommandId, out var observedServicePause) || - runtime.ActiveVersion < observedServicePause.SourceVersion)) - { - yield return new WaitForSecondsRealtime(0.1f); - runtime.Refresh(); - } - Assert.That(commandReceipts.TryGetValue(servicePause.CommandId, out var servicePauseReceipt), Is.True); - Assert.That(servicePauseReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), servicePauseReceipt.Message); - - var tradeAction = provider.CurrentInputCapability.Actions - .FirstOrDefault(candidate => - string.Equals(candidate.Category, "trade", StringComparison.Ordinal) && - string.Equals(candidate.Availability, "available", StringComparison.OrdinalIgnoreCase)); - Assert.That(tradeAction, Is.Not.Null, - "The docked pilot input capability did not advertise an available daemon-authored station stock action. Actions: " + - string.Join("; ", provider.CurrentInputCapability.Actions.Select(candidate => - $"{candidate.ActionId}[category={candidate.Category},availability={candidate.Availability},label={candidate.Label}]"))); - var trade = runtime.SubmitActionIntent(playerId, tradeAction.ActionId); - var tradeDeadline = Time.realtimeSinceStartup + 12f; - while (Time.realtimeSinceStartup < tradeDeadline && - (!commandReceipts.TryGetValue(trade.CommandId, out var observedTrade) || - runtime.ActiveVersion < observedTrade.SourceVersion)) - { - yield return new WaitForSecondsRealtime(0.1f); - runtime.Refresh(); - } - Assert.That(commandReceipts.TryGetValue(trade.CommandId, out var tradeCommandReceipt), Is.True); - Assert.That( - tradeCommandReceipt.State, - Is.EqualTo("accepted").Or.EqualTo("reconciled"), - tradeCommandReceipt.Message); - tradeReceipt = WitnessReceipt.From("trade", tradeCommandReceipt); - - var undock = runtime.SubmitActionIntent(playerId, undockAction.ActionId); - var resumeDeparture = runtime.SubmitActionIntent(playerId, stepAction.ActionId); - var undockDeadline = Time.realtimeSinceStartup + 12f; - while (Time.realtimeSinceStartup < undockDeadline && - (!commandReceipts.TryGetValue(undock.CommandId, out var observedUndock) || - runtime.ActiveVersion < observedUndock.SourceVersion || - !string.Equals(runtime.ActiveWorld.PresentationMode, "world", StringComparison.Ordinal))) - { - yield return new WaitForSecondsRealtime(0.1f); - runtime.Refresh(); - } - Assert.That(commandReceipts.TryGetValue(resumeDeparture.CommandId, out var resumeDepartureReceipt), Is.True); - Assert.That(resumeDepartureReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), resumeDepartureReceipt.Message); - Assert.That(commandReceipts.TryGetValue(undock.CommandId, out var undockReceipt), Is.True); - Assert.That(undockReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), undockReceipt.Message); - Assert.That(runtime.ActiveWorld.PresentationMode, Is.EqualTo("world")); - Assert.That(runtime.ActiveWorld.CameraTargetEntityId, Is.EqualTo(playerId)); - Assert.That(runtime.ActiveWorld.SubjectVisible, Is.True); - Assert.That(runtime.ActiveWorld.MovementEnabled, Is.True); - Assert.That(FindMarker(root, playerId).gameObject.activeSelf, Is.True); - undocked = true; - var undockPosition = marker.transform.position; - - var pause = runtime.SubmitActionIntent(playerId, pauseAction.ActionId); - var pauseDeadline = Time.realtimeSinceStartup + 12f; - while (Time.realtimeSinceStartup < pauseDeadline && - (!commandReceipts.TryGetValue(pause.CommandId, out var observedPause) || - runtime.ActiveVersion < observedPause.SourceVersion)) - { - yield return new WaitForSecondsRealtime(0.1f); - runtime.Refresh(); - } - Assert.That(commandReceipts.TryGetValue(pause.CommandId, out var pauseReceipt), Is.True); - Assert.That(pauseReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), pauseReceipt.Message); - - cargoQuantityBeforePickup = ReadInt32EntitySemantic( - provider.CurrentEntityView, - playerId, - "inventory.cargo.quantity"); - - var scoop = runtime.SubmitActionIntent(playerId, scoopAction.ActionId); - var initialScoopDeadline = Time.realtimeSinceStartup + 12f; - while (Time.realtimeSinceStartup < initialScoopDeadline && - !commandReceipts.TryGetValue(scoop.CommandId, out _)) - { - yield return new WaitForSecondsRealtime(0.1f); - runtime.Refresh(); - } - Assert.That(commandReceipts.TryGetValue(scoop.CommandId, out var initialScoopReceipt), Is.True); - Assert.That(initialScoopReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), initialScoopReceipt.Message); - var resumeScoop = runtime.SubmitActionIntent(playerId, realtimeAction.ActionId); - var resumeScoopDeadline = Time.realtimeSinceStartup + 12f; - while (Time.realtimeSinceStartup < resumeScoopDeadline && - (!commandReceipts.TryGetValue(resumeScoop.CommandId, out var observedResumeScoop) || - runtime.ActiveVersion < observedResumeScoop.SourceVersion)) - { - yield return new WaitForSecondsRealtime(0.1f); - runtime.Refresh(); - } - Assert.That(commandReceipts.TryGetValue(resumeScoop.CommandId, out var resumeScoopReceipt), Is.True); - Assert.That(resumeScoopReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), resumeScoopReceipt.Message); - var collectionDeadline = Time.realtimeSinceStartup + 25f; - while (Time.realtimeSinceStartup < collectionDeadline && - root.GetComponentsInChildren() - .Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal))) - { - yield return new WaitForSecondsRealtime(0.1f); - runtime.Refresh(); - } - pickupCollected = !root.GetComponentsInChildren() - .Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal)); - var uncollectedPickup = root.GetComponentsInChildren() - .FirstOrDefault(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal)); - var currentCargoQuantity = ReadInt32EntitySemantic( - provider.CurrentEntityView, - playerId, - "inventory.cargo.quantity"); - Assert.That(pickupCollected, Is.True, - uncollectedPickup == null - ? "The pilot bot did not collect the daemon-authored salvage." - : $"The pilot bot did not collect the daemon-authored salvage. " + - $"player={FindMarker(root, playerId).transform.position}, " + - $"pickup={uncollectedPickup.transform.position}, " + - $"distance={Vector3.Distance(FindMarker(root, playerId).transform.position, uncollectedPickup.transform.position):0.###}, " + - $"cargo={currentCargoQuantity}."); - cargoQuantityAfterPickup = ReadInt32EntitySemantic( - provider.CurrentEntityView, - playerId, - "inventory.cargo.quantity"); - Assert.That(cargoQuantityAfterPickup, Is.EqualTo(cargoQuantityBeforePickup + 1), - "The pickup left the world without an authoritative cargo-unit delta in the portable Eve SoA view."); - - var combatStartVersion = runtime.ActiveVersion; - var visibleMarkers = root.GetComponentsInChildren(); - var hostileTargets = visibleMarkers - .Where(candidate => candidate.Selectable && !candidate.Controllable && - string.Equals(candidate.EntityKind, "ship", StringComparison.OrdinalIgnoreCase) && - !string.Equals(candidate.Faction, marker.Faction, StringComparison.Ordinal)) - .OrderBy(candidate => Vector3.Distance(candidate.transform.position, marker.transform.position)) - .ToArray(); - Assert.That(hostileTargets, Is.Not.Empty, - "No hostile contact was available from the ship's own sensors immediately after undocking. " + - $"The station-only contact was {stationObservedHostileId}. Current entities: " + - string.Join(", ", visibleMarkers.Select(candidate => - $"{candidate.EntityId}[kind={candidate.EntityKind},faction={candidate.Faction},selectable={candidate.Selectable}]"))); - var targetEntityId = hostileTargets[0].EntityId; - var focus = runtime.SubmitTargetIntent(playerId, targetEntityId); - var focusVersion = runtime.ActiveVersion; - var aimVector = hostileTargets[0].transform.position - marker.transform.position; - var aim = runtime.SubmitMoveVectorIntent( - playerId, - aimVector.x, - aimVector.z, - 0.1f); - var trajectoryRenderer = root.AddComponent(); - runtime.ShotAvailable += shot => - { - var playerIdentity = provider.CurrentEntityView?.Identities - .FirstOrDefault(identity => string.Equals(identity.EntityId, playerId, StringComparison.Ordinal)); - if (playerIdentity != null && shot.SourceEntityIndex == playerIdentity.EntityIndex) - { - combatShot = shot; - trajectoryRenderer.Present(shot); - } - }; - Assert.That(provider.CurrentInputCapability, Is.Not.Null, "The pilot world did not advertise a typed input capability document."); - Assert.That(provider.CurrentInputCapability.Actions, Is.Not.Empty, "The pilot input capability advertised no available actions."); - var advertisedAction = provider.CurrentInputCapability.Actions - .First(candidate => string.Equals(candidate.Category, "weapon-group", StringComparison.Ordinal) && - string.Equals(candidate.Availability, "available", StringComparison.OrdinalIgnoreCase)); - combatActionId = advertisedAction.ActionId; - var action = runtime.SubmitActionIntent(playerId, advertisedAction.ActionId); - var resumeCombat = runtime.SubmitActionIntent(playerId, realtimeAction.ActionId); - var resumeCombatDeadline = Time.realtimeSinceStartup + 12f; - while (Time.realtimeSinceStartup < resumeCombatDeadline && - (!commandReceipts.TryGetValue(resumeCombat.CommandId, out var observedResumeCombat) || - runtime.ActiveVersion < observedResumeCombat.SourceVersion)) - { - yield return new WaitForSecondsRealtime(0.1f); - runtime.Refresh(); - } - Assert.That(commandReceipts.TryGetValue(resumeCombat.CommandId, out var resumeCombatReceipt), Is.True); - Assert.That(resumeCombatReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled"), resumeCombatReceipt.Message); - var actionDeadline = Time.realtimeSinceStartup + 12f; - while (Time.realtimeSinceStartup < actionDeadline && - (!commandReceipts.TryGetValue(focus.CommandId, out var observedFocus) || - runtime.ActiveVersion < observedFocus.SourceVersion || - !commandReceipts.TryGetValue(aim.CommandId, out var observedAim) || - runtime.ActiveVersion < observedAim.SourceVersion || - !commandReceipts.TryGetValue(action.CommandId, out var observedAction) || - runtime.ActiveVersion < observedAction.SourceVersion || - Vector3.Distance(FindMarker(root, playerId).transform.position, undockPosition) < 0.01f || - combatShot == null)) - { - yield return new WaitForSecondsRealtime(0.1f); - runtime.Refresh(); - } - Assert.That(commandReceipts.TryGetValue(focus.CommandId, out var focusCommandReceipt), Is.True); - Assert.That( - focusCommandReceipt.State, - Is.EqualTo("accepted").Or.EqualTo("reconciled"), - focusCommandReceipt.Message); - Assert.That(focusCommandReceipt.SourceVersion, Is.GreaterThan(combatStartVersion)); - focusReceipt = WitnessReceipt.From("targeting", focusCommandReceipt); - Assert.That(commandReceipts.TryGetValue(aim.CommandId, out var aimCommandReceipt), Is.True); - Assert.That( - aimCommandReceipt.State, - Is.EqualTo("accepted").Or.EqualTo("reconciled"), - aimCommandReceipt.Message); - movementDistance = Vector3.Distance(FindMarker(root, playerId).transform.position, undockPosition); - movementReceipt = WitnessReceipt.From("movement", aimCommandReceipt); - Assert.That(movementDistance, Is.GreaterThan(0.01f)); - Assert.That(commandReceipts.TryGetValue(action.CommandId, out var actionCommandReceipt), Is.True); - Assert.That( - actionCommandReceipt.State, - Is.EqualTo("accepted").Or.EqualTo("reconciled"), - actionCommandReceipt.Message); - Assert.That(actionCommandReceipt.SourceVersion, Is.GreaterThan(focusVersion)); - Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(actionCommandReceipt.SourceVersion)); - Assert.That(combatShot, Is.Not.Null, - $"The daemon accepted fire but published no shot receipt through Eve. Weapon state: {DescribeWeaponStates(provider)}"); - Assert.That(combatShot.ShotId, Is.Not.Empty); - var playerEntityIndex = provider.CurrentEntityView.Identities - .Single(identity => string.Equals(identity.EntityId, playerId, StringComparison.Ordinal)).EntityIndex; - Assert.That(combatShot.SourceEntityIndex, Is.EqualTo(playerEntityIndex)); - Assert.That(combatShot.TargetEntityIndex, Is.GreaterThanOrEqualTo(0)); - Assert.That(combatShot.PresentationKind, Is.Not.Empty); - Assert.That(trajectoryRenderer.ActiveTrajectoryCount, Is.GreaterThan(0)); - actionReceipt = WitnessReceipt.From("action", actionCommandReceipt); - - if (!pickupCollected) - { - var retryScoopAction = provider.CurrentInputCapability.Actions - .Single(candidate => string.Equals(candidate.ActionId, "pilot.scoop", StringComparison.Ordinal)); - var retryScoop = runtime.SubmitActionIntent(playerId, retryScoopAction.ActionId); - var scoopDeadline = Time.realtimeSinceStartup + 12f; - while (Time.realtimeSinceStartup < scoopDeadline && - (!commandReceipts.TryGetValue(retryScoop.CommandId, out var observedScoop) || - runtime.ActiveVersion < observedScoop.SourceVersion)) - { - yield return new WaitForSecondsRealtime(0.1f); - runtime.Refresh(); - } - Assert.That(commandReceipts.TryGetValue(retryScoop.CommandId, out var scoopCommandReceipt), Is.True); - Assert.That( - scoopCommandReceipt.State, - Is.EqualTo("accepted").Or.EqualTo("reconciled"), - scoopCommandReceipt.Message); - - pickupCollected = !root.GetComponentsInChildren() - .Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal)); - if (pickupCollected) - { - movementDistance = Vector3.Distance(marker.transform.position, undockPosition); - movementReceipt = WitnessReceipt.From("movement", aimCommandReceipt); - } - else + initialVersion = runtime.ActiveVersion; + if (requireSessionGameplay) { var initialPosition = marker.transform.position; - initialVersion = runtime.ActiveVersion; - var currentPickup = root.GetComponentsInChildren() - .Single(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal)); - var pickupVector = currentPickup.transform.position - marker.transform.position; - var request = runtime.SubmitMoveVectorIntent(playerId, pickupVector.x, pickupVector.z, 1f); + var request = runtime.SubmitMoveVectorIntent(playerId, 1f, 0f, 1f); var deadline = Time.realtimeSinceStartup + 12f; while (Time.realtimeSinceStartup < deadline && - (!commandReceipts.TryGetValue(request.CommandId, out var observedMovement) || - runtime.ActiveVersion < observedMovement.SourceVersion || + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != request.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion <= initialVersion || Vector3.Distance(FindMarker(root, playerId).transform.position, initialPosition) < 0.01f)) { yield return new WaitForSecondsRealtime(0.1f); runtime.Refresh(); } - Assert.That(commandReceipts.TryGetValue(request.CommandId, out var movementCommandReceipt), Is.True); - Assert.That( - movementCommandReceipt.State, - Is.EqualTo("accepted").Or.EqualTo("reconciled"), - movementCommandReceipt.Message); - Assert.That(movementCommandReceipt.SourceVersion, Is.GreaterThan(initialVersion)); - Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(movementCommandReceipt.SourceVersion)); + AssertReconciledProviderReceipt( + runtime.LastReceipt, + request.CommandId, + provider.Selection.ProviderId, + provider.Selection.SurfaceId, + initialVersion); + Assert.That(runtime.ActiveVersion, Is.GreaterThan(initialVersion)); + Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(runtime.LastReceipt.SourceVersion)); movementDistance = Vector3.Distance(FindMarker(root, playerId).transform.position, initialPosition); - var pickupDeadline = Time.realtimeSinceStartup + 20f; - var nextSteeringUpdate = 0f; - while (Time.realtimeSinceStartup < pickupDeadline && - root.GetComponentsInChildren() - .Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal))) + Assert.That(movementDistance, Is.GreaterThan(0.01f)); + movementReceipt = WitnessReceipt.From("movement", runtime.LastReceipt); + + var releaseVersion = runtime.ActiveVersion; + var release = runtime.SubmitMoveVectorIntent(playerId, 0f, 0f, 0f); + var releaseDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < releaseDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != release.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase))) { - if (Time.realtimeSinceStartup >= nextSteeringUpdate) - { - var currentPlayer = FindMarker(root, playerId); - var observedPickup = root.GetComponentsInChildren() - .FirstOrDefault(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal)); - if (observedPickup != null) - { - var correction = observedPickup.transform.position - currentPlayer.transform.position; - runtime.SubmitMoveVectorIntent(playerId, correction.x, correction.z, 1f); - } - nextSteeringUpdate = Time.realtimeSinceStartup + 0.25f; - } yield return new WaitForSecondsRealtime(0.1f); runtime.Refresh(); } - pickupCollected = !root.GetComponentsInChildren() - .Any(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal)); - movementReceipt = WitnessReceipt.From("movement", movementCommandReceipt); - } - Assert.That(movementDistance, Is.GreaterThan(0.01f)); - var remainingPickup = root.GetComponentsInChildren() - .FirstOrDefault(candidate => string.Equals(candidate.EntityId, collectedPickupId, StringComparison.Ordinal)); - Assert.That( - pickupCollected, - Is.True, - remainingPickup == null - ? "Ymir/daemon simulation retained a collected pickup outside the active Eve entity view." - : $"Ymir/daemon simulation produced no pickup contact. " + - $"player={marker.transform.position}, pickup={remainingPickup.transform.position}, " + - $"distance={Vector3.Distance(marker.transform.position, remainingPickup.transform.position):0.###}."); + AssertReconciledProviderReceipt(runtime.LastReceipt, release.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, releaseVersion); } - var stop = runtime.SubmitMoveVectorIntent(playerId, 0f, 0f, 0f); - var stopDeadline = Time.realtimeSinceStartup + 12f; - while (Time.realtimeSinceStartup < stopDeadline && - (!commandReceipts.TryGetValue(stop.CommandId, out var observedStop) || - runtime.ActiveVersion < observedStop.SourceVersion)) + if (requireSessionGameplay) + { + var movementVersion = runtime.ActiveVersion; + var focus = runtime.SubmitFocusIntent(playerId); + var focusDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < focusDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != focus.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion || + string.IsNullOrWhiteSpace(EveUnityCombatPresentation.Find(runtime.ActiveProjection)?.SelectedTargetEntityId))) { yield return new WaitForSecondsRealtime(0.1f); runtime.Refresh(); } - Assert.That(commandReceipts.TryGetValue(stop.CommandId, out var stopCommandReceipt), Is.True); - Assert.That( - stopCommandReceipt.State, - Is.EqualTo("accepted").Or.EqualTo("reconciled"), - stopCommandReceipt.Message); + AssertReconciledProviderReceipt( + runtime.LastReceipt, + focus.CommandId, + provider.Selection.ProviderId, + provider.Selection.SurfaceId, + movementVersion); + Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(runtime.LastReceipt.SourceVersion)); + focusReceipt = WitnessReceipt.From("targeting", runtime.LastReceipt); + observedCombat = EveUnityCombatPresentation.Find(runtime.ActiveProjection); + Assert.That(observedCombat, Is.Not.Null); + Assert.That(observedCombat.TargetVisible, Is.True); + Assert.That(observedCombat.TargetHostile, Is.True); + var targetEntityId = observedCombat.SelectedTargetEntityId; + var targetEntityIndex = observedCombat.SelectedTargetEntityIndex; + Assert.That(targetEntityId, Is.Not.Empty); + Assert.That(host.PresentedEntities.CurrentGeneration.Entities.Any(entity => entity.EntityId == targetEntityId), Is.True, + "The provider-selected combat target is absent from the advertised SoA generation."); + shieldRatioBefore = observedCombat.ShieldRatio; + hullRatioBefore = observedCombat.HullRatio; + + var targetFact = host.PresentedEntities.CurrentGeneration.Entities + .Single(entity => entity.EntityId == targetEntityId); + playerFact = host.PresentedEntities.CurrentGeneration.Entities + .Single(entity => entity.EntityId == playerId); + var aim = targetFact.Position - playerFact.Position; + aim.y = 0; + Assert.That(aim.sqrMagnitude, Is.GreaterThan(0.0001f)); + aim.Normalize(); + var lookVersion = runtime.ActiveVersion; + var look = runtime.SubmitLookDirectionIntent(playerId, aim.x, 0f, aim.z); + var lookDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < lookDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != look.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion || + Vector3.Dot(FindMarker(root, playerId).transform.forward, aim) < 0.99f)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, look.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, lookVersion); + Assert.That(Vector3.Dot(FindMarker(root, playerId).transform.forward, aim), Is.GreaterThan(0.99f)); + lookReceipt = WitnessReceipt.From("look", runtime.LastReceipt); + aimRenderer = root.GetComponent(); + Assert.That(aimRenderer, Is.Not.Null); + + beamPresentation = EveUnityBeamPresentation.FindAll(runtime.ActiveProjection).SingleOrDefault(); + Assert.That(beamPresentation, Is.Not.Null, + "The provider did not advertise its continuous beam presentation."); + Assert.That(beamPresentation.ActivationActionId, Is.Not.Empty, + "The beam presentation does not name an advertised generic action."); + var tractorVersion = runtime.ActiveVersion; + var tractor = host.SubmitAdvertisedActionValueIntent(playerId, beamPresentation.ActivationActionId, 1f); + var tractorDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < tractorDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != tractor.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion || + EveUnityBeamPresentation.FindAll(runtime.ActiveProjection).Single().Power < 0.99f)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, tractor.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, tractorVersion); + tractorReceipt = WitnessReceipt.From("beam", runtime.LastReceipt); + Assert.That(beamPresentation.SourceEntityId, Is.EqualTo(playerId), + "The daemon beam source does not identify the presented player entity."); + Assert.That(host.PresentedEntities.TryGetByEntityId(beamPresentation.SourceEntityId, out _), Is.True, + "The generic beam lowerer cannot resolve the advertised source entity."); + Assert.That(provider.ResolvePrefab(new EveUnityPlayableWorldAssetBinding( + beamPresentation.AssetRole, beamPresentation.AssetRole, "provider-asset-ref")), Is.Not.Null, + $"The provider asset catalog did not resolve beam role '{beamPresentation.AssetRole}'."); + beamRenderer = root.GetComponent(); + Assert.That(beamRenderer, Is.Not.Null); + beamRenderer.RefreshNow(); + Assert.That(beamRenderer.ActiveBeamCount, Is.EqualTo(1)); + Assert.That(beamRenderer.TryGetPower(beamPresentation.Id, out tractorBeamPower), Is.True); + Assert.That(tractorBeamPower, Is.GreaterThanOrEqualTo(0.99f), + "Held input did not reach full daemon-authored tractor power before capture."); + tractorBeamParticleSystemCount = beamRenderer.ActiveParticleSystemCount; + Assert.That(tractorBeamParticleSystemCount, Is.GreaterThan(0), + "The generic beam lowerer did not instantiate the provider-owned effect prefab."); + targetFact = host.PresentedEntities.CurrentGeneration.Entities + .Single(entity => entity.EntityId == targetEntityId); + playerFact = host.PresentedEntities.CurrentGeneration.Entities + .Single(entity => entity.EntityId == playerId); + aim = targetFact.Position - playerFact.Position; + aim.y = 0; + Assert.That(aim.sqrMagnitude, Is.GreaterThan(0.0001f)); + aim.Normalize(); + lookVersion = runtime.ActiveVersion; + look = runtime.SubmitLookDirectionIntent(playerId, aim.x, 0f, aim.z); + lookDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < lookDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != look.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion || + Vector3.Dot(FindMarker(root, playerId).transform.forward, aim) < 0.99f)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, look.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, lookVersion); + Assert.That(Vector3.Dot(FindMarker(root, playerId).transform.forward, aim), Is.GreaterThan(0.99f)); + lookReceipt = WitnessReceipt.From("look", runtime.LastReceipt); + + var combatRenderer = root.GetComponent(); + Assert.That(combatRenderer, Is.Not.Null); + var focusVersion = runtime.ActiveVersion; + var inputDriver = root.GetComponent() ?? + root.AddComponent(); + inputDriver.Host = host; + var primaryActionId = EveUnityPlayableWorldInputDriver.ResolvePrimaryActionId(host.InputCapability); + Assert.That(primaryActionId, Is.Not.Empty, + "The advertised input capability did not bind a primary action for this generic client."); + var primaryAction = EveUnityAdvertisedInputAction.Resolve(host.InputCapability, primaryActionId); + Assert.That(primaryAction.IsButtonHold, Is.True, + "The witness primary action must be driven through its advertised held-input semantics."); + var action = inputDriver.SubmitActionValue(primaryActionId, 1f); + Assert.That(action, Is.Not.Null, + "The advertised input capability did not bind a primary action for this generic client."); + var actionDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < actionDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != action!.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + combatRenderer.RefreshNow(); + hitMarkerObserved |= combatRenderer.HitMarkerVisible; + } + AssertReconciledProviderReceipt( + runtime.LastReceipt, + action!.CommandId, + provider.Selection.ProviderId, + provider.Selection.SurfaceId, + focusVersion); + Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(runtime.LastReceipt.SourceVersion)); + actionReceipt = WitnessReceipt.From("action", runtime.LastReceipt); + + var shotDeadline = Time.realtimeSinceStartup + 20f; + while (Time.realtimeSinceStartup < shotDeadline && + (observedShot == null || + host.PresentedEntities.CurrentGeneration.Entities.Any(entity => + entity.EntityId == targetEntityId) || + (!destructionPickupObserved && pickupCollection == null && pickupRejection == null))) + { + yield return new WaitForSecondsRealtime(0.05f); + runtime.Refresh(); + destructionPickupObserved |= host.PresentedEntities.CurrentGeneration.Entities.Any(entity => + string.Equals(entity.EntityKind, "pickup", StringComparison.Ordinal)); + maximumTrajectoryCount = Math.Max(maximumTrajectoryCount, trajectories.ActiveTrajectoryCount); + combatRenderer.RefreshNow(); + hitMarkerObserved |= combatRenderer.HitMarkerVisible; + } + Assert.That(observedShot, Is.Not.Null, + "A reconciled fire request never reached daemon-owned lock acquisition and shot resolution."); + Assert.That(host.PresentedEntities.CurrentGeneration.Entities.Any(entity => + entity.EntityId == targetEntityId), Is.False, + "The low-hull witness raider was not destroyed by daemon combat."); + Assert.That(destructionPickupObserved || pickupCollection != null || pickupRejection != null, Is.True, + "Daemon destruction did not publish its guaranteed cargo as a pickup."); + // The provider advances a fixed-step simulation while also publishing the large SoA witness. + // Allow enough wall time for the tractor to cover its advertised volume without making the + // generic client manufacture ship motion or loot authority. + var collectionDeadline = Time.realtimeSinceStartup + 45f; + var nextTractorAimAt = 0f; + while (Time.realtimeSinceStartup < collectionDeadline && + (expectCargoRejection + ? pickupRejection == null + : pickupCollection == null || host.PresentedEntities.CurrentGeneration.Entities.Any(entity => + string.Equals(entity.EntityKind, "pickup", StringComparison.Ordinal)))) + { + yield return new WaitForSecondsRealtime(0.05f); + runtime.Refresh(); + maximumTrajectoryCount = Math.Max(maximumTrajectoryCount, trajectories.ActiveTrajectoryCount); + combatRenderer.RefreshNow(); + hitMarkerObserved |= combatRenderer.HitMarkerVisible; + var pickupFact = host.PresentedEntities.CurrentGeneration.Entities.FirstOrDefault(entity => + string.Equals(entity.EntityKind, "pickup", StringComparison.Ordinal)); + if (pickupFact == null || Time.realtimeSinceStartup < nextTractorAimAt) + continue; + playerFact = host.PresentedEntities.CurrentGeneration.Entities + .Single(entity => entity.EntityId == playerId); + var tractorAim = pickupFact.Position - playerFact.Position; + tractorAim.y = 0; + if (tractorAim.sqrMagnitude <= 0.0001f) + continue; + tractorAim.Normalize(); + runtime.SubmitLookDirectionIntent(playerId, tractorAim.x, 0f, tractorAim.z); + nextTractorAimAt = Time.realtimeSinceStartup + 0.5f; + } + finalPickupEntityCount = host.PresentedEntities.CurrentGeneration.Entities.Count(entity => + string.Equals(entity.EntityKind, "pickup", StringComparison.Ordinal)); + if (expectCargoRejection) + { + Assert.That(pickupRejection, Is.Not.Null, + "Full cargo did not reject destruction salvage at daemon pickup proximity."); + Assert.That(pickupRejectionEventCount, Is.EqualTo(1), + "The first destruction-loot proximity attempt must emit one rejection event."); + Assert.That(pickupRejectionEventIds.Count, Is.EqualTo(pickupRejectionEventCount), + "One daemon proximity attempt emitted duplicate player rejection feedback."); + Assert.That(pickupCollectionEventCount, Is.Zero, + "Capacity-rejected salvage was also collected."); + Assert.That(pickupRejection.ItemKey, Is.Not.Empty); + Assert.That(pickupRejection.EventId, Does.StartWith("frame:"), + "Capacity feedback must retain its daemon frame/zone/pickup attempt identity."); + Assert.That(pickupRejection.Reason, Is.EqualTo("cargo-capacity")); + Assert.That(pickupRejection.ScalarValue, Is.EqualTo(1)); + Assert.That(pickupRejection.CargoQuantityAfter, + Is.EqualTo(pickupRejection.CargoQuantityBefore)); + Assert.That(finalPickupEntityCount, Is.EqualTo(1), + "Capacity-rejected destruction loot disappeared from provider state."); + } + else + { + Assert.That(pickupCollection, Is.Not.Null, + "Destruction-created salvage never reached cargo through daemon pickup proximity."); + Assert.That(pickupCollectionEventCount, Is.EqualTo(1), + "One durable destruction pickup must emit one collection event."); + Assert.That(pickupRejectionEventCount, Is.Zero, + "Collectible destruction salvage was also rejected."); + Assert.That(pickupCollection.ItemKey, Is.Not.Empty, + "Destruction loot must retain its canonical typed item identity."); + Assert.That(pickupCollection.EventId, Does.StartWith("zone:"), + "Collection feedback must retain its stable daemon zone/pickup identity."); + Assert.That(pickupCollection.ScalarValue, Is.EqualTo(1)); + Assert.That(pickupCollection.CargoQuantityBefore, Is.Zero); + Assert.That(pickupCollection.CargoQuantityAfter, Is.EqualTo(1)); + Assert.That(finalPickupEntityCount, Is.Zero, + "Collected destruction loot remained in the provider-authored entity generation."); + } + observedCombat = EveUnityCombatPresentation.Find(runtime.ActiveProjection); + Assert.That(observedShot.TargetEntityIndex, Is.EqualTo(targetEntityIndex)); + Assert.That(observedShot.LockQuality, Is.GreaterThanOrEqualTo(0.99)); + Assert.That(maximumTrajectoryCount, Is.GreaterThan(0)); + combatRenderer.RefreshNow(); + if (observedCombat != null && !string.IsNullOrWhiteSpace(observedCombat.SelectedTargetEntityId)) + { + Assert.That(combatRenderer.ReticleVisible, Is.True); + Assert.That(combatRenderer.LockVisible, Is.True); + } + if (observedShot.Hit && (observedShot.AppliedDamage > 0 || observedShot.ShieldAbsorbedDamage > 0)) + Assert.That(hitMarkerObserved, Is.True); + } + + aimRenderer ??= root.GetComponent(); + Assert.That(aimRenderer, Is.Not.Null); + beamPresentation ??= EveUnityBeamPresentation.FindAll(runtime.ActiveProjection).SingleOrDefault(); + Assert.That(beamPresentation, Is.Not.Null, + "The provider did not advertise its continuous beam presentation."); + beamRenderer ??= root.GetComponent(); + finalPickupEntityCount = Math.Max(0, finalPickupEntityCount); - var light = lightObject.AddComponent(); - light.type = LightType.Directional; - light.intensity = 1.4f; - lightObject.transform.rotation = Quaternion.Euler(45f, -35f, 0f); var camera = cameraObject.AddComponent(); camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = new Color(0.035f, 0.055f, 0.08f, 1f); - foreach (var entityMarker in root.GetComponentsInChildren()) - { - 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); - } - } + camera.cullingMask = -1; var playerMarker = FindMarker(root, playerId); var playerRenderers = playerMarker.GetComponentsInChildren(); Assert.That(playerRenderers, Is.Not.Empty, "The provider-authored player prefab has no renderable visual."); - var playerBounds = playerRenderers[0].bounds; + Assert.That(playerRenderers.Any(renderer => renderer.name == "Shield Visual"), Is.False, + "The provider hull retained an always-on shield envelope instead of using receipt-driven shield feedback."); + Assert.That(playerMarker.GetComponentsInChildren(), Is.Empty, + "The provider-authored player presentation retained gameplay colliders."); + Assert.That(playerMarker.GetComponentsInChildren(), Is.Empty, + "The provider-authored player presentation retained gameplay rigid bodies."); foreach (var renderer in playerRenderers) - playerBounds.Encapsulate(renderer.bounds); - var halfFovRadians = camera.fieldOfView * 0.5f * Mathf.Deg2Rad; - var cameraDistance = Mathf.Max(8f, playerBounds.extents.magnitude / Mathf.Tan(halfFovRadians) * 1.35f); - camera.nearClipPlane = Mathf.Max(0.01f, cameraDistance * 0.001f); - camera.farClipPlane = Mathf.Max(1000f, cameraDistance + playerBounds.size.magnitude * 4f); - camera.transform.position = playerBounds.center + new Vector3(1f, 0.65f, -1.2f).normalized * cameraDistance; - camera.transform.LookAt(playerBounds.center); - target = new RenderTexture(640, 360, 24, RenderTextureFormat.ARGB32); + foreach (var material in renderer.sharedMaterials) + { + Assert.That(material, Is.Not.Null, $"Provider renderer {renderer.name} has a null material."); + Assert.That(material.shader, Is.Not.Null, $"Provider material {material.name} has no shader."); + Assert.That(material.shader.isSupported, Is.True, + $"Provider material {material.name} uses unsupported shader {material.shader.name}."); + Assert.That(material.shader.name, Does.Not.Contain("FallbackError"), + $"Provider material {material.name} resolved to Unity's error shader."); + } + Assert.That(provider.TryGetRenderChannelLayer("map", out var mapLayer), Is.True); + var mapRenderers = new List(); + foreach (var renderer in root.GetComponentsInChildren(includeInactive: true)) + { + if (renderer.gameObject.layer == mapLayer) + mapRenderers.Add(renderer); + } + Assert.That(mapRenderers, Is.Not.Empty, "The live provider prefab has no renderer assigned to its advertised map channel."); + + const int captureWidth = 1280; + const int captureHeight = 720; + target = new RenderTexture(captureWidth, captureHeight, 24, RenderTextureFormat.ARGB32); camera.targetTexture = target; + var cameraRig = cameraObject.AddComponent(); + cameraRig.Host = host; + cameraRig.CameraTransform = camera.transform; + cameraRig.RenderPolicySource = provider; + Assert.That(cameraRig.ApplyRig(0f), Is.True); + var activePipelineAsset = GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset; + Assert.That(activePipelineAsset, Is.Not.Null, + "The generic client has no active URP asset for the advertised grading space."); + Assert.That(activePipelineAsset.colorGradingMode, Is.EqualTo(ColorGradingMode.HighDynamicRange), + "The advertised HDR-before-tonemap grading space did not reach the active URP pipeline."); + var reflection = provider.ResolveAsset(new EveUnityPlayableWorldAssetBinding( + runtime.ActiveWorld.ReflectionAssetRef, "", "provider-asset-ref"), typeof(Cubemap)) as Cubemap; + Assert.That(runtime.ActiveWorld.SkyboxAssetRef, Is.Empty, + "Aetheria's raymarch owns the frame; its reflection HDRI must not become a skybox-lighting authority."); + Assert.That(runtime.ActiveWorld.ReflectionAssetRef, Is.Not.Empty); + Assert.That(reflection, Is.Not.Null); + Assert.That(RenderSettings.ambientMode, Is.EqualTo(AmbientMode.Flat)); + Assert.That(RenderSettings.ambientLight.r, + Is.EqualTo(runtime.ActiveWorld.AmbientLightR * runtime.ActiveWorld.AmbientLightIntensity).Within(0.001f)); + Assert.That(RenderSettings.ambientLight.g, + Is.EqualTo(runtime.ActiveWorld.AmbientLightG * runtime.ActiveWorld.AmbientLightIntensity).Within(0.001f)); + Assert.That(RenderSettings.ambientLight.b, + Is.EqualTo(runtime.ActiveWorld.AmbientLightB * runtime.ActiveWorld.AmbientLightIntensity).Within(0.001f)); + Assert.That(RenderSettings.customReflectionTexture, Is.SameAs(reflection)); + var environmentPresentation = RenderSettings.ambientMode == AmbientMode.Flat && + RenderSettings.customReflectionTexture == reflection; + aimRenderer.RefreshNow(); + Assert.That(aimRenderer.ViewDotVisible, Is.True); + aimDotDistance = Vector3.Distance(playerMarker.transform.position, aimRenderer.ViewDotPosition); + Assert.That(aimDotDistance, Is.GreaterThanOrEqualTo(49.9f)); + Assert.That(camera.cullingMask & (1 << mapLayer), Is.Zero); + Assert.That(runtime.ActiveWorld.CameraRig, Is.EqualTo("perspective.entity-forward-follow.v1")); + Assert.That(runtime.ActiveWorld.CameraLookAt, Is.EqualTo("aim.convergence-point.v1")); + Assert.That(runtime.ActiveWorld.CameraDistance, Is.EqualTo(30f).Within(0.001f)); + Assert.That(camera.fieldOfView, Is.EqualTo(60f).Within(0.001f)); + Assert.That(runtime.ActiveWorld.CameraPositionDamping, Is.EqualTo(0f).Within(0.001f)); + var playerViewport = camera.WorldToViewportPoint(playerMarker.transform.position); + Assert.That(runtime.ActiveWorld.CameraTargetScreenX, Is.EqualTo(0.64f).Within(0.001f)); + Assert.That(runtime.ActiveWorld.CameraTargetScreenY, Is.EqualTo(0.19f).Within(0.001f)); + Assert.That(playerViewport.x, Is.EqualTo(runtime.ActiveWorld.CameraTargetScreenX).Within(0.01f)); + Assert.That(playerViewport.y, Is.EqualTo(runtime.ActiveWorld.CameraTargetScreenY).Within(0.01f)); + MeasureViewportCoverage(camera, playerRenderers, out var playerViewportWidth, out var playerViewportHeight); + var playerRendererFacts = playerRenderers + .Select(renderer => RendererFact.From(camera, renderer)) + .ToArray(); + var texturedPlayerMaterialCount = playerRenderers + .SelectMany(renderer => renderer.sharedMaterials) + .Where(material => material != null) + .Distinct() + .Count(HasAnyTexture); + var expectedTexturedPlayerMaterialCount = requireSessionGameplay ? 8 : 7; + Assert.That(texturedPlayerMaterialCount, Is.GreaterThanOrEqualTo(expectedTexturedPlayerMaterialCount), + "The provider bundle must retain its advertised native material textures."); + var directionalLightIntensity = UnityEngine.Object.FindObjectsOfType() + .Where(light => light != null && light.enabled && light.type == LightType.Directional) + .Sum(light => light.intensity); + Assert.That(directionalLightIntensity, + Is.EqualTo(runtime.ActiveWorld.KeyLightIntensity).Within(0.001f), + "The live witness contains directional lighting not owned by the advertised provider surface."); + + var mapCamera = mapCameraObject.AddComponent(); + mapCamera.cullingMask = 1 << mapLayer; + Assert.That(mapCamera.cullingMask, Is.EqualTo(1 << mapLayer)); + var captureFieldVolume = root.GetComponent(); + Assert.That(captureFieldVolume, Is.Not.Null, + "The generic client did not install its Fields volume lowerer before temporal settling."); + const int temporalSettlingFrames = 128; + var temporalSettlingElapsed = Stopwatch.StartNew(); + var initialCompositeCount = captureFieldVolume.CompositeCount; + var temporalSettlingDeadline = Time.realtimeSinceStartup + 20f; + while (captureFieldVolume.CompositeCount - initialCompositeCount < temporalSettlingFrames && + Time.realtimeSinceStartup < temporalSettlingDeadline) + { + camera.Render(); + yield return null; + } + Assert.That(captureFieldVolume.CompositeCount - initialCompositeCount, + Is.GreaterThanOrEqualTo(temporalSettlingFrames), + "The pilot capture ran before the advertised temporal reconstruction had settled."); + Debug.Log($"EveUnity witness phase temporal-settling-128-frames took {temporalSettlingElapsed.Elapsed.TotalMilliseconds:0.###}ms."); + var pilotCaptureElapsed = Stopwatch.StartNew(); yield return null; - camera.Render(); RenderTexture.active = target; - pixels = new Texture2D(640, 360, TextureFormat.RGB24, false); - pixels.ReadPixels(new Rect(0, 0, 640, 360), 0, 0); + pixels = new Texture2D(captureWidth, captureHeight, TextureFormat.RGB24, false); + pixels.ReadPixels(new Rect(0, 0, captureWidth, captureHeight), 0, 0); pixels.Apply(); + var pilotChangedPixels = CountChangedPixels(pixels, camera.backgroundColor); + MeasureLuminance(pixels, out var pilotAverageLuminance, out var pilotBrightPixelCount); + Assert.That(pilotChangedPixels, Is.GreaterThan(25), "The pilot camera rendered no provider-authored world pixels."); Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(capturePath))); File.WriteAllBytes(capturePath, pixels.EncodeToPNG()); Assert.That(new FileInfo(capturePath).Length, Is.GreaterThan(1024)); + WriteFieldLayerCaptures(root, host.Runtime.ActiveWorld, capturePath); + WriteFieldCloudCapture(root, capturePath); + MeasureRendererOutputs( + camera, + target, + pixels, + root.GetComponentsInChildren(includeInactive: true), + playerRenderers, + playerRendererFacts); + var playerMapRendererFacts = playerRendererFacts + .Where(fact => fact.layer == mapLayer) + .ToArray(); + Assert.That(playerMapRendererFacts, Is.Not.Empty, + "The provider-authored player prefab has no renderer on its advertised map channel."); + Assert.That(playerMapRendererFacts.Sum(fact => fact.renderedPixelCount), Is.Zero, + "A provider-prefab map-channel renderer leaked into the pilot camera."); + Debug.Log($"EveUnity witness phase pilot-capture-and-renderer-probes took {pilotCaptureElapsed.Elapsed.TotalMilliseconds:0.###}ms."); + + var mapCaptureElapsed = Stopwatch.StartNew(); + mapRenderers = mapRenderers.Where(renderer => renderer != null).ToList(); + Assert.That(mapRenderers, Is.Not.Empty, + "All advertised map-channel renderers were destroyed before map capture."); + var mapBounds = mapRenderers[0].bounds; + foreach (var renderer in mapRenderers) + mapBounds.Encapsulate(renderer.bounds); + mapCamera.clearFlags = CameraClearFlags.SolidColor; + mapCamera.backgroundColor = camera.backgroundColor; + mapCamera.orthographic = true; + mapCamera.orthographicSize = Mathf.Max(0.1f, mapBounds.extents.x, mapBounds.extents.z) * 1.25f; + mapCamera.nearClipPlane = 0.01f; + mapCamera.farClipPlane = Mathf.Max(10f, mapBounds.size.magnitude * 4f); + mapCamera.transform.position = mapBounds.center + Vector3.up * (mapCamera.farClipPlane * 0.25f); + mapCamera.transform.rotation = Quaternion.Euler(90f, 0f, 0f); + mapCamera.targetTexture = target; + var fieldParticlesForMap = root.GetComponent(); + var particleDrawsBeforeMap = fieldParticlesForMap?.DrawCount ?? 0; + mapCamera.Render(); + var particleMapCameraIsolated = fieldParticlesForMap != null && + fieldParticlesForMap.DrawCount == particleDrawsBeforeMap; + Assert.That(particleMapCameraIsolated, Is.True, + "The map camera executed the pilot-only field-particle pass."); + RenderTexture.active = target; + pixels.ReadPixels(new Rect(0, 0, captureWidth, captureHeight), 0, 0); + pixels.Apply(); + var mapChangedPixels = CountChangedPixels(pixels, mapCamera.backgroundColor); + Assert.That(mapChangedPixels, Is.GreaterThan(25), "The map-only camera rendered no provider-authored map glyph pixels."); + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(mapCapturePath))); + File.WriteAllBytes(mapCapturePath, pixels.EncodeToPNG()); + Assert.That(new FileInfo(mapCapturePath).Length, Is.GreaterThan(1024)); + Debug.Log($"EveUnity witness phase map-capture took {mapCaptureElapsed.Elapsed.TotalMilliseconds:0.###}ms."); + if (requireSessionGameplay) + { + var tractorReleaseVersion = runtime.ActiveVersion; + var tractorRelease = host.SubmitAdvertisedActionValueIntent( + playerId, + beamPresentation.ActivationActionId, + 0f); + var tractorReleaseDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < tractorReleaseDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != tractorRelease.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, tractorRelease.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, tractorReleaseVersion); + tractorReleaseReceipt = WitnessReceipt.From("beam-release", runtime.LastReceipt); + beamRenderer.RefreshNow(); + Assert.That(beamRenderer.TryGetPower(beamPresentation.Id, out tractorReleasedPower), Is.True); + } + var fieldVolume = root.GetComponent(); + Assert.That(fieldVolume, Is.Not.Null, "The generic client did not install its Fields volume lowerer."); + Assert.That(fieldVolume.PresentedFrameId, Is.GreaterThanOrEqualTo(0), + "The advertised Fields splat document never reached the generic volume lowerer."); + Assert.That(fieldVolume.PresentedLayerCount, Is.GreaterThan(0), + "The generic volume lowerer did not rasterize any advertised Fields layers."); + Assert.That(fieldVolume.CompositeCount, Is.GreaterThan(0), + "The generic volume lowerer never composited into the pilot camera."); + var fieldParticles = fieldParticlesForMap; + Assert.That(fieldParticles, Is.Not.Null, + "The generic client did not install its field-particle lowerer."); + var particleProjection = host.ActiveWorld.FieldParticles.Single(); + var particleComputeBinding = new EveUnityPlayableWorldAssetBinding( + particleProjection.ComputeProgramAssetRef, "", "provider-asset-ref"); + var particleMaterialBinding = new EveUnityPlayableWorldAssetBinding( + particleProjection.MaterialAssetRef, "", "provider-asset-ref"); + var particleCompute = host.NativeAssetProvider.ResolveAsset( + particleComputeBinding, typeof(ComputeShader)) as ComputeShader; + var particleMaterial = host.NativeAssetProvider.ResolveAsset( + particleMaterialBinding, typeof(Material)) as Material; + Assert.That(particleCompute, Is.Not.Null, + $"Provider asset '{particleProjection.ComputeProgramAssetRef}' did not lower as a ComputeShader."); + Assert.That(particleMaterial, Is.Not.Null, + $"Provider asset '{particleProjection.MaterialAssetRef}' did not lower as a Material."); + var particleMetadata = host.NativeAssetProvider as IEveUnityNativeAssetMetadataProvider; + Assert.That(particleMetadata, Is.Not.Null); + Assert.That(particleMetadata.TryResolveAssetMetadata( + particleComputeBinding, out var particleComputeMetadata), Is.True); + Assert.That(particleMetadata.TryResolveAssetMetadata( + particleMaterialBinding, out var particleMaterialMetadata), Is.True); + Assert.That(EveUnityFieldsParticleRenderer.TryValidateProgramMetadata( + particleComputeMetadata, particleMaterialMetadata, out var particleAbiError), Is.True, + particleAbiError); + Assert.That(fieldParticles.ProgramReady, Is.True, + "The generic field-particle lowerer rejected the advertised provider ABI or assets."); + var liveParticleMaterial = typeof(EveUnityFieldsParticleRenderer) + .GetField("_material", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + ?.GetValue(fieldParticles) as Material; + Assert.That(liveParticleMaterial, Is.Not.Null); + var ditherTextureProperty = particleMaterialMetadata["unity.particles.texturePort.dither"]; + var ditherCoordinatesProperty = particleMaterialMetadata["unity.particles.vectorPort.ditherCoordinates"]; + var renderFrameProperty = particleMaterialMetadata["unity.particles.intPort.frameIndex"]; + var liveDitherTexture = liveParticleMaterial.GetTexture(ditherTextureProperty); + Assert.That(liveDitherTexture, Is.Not.Null, + "The generic lowerer did not bind the advertised provider dither texture."); + Assert.That(liveParticleMaterial.GetVector(ditherCoordinatesProperty).x, + Is.EqualTo((float)camera.pixelWidth / liveDitherTexture.width).Within(0.0001f)); + Assert.That(liveParticleMaterial.GetVector(ditherCoordinatesProperty).y, + Is.EqualTo((float)camera.pixelHeight / liveDitherTexture.height).Within(0.0001f)); + Assert.That(liveParticleMaterial.GetInt(renderFrameProperty), Is.GreaterThan(0), + "The generic lowerer did not advance the advertised render-frame port."); + Assert.That(fieldParticles.PresentedFrameId, Is.GreaterThanOrEqualTo(0)); + Assert.That(fieldParticles.ParticleCount, Is.EqualTo(65536)); + Assert.That(fieldParticles.DispatchCount, Is.GreaterThan(0)); + Assert.That(fieldParticles.DrawCount, Is.GreaterThan(0)); + Assert.That(fieldVolume.LastGridCenter, Is.EqualTo(fieldParticles.LastGridCenter), + "Fog and stateless particles were lowered against different world-grid frames."); + var pilotCameraData = camera.GetUniversalAdditionalCameraData(); + Assert.That(pilotCameraData.antialiasing, Is.EqualTo(AntialiasingMode.TemporalAntiAliasing), + "The advertised temporal reconstruction contract did not reach the pilot camera."); + Assert.That(pilotCameraData.taaSettings.baseBlendFactor, Is.EqualTo(0.99f).Within(0.0001f)); + Assert.That(pilotCameraData.taaSettings.jitterScale, Is.EqualTo(0.1f).Within(0.0001f)); + var fieldDocument = typeof(EveUnityFieldsVolumeRenderer) + .GetField("_document", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + ?.GetValue(fieldVolume); + Assert.That(fieldDocument, Is.Not.Null, "The volume lowerer did not retain its presented Fields document."); + var fieldViewport = fieldDocument.GetType().GetProperty("Viewport")?.GetValue(fieldDocument); + var fieldSplats = fieldDocument.GetType().GetProperty("Splats")?.GetValue(fieldDocument); + Assert.That(fieldViewport, Is.Not.Null); + Assert.That(fieldSplats, Is.Not.Null); WriteWitnessFacts( + witnessProfile, + gameplayScenario, + provider.CurrentAssetBodyTransportKind?.ToString() ?? "", initialVersion, runtime.ActiveVersion, movementDistance, + playerRenderers.Length, + pilotChangedPixels, + mapRenderers.Count, + mapChangedPixels, + (camera.cullingMask & (1 << mapLayer)) == 0, + (mapCamera.cullingMask & (1 << mapLayer)) != 0, + environmentPresentation, + fieldVolume.PresentedFrameId, + fieldVolume.PresentedLayerCount, + fieldVolume.CompositeCount, + fieldParticles.PresentedFrameId, + fieldParticles.ParticleCount, + fieldParticles.DispatchCount, + fieldParticles.DrawCount, + particleMapCameraIsolated, + fieldParticles.LastGridCenter, movementReceipt, + lookReceipt, focusReceipt, + tractorReceipt, + tractorReleaseReceipt, actionReceipt, - tradeReceipt, - combatActionId, - combatShot, - collectedPickupId, - pickupCollected, - cargoQuantityBeforePickup, - cargoQuantityAfterPickup, - docked, - undocked); + tradePurchaseReceipt, + tradeSaleReceipt, + tradeRoundTrip, + tradeItemKey, + observedShot, + shieldRatioBefore, + observedCombat?.ShieldRatio ?? shieldRatioBefore, + hullRatioBefore, + observedCombat?.HullRatio ?? hullRatioBefore, + (float)(observedShot?.LockQuality ?? 0), + aimDotDistance, + playerViewportWidth, + playerViewportHeight, + texturedPlayerMaterialCount, + directionalLightIntensity, + pilotAverageLuminance, + pilotBrightPixelCount, + cockpitProgressCount, + tractorBeamPower, + tractorReleasedPower, + tractorBeamParticleSystemCount, + initialPickupEntityCount, + finalPickupEntityCount, + pickupCollectionEventCount, + pickupRejectionEventCount, + destructionPickupObserved, + pickupCollection, + pickupRejection, + camera.transform.position, + camera.transform.forward, + playerMarker.transform.position, + aimRenderer.ViewDotPosition, + Convert.ToSingle(fieldViewport.GetType().GetProperty("MinX")?.GetValue(fieldViewport)), + Convert.ToSingle(fieldViewport.GetType().GetProperty("MinY")?.GetValue(fieldViewport)), + Convert.ToSingle(fieldViewport.GetType().GetProperty("MaxX")?.GetValue(fieldViewport)), + Convert.ToSingle(fieldViewport.GetType().GetProperty("MaxY")?.GetValue(fieldViewport)), + Convert.ToInt32(fieldSplats.GetType().GetProperty("Count")?.GetValue(fieldSplats)), + playerRendererFacts, + root.GetComponentsInChildren(includeInactive: true) + .Select(marker => PresentedEntityFact.From(camera, marker)) + .ToArray()); } finally { RenderTexture.active = null; var camera = cameraObject.GetComponent(); if (camera != null) camera.targetTexture = null; + var mapCamera = mapCameraObject.GetComponent(); + if (mapCamera != null) mapCamera.targetTexture = null; if (target != null) UnityEngine.Object.DestroyImmediate(target); if (pixels != null) UnityEngine.Object.DestroyImmediate(pixels); - runtime?.Dispose(); - UnityEngine.Object.DestroyImmediate(lightObject); + host?.Disconnect(); + UnityEngine.Object.DestroyImmediate(mapCameraObject); UnityEngine.Object.DestroyImmediate(cameraObject); UnityEngine.Object.DestroyImmediate(root); } } + private static async Task ReceiveAdvertisedRealtimeFrameAsync( + string rendezvousEndpoint, + string verseId, + string authorityRuntimeId) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using var client = new CultMeshClient(new CultMeshClientOptions + { + RendezvousEndpoints = new[] { rendezvousEndpoint }, + RealtimeConnectors = new ICultMeshRealtimeTransportConnector[] + { + new CultMeshNativeQuicRealtimeTransportConnector() + } + }); + using var session = await client.ConnectRealtimeAsync( + new CultMeshSessionTarget(verseId, authorityRuntimeId), + timeout.Token); + return await session.ReceiveAsync(timeout.Token); + } + private static void WriteWitnessFacts( + string witnessProfile, + string gameplayScenario, + string assetBodyTransport, long initialVersion, long finalVersion, float movementDistance, + int playerRendererCount, + int pilotChangedPixels, + int mapChannelRendererCount, + int mapChangedPixels, + bool pilotCameraExcludesMapChannel, + bool mapCameraIncludesMapChannel, + bool environmentPresentation, + long fieldVolumeFrameId, + int fieldVolumeLayerCount, + long fieldVolumeCompositeCount, + long fieldParticleFrameId, + int fieldParticleCount, + int fieldParticleDispatchCount, + int fieldParticleDrawCount, + bool fieldParticleMapCameraIsolated, + Vector2 fieldParticleGridCenter, WitnessReceipt movement, + WitnessReceipt look, WitnessReceipt targeting, + WitnessReceipt tractor, + WitnessReceipt tractorRelease, WitnessReceipt action, - WitnessReceipt trade, - string combatActionId, - EveUnityShotReceipt combatShot, - string collectedPickupId, - bool pickupCollected, - int cargoQuantityBeforePickup, - int cargoQuantityAfterPickup, - bool docked, - bool undocked) + WitnessReceipt tradePurchase, + WitnessReceipt tradeSale, + bool tradeRoundTrip, + string tradeItemKey, + EveUnityShotReceipt shot, + float shieldRatioBefore, + float shieldRatioAfter, + float hullRatioBefore, + float hullRatioAfter, + float lockProgress, + float aimDotDistance, + float playerViewportWidth, + float playerViewportHeight, + int texturedPlayerMaterialCount, + float directionalLightIntensity, + float pilotAverageLuminance, + int pilotBrightPixelCount, + int cockpitProgressCount, + float tractorBeamPower, + float tractorReleasedPower, + int tractorBeamParticleSystemCount, + int initialPickupEntityCount, + int finalPickupEntityCount, + int pickupCollectionEventCount, + int pickupRejectionEventCount, + bool destructionPickupObserved, + EveUnityFeedbackEvent pickupCollection, + EveUnityFeedbackEvent pickupRejection, + Vector3 cameraPosition, + Vector3 cameraForward, + Vector3 playerPosition, + Vector3 aimPosition, + float fieldViewportMinX, + float fieldViewportMinY, + float fieldViewportMaxX, + float fieldViewportMaxY, + int fieldSplatCount, + RendererFact[] playerRendererFacts, + PresentedEntityFact[] presentedEntityFacts) { var path = Environment.GetEnvironmentVariable("EVEUNITY_WITNESS_FACTS_PATH"); if (string.IsNullOrWhiteSpace(path)) return; + var pickupEvent = pickupCollection ?? pickupRejection; var document = new WitnessFacts { + witnessProfile = witnessProfile, + gameplayScenario = gameplayScenario, providerAdvertisement = true, providerAssets = true, + assetBodyTransport = assetBodyTransport, + environmentPresentation = environmentPresentation, + ambientProbeEnergy = AmbientProbeEnergy(), + ambientMode = RenderSettings.ambientMode.ToString(), + ambientLight = RenderSettings.ambientLight, + reflectionTexture = RenderSettings.customReflectionTexture != null + ? RenderSettings.customReflectionTexture.name + : "", + fieldVolumeFrameId = fieldVolumeFrameId, + fieldVolumeLayerCount = fieldVolumeLayerCount, + fieldVolumeCompositeCount = fieldVolumeCompositeCount, + fieldParticleFrameId = fieldParticleFrameId, + fieldParticleCount = fieldParticleCount, + fieldParticleDispatchCount = fieldParticleDispatchCount, + fieldParticleDrawCount = fieldParticleDrawCount, + fieldParticleMapCameraIsolated = fieldParticleMapCameraIsolated, + fieldParticleGridCenter = fieldParticleGridCenter, movement = movement != null, + aimPresentation = look != null && aimDotDistance >= 49.9f, targeting = targeting != null, + beamPresentation = tractor != null && tractorBeamPower > 0.01f && tractorBeamParticleSystemCount > 0, + beamRelease = tractorRelease != null, action = action != null, - trade = trade != null, - combatShot = combatShot != null, - combatActionId = combatActionId ?? "", - shotId = combatShot?.ShotId ?? "", - shotOutcome = combatShot?.Outcome ?? "", - shotPresentationKind = combatShot?.PresentationKind ?? "", - shotHit = combatShot?.Hit ?? false, - pickupVisible = !string.IsNullOrWhiteSpace(collectedPickupId), - pickupCollected = pickupCollected, - cargoQuantityBeforePickup = cargoQuantityBeforePickup, - cargoQuantityAfterPickup = cargoQuantityAfterPickup, - docked = docked, - undocked = undocked, - pickupId = collectedPickupId ?? "", + tradeRoundTrip = tradeRoundTrip && tradePurchase != null && tradeSale != null, + tradeItemKey = tradeItemKey ?? "", + combatPresentation = shot != null, + shotId = shot?.ShotId ?? "", + shotHit = shot?.Hit ?? false, + impactKind = shot?.ImpactKind ?? "", + appliedDamage = (float)(shot?.AppliedDamage ?? 0), + shieldAbsorbedDamage = (float)(shot?.ShieldAbsorbedDamage ?? 0), + shieldRatioBefore = shieldRatioBefore, + shieldRatioAfter = shieldRatioAfter, + hullRatioBefore = hullRatioBefore, + hullRatioAfter = hullRatioAfter, + lockProgress = lockProgress, initialVersion = initialVersion, finalVersion = finalVersion, movementDistance = movementDistance, - receipts = new[] { movement, targeting, action, trade } + aimDotDistance = aimDotDistance, + playerRendererCount = playerRendererCount, + playerViewportWidth = playerViewportWidth, + playerViewportHeight = playerViewportHeight, + texturedPlayerMaterialCount = texturedPlayerMaterialCount, + directionalLightIntensity = directionalLightIntensity, + pilotChangedPixels = pilotChangedPixels, + pilotAverageLuminance = pilotAverageLuminance, + pilotBrightPixelCount = pilotBrightPixelCount, + cockpitProgressCount = cockpitProgressCount, + tractorBeamPower = tractorBeamPower, + tractorReleasedPower = tractorReleasedPower, + tractorBeamParticleSystemCount = tractorBeamParticleSystemCount, + pickupCollection = pickupCollection != null && pickupCollectionEventCount == 1 && + pickupCollection.CargoQuantityAfter - pickupCollection.CargoQuantityBefore == pickupCollection.ScalarValue, + pickupRejection = pickupRejection != null && pickupRejectionEventCount >= 1 && + pickupCollectionEventCount == 0 && + string.Equals(pickupRejection.Reason, "cargo-capacity", StringComparison.Ordinal) && + pickupRejection.CargoQuantityAfter == pickupRejection.CargoQuantityBefore, + destructionLoot = pickupEvent != null && !string.IsNullOrWhiteSpace(pickupEvent.EventId) && + !string.IsNullOrWhiteSpace(pickupEvent.ItemKey) && pickupEvent.ScalarValue > 0, + initialPickupEntityCount = initialPickupEntityCount, + finalPickupEntityCount = finalPickupEntityCount, + pickupCollectionEventCount = pickupCollectionEventCount, + pickupRejectionEventCount = pickupRejectionEventCount, + pickupRejectionEventId = pickupRejection?.EventId ?? "", + pickupRejectionReason = pickupRejection?.Reason ?? "", + cargoQuantityBeforeRejection = (float)(pickupRejection?.CargoQuantityBefore ?? 0), + cargoQuantityAfterRejection = (float)(pickupRejection?.CargoQuantityAfter ?? 0), + pickupEventId = pickupEvent?.EventId ?? "", + pickupItemKey = pickupEvent?.ItemKey ?? "", + pickupQuantity = (float)(pickupEvent?.ScalarValue ?? 0), + cargoQuantityBeforePickup = (float)(pickupEvent?.CargoQuantityBefore ?? 0), + cargoQuantityAfterPickup = (float)(pickupEvent?.CargoQuantityAfter ?? 0), + cameraPosition = cameraPosition, + cameraForward = cameraForward, + playerPosition = playerPosition, + aimPosition = aimPosition, + fieldViewportMinX = fieldViewportMinX, + fieldViewportMinY = fieldViewportMinY, + fieldViewportMaxX = fieldViewportMaxX, + fieldViewportMaxY = fieldViewportMaxY, + fieldSplatCount = fieldSplatCount, + playerRenderers = playerRendererFacts, + presentedEntities = presentedEntityFacts, + mapChannelRendererCount = mapChannelRendererCount, + mapChangedPixels = mapChangedPixels, + pilotCameraExcludesMapChannel = pilotCameraExcludesMapChannel, + mapCameraIncludesMapChannel = mapCameraIncludesMapChannel, + receipts = new[] { movement, look, targeting, tractor, tractorRelease, action, tradePurchase, tradeSale } }; Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))); File.WriteAllText(path, JsonUtility.ToJson(document, true)); @@ -759,52 +1394,102 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests [Serializable] private sealed class WitnessFacts { + public string witnessProfile; + public string gameplayScenario; public bool providerAdvertisement; public bool providerAssets; + public string assetBodyTransport; + public bool environmentPresentation; + public float ambientProbeEnergy; + public string ambientMode; + public Color ambientLight; + public string reflectionTexture; + public long fieldVolumeFrameId; + public int fieldVolumeLayerCount; + public long fieldVolumeCompositeCount; + public long fieldParticleFrameId; + public int fieldParticleCount; + public int fieldParticleDispatchCount; + public int fieldParticleDrawCount; + public bool fieldParticleMapCameraIsolated; + public Vector2 fieldParticleGridCenter; public bool movement; + public bool aimPresentation; public bool targeting; + public bool beamPresentation; + public bool beamRelease; public bool action; - public bool trade; - public bool combatShot; - public string combatActionId; + public bool tradeRoundTrip; + public string tradeItemKey; + public bool combatPresentation; public string shotId; - public string shotOutcome; - public string shotPresentationKind; public bool shotHit; - public bool pickupVisible; - public bool pickupCollected; - public int cargoQuantityBeforePickup; - public int cargoQuantityAfterPickup; - public bool docked; - public bool undocked; - public string pickupId; + public string impactKind; + public float appliedDamage; + public float shieldAbsorbedDamage; + public float shieldRatioBefore; + public float shieldRatioAfter; + public float hullRatioBefore; + public float hullRatioAfter; + public float lockProgress; public long initialVersion; public long finalVersion; public float movementDistance; + public float aimDotDistance; + public int playerRendererCount; + public float playerViewportWidth; + public float playerViewportHeight; + public int texturedPlayerMaterialCount; + public float directionalLightIntensity; + public int pilotChangedPixels; + public float pilotAverageLuminance; + public int pilotBrightPixelCount; + public int cockpitProgressCount; + public float tractorBeamPower; + public float tractorReleasedPower; + public int tractorBeamParticleSystemCount; + public bool pickupCollection; + public bool pickupRejection; + public bool destructionLoot; + public int initialPickupEntityCount; + public int finalPickupEntityCount; + public int pickupCollectionEventCount; + public int pickupRejectionEventCount; + public string pickupRejectionEventId; + public string pickupRejectionReason; + public float cargoQuantityBeforeRejection; + public float cargoQuantityAfterRejection; + public string pickupEventId; + public string pickupItemKey; + public float pickupQuantity; + public float cargoQuantityBeforePickup; + public float cargoQuantityAfterPickup; + public Vector3 cameraPosition; + public Vector3 cameraForward; + public Vector3 playerPosition; + public Vector3 aimPosition; + public float fieldViewportMinX; + public float fieldViewportMinY; + public float fieldViewportMaxX; + public float fieldViewportMaxY; + public int fieldSplatCount; + public RendererFact[] playerRenderers; + public PresentedEntityFact[] presentedEntities; + public int mapChannelRendererCount; + public int mapChangedPixels; + public bool pilotCameraExcludesMapChannel; + public bool mapCameraIncludesMapChannel; public WitnessReceipt[] receipts; } - private static int ReadInt32EntitySemantic( - EveEntitySoaViewDocument document, - string entityId, - string semantic) + private static float AmbientProbeEnergy() { - Assert.That(document, Is.Not.Null, "The provider did not publish an Eve entity SoA view."); - var entityIndex = document.Identities - .Single(identity => string.Equals(identity.EntityId, entityId, StringComparison.Ordinal)) - .EntityIndex; - using var view = EveUnityEntitySoaView.Open(document); - for (var row = 0; row < view.EntityCount; row++) - { - if (!view.TryReadInt32("entity.index", row, out var rowEntityIndex) || rowEntityIndex != entityIndex) - continue; - Assert.That(view.TryReadInt32(semantic, row, out var value), Is.True, - $"The Eve entity SoA view did not expose int32 semantic '{semantic}'."); - return value; - } - - Assert.Fail($"The Eve entity SoA view did not contain entity '{entityId}' ({entityIndex})."); - return 0; + var probe = RenderSettings.ambientProbe; + var energy = 0f; + for (var channel = 0; channel < 3; channel++) + for (var coefficient = 0; coefficient < 9; coefficient++) + energy += Mathf.Abs(probe[channel, coefficient]); + return energy; } [Serializable] @@ -813,6 +1498,12 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests public string commandKind; public string commandId; public string state; + public string receiptId; + public string ownerRepo; + public string authority; + public string providerId; + public string surfaceId; + public bool isProviderOwned; public long sourceVersion; public static WitnessReceipt From(string commandKind, EveUnitySceneCommandReceipt receipt) @@ -822,17 +1513,140 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests commandKind = commandKind, commandId = receipt.CommandId, state = receipt.State, + receiptId = receipt.ReceiptId, + ownerRepo = receipt.OwnerRepo, + authority = receipt.Authority, + providerId = receipt.ProviderId, + surfaceId = receipt.SurfaceId, + isProviderOwned = receipt.IsProviderOwned, sourceVersion = receipt.SourceVersion }; } } - private static EveUnityPlayableWorldEntityMarker FindMarker( - GameObject root, - string entityId, - bool includeInactive = false) + [Serializable] + private sealed class RendererFact { - foreach (var marker in root.GetComponentsInChildren(includeInactive)) + public string name; + public string rendererType; + public bool enabled; + public bool activeInHierarchy; + public int layer; + public string boundsSize; + public float viewportWidth; + public float viewportHeight; + public int renderedPixelCount; + public float renderedAverageLuminance; + public int renderedBrightPixelCount; + public MaterialFact[] materials; + + public static RendererFact From(Camera camera, Renderer renderer) + { + MeasureViewportCoverage(camera, new[] { renderer }, out var width, out var height); + return new RendererFact + { + name = renderer.name, + rendererType = renderer.GetType().Name, + enabled = renderer.enabled, + activeInHierarchy = renderer.gameObject.activeInHierarchy, + layer = renderer.gameObject.layer, + boundsSize = FormatVector(renderer.bounds.size), + viewportWidth = width, + viewportHeight = height, + materials = renderer.sharedMaterials + .Where(material => material != null) + .Select(MaterialFact.From) + .ToArray() + }; + } + } + + [Serializable] + private sealed class PresentedEntityFact + { + public string entityId; + public string entityKind; + public string label; + public string assetRef; + public string worldPosition; + public string worldScale; + public int rendererCount; + public int enabledRendererCount; + public bool intersectsPilotFrustum; + public float viewportWidth; + public float viewportHeight; + + public static PresentedEntityFact From(Camera camera, EveUnityPlayableWorldEntityMarker marker) + { + var renderers = marker.GetComponentsInChildren(includeInactive: true); + MeasureViewportCoverage(camera, renderers, out var width, out var height); + var planes = GeometryUtility.CalculateFrustumPlanes(camera); + return new PresentedEntityFact + { + entityId = marker.EntityId, + entityKind = marker.EntityKind, + label = marker.Label, + assetRef = marker.AssetRef, + worldPosition = FormatVector(marker.transform.position), + worldScale = FormatVector(marker.transform.lossyScale), + rendererCount = renderers.Length, + enabledRendererCount = renderers.Count(renderer => renderer.enabled && renderer.gameObject.activeInHierarchy), + intersectsPilotFrustum = renderers.Any(renderer => + renderer.enabled && renderer.gameObject.activeInHierarchy && + GeometryUtility.TestPlanesAABB(planes, renderer.bounds)), + viewportWidth = width, + viewportHeight = height + }; + } + } + + [Serializable] + private sealed class MaterialFact + { + public string name; + public string shader; + public bool shaderSupported; + public string baseColor; + public float surface; + public int textureCount; + public int renderQueue; + + public static MaterialFact From(Material material) => new MaterialFact + { + name = material.name, + shader = material.shader?.name ?? "", + shaderSupported = material.shader != null && material.shader.isSupported, + baseColor = material.HasProperty("_BaseColor") + ? FormatColor(material.GetColor("_BaseColor")) + : "", + surface = material.HasProperty("_Surface") ? material.GetFloat("_Surface") : -1f, + textureCount = material.GetTexturePropertyNames().Count(name => material.GetTexture(name) != null), + renderQueue = material.renderQueue + }; + } + + private static void AssertReconciledProviderReceipt( + EveUnitySceneCommandReceipt receipt, + string commandId, + string providerId, + string surfaceId, + long minimumSourceVersion) + { + Assert.That(receipt, Is.Not.Null); + Assert.That(receipt.CommandId, Is.EqualTo(commandId)); + Assert.That(receipt.State, Is.EqualTo("reconciled"), receipt.Message); + Assert.That(receipt.IsProviderOwned, Is.True); + Assert.That(receipt.ProviderId, Is.EqualTo(providerId)); + Assert.That(receipt.SurfaceId, Is.EqualTo(surfaceId)); + Assert.That(receipt.ReceiptId, Is.Not.Empty); + Assert.That(receipt.OwnerRepo, Is.Not.Empty); + Assert.That(receipt.Authority, Is.Not.Empty); + Assert.That(receipt.SourceVersion, Is.GreaterThan(minimumSourceVersion)); + } + + private static EveUnityPlayableWorldEntityMarker FindMarker(GameObject root, string entityId) + { + foreach (var marker in root.GetComponentsInChildren()) { if (string.Equals(marker.EntityId, entityId, StringComparison.Ordinal)) return marker; @@ -840,32 +1654,268 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests throw new InvalidOperationException($"Projected world has no entity marker '{entityId}'."); } - private static string DescribeWeaponStates(EveUnityCultMeshPlayableWorldProvider provider) + private static bool HasAnyTexture(Material material) { - return string.Join("; ", Flatten(provider.CurrentDocument.SurfaceDocument.Surface.Root) - .Where(node => string.Equals(node.Kind, "weapon.state", StringComparison.Ordinal)) - .Select(node => string.Join(",", new[] - { - Prop(node, "behaviorKind"), - $"pending={Prop(node, "triggerPending")}", - $"firing={Prop(node, "firing")}", - $"charging={Prop(node, "charging")}", - $"charged={Prop(node, "charged")}", - $"lock={Prop(node, "lockProgress")}", - $"target={Prop(node, "targetEntityId")}", - $"refusal={Prop(node, "lastRefusalReason")}" - }))); + foreach (var propertyName in material.GetTexturePropertyNames()) + { + if (material.GetTexture(propertyName) != null) + return true; + } + return false; } - private static string Prop(EveSurfaceComponent node, string key) => - node.Props.TryGetValue(key, out var value) ? value : ""; + private static string FormatVector(Vector3 value) => + $"{value.x:R},{value.y:R},{value.z:R}"; - private static IEnumerable Flatten(EveSurfaceComponent root) + private static string FormatColor(Color value) => + $"{value.r:R},{value.g:R},{value.b:R},{value.a:R}"; + + private static void MeasureViewportCoverage( + Camera camera, + IReadOnlyList renderers, + out float width, + out float height) { - yield return root; - foreach (var child in root.Children) - foreach (var descendant in Flatten(child)) - yield return descendant; + var min = new Vector2(float.PositiveInfinity, float.PositiveInfinity); + var max = new Vector2(float.NegativeInfinity, float.NegativeInfinity); + foreach (var renderer in renderers) + { + var bounds = renderer.bounds; + for (var x = -1; x <= 1; x += 2) + for (var y = -1; y <= 1; y += 2) + for (var z = -1; z <= 1; z += 2) + { + var point = bounds.center + Vector3.Scale(bounds.extents, new Vector3(x, y, z)); + var viewport = camera.WorldToViewportPoint(point); + if (viewport.z <= 0f) continue; + min = Vector2.Min(min, viewport); + max = Vector2.Max(max, viewport); + } + } + width = float.IsInfinity(min.x) ? 0f : Mathf.Max(0f, max.x - min.x); + height = float.IsInfinity(min.y) ? 0f : Mathf.Max(0f, max.y - min.y); + } + + private static void MeasureLuminance( + Texture2D texture, + out float average, + out int brightPixelCount) + { + double total = 0; + brightPixelCount = 0; + var pixels = texture.GetPixels32(); + foreach (var pixel in pixels) + { + var luminance = (0.2126 * pixel.r + 0.7152 * pixel.g + 0.0722 * pixel.b) / 255.0; + total += luminance; + if (luminance >= 0.2) + brightPixelCount++; + } + average = pixels.Length == 0 ? 0f : (float)(total / pixels.Length); + } + + private static void MeasureRendererOutputs( + Camera camera, + RenderTexture target, + Texture2D pixels, + IReadOnlyList allRenderers, + IReadOnlyList measuredRenderers, + IReadOnlyList facts) + { + var enabled = allRenderers.Select(renderer => renderer.enabled).ToArray(); + var clearFlags = camera.clearFlags; + var background = camera.backgroundColor; + try + { + foreach (var renderer in allRenderers) + renderer.enabled = false; + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = Color.black; + camera.Render(); + RenderTexture.active = target; + pixels.ReadPixels(new Rect(0, 0, target.width, target.height), 0, 0); + pixels.Apply(); + var baseline = pixels.GetPixels(); + for (var index = 0; index < measuredRenderers.Count; index++) + { + var fact = facts[index]; + var rendererLayer = measuredRenderers[index].gameObject.layer; + if (rendererLayer >= 0 && rendererLayer < 32 && + (camera.cullingMask & (1 << rendererLayer)) == 0) + { + fact.renderedPixelCount = 0; + fact.renderedAverageLuminance = 0; + fact.renderedBrightPixelCount = 0; + continue; + } + measuredRenderers[index].enabled = true; + camera.Render(); + RenderTexture.active = target; + pixels.ReadPixels(new Rect(0, 0, target.width, target.height), 0, 0); + pixels.Apply(); + MeasureDifference( + pixels.GetPixels(), + baseline, + out fact.renderedPixelCount, + out fact.renderedAverageLuminance, + out fact.renderedBrightPixelCount); + measuredRenderers[index].enabled = false; + } + } + finally + { + for (var index = 0; index < allRenderers.Count; index++) + allRenderers[index].enabled = enabled[index]; + camera.clearFlags = clearFlags; + camera.backgroundColor = background; + } + } + + private static void MeasureDifference( + IReadOnlyList actual, + IReadOnlyList baseline, + out int changedPixelCount, + out float averageLuminance, + out int brightPixelCount) + { + changedPixelCount = 0; + brightPixelCount = 0; + double total = 0; + var count = Math.Min(actual.Count, baseline.Count); + for (var index = 0; index < count; index++) + { + var delta = new Color( + Mathf.Abs(actual[index].r - baseline[index].r), + Mathf.Abs(actual[index].g - baseline[index].g), + Mathf.Abs(actual[index].b - baseline[index].b)); + var magnitude = delta.r + delta.g + delta.b; + if (magnitude > 0.08f) changedPixelCount++; + var luminance = delta.r * 0.2126f + delta.g * 0.7152f + delta.b * 0.0722f; + total += luminance; + if (luminance >= 0.2f) brightPixelCount++; + } + averageLuminance = count == 0 ? 0f : (float)(total / count); + } + + private static int CountChangedPixels(Texture2D texture, Color background) + { + var changed = 0; + foreach (var pixel in texture.GetPixels()) + { + if (Mathf.Abs(pixel.r - background.r) + + Mathf.Abs(pixel.g - background.g) + + Mathf.Abs(pixel.b - background.b) > 0.08f) + changed++; + } + return changed; + } + + private static void WriteFieldLayerCaptures(GameObject root, EveUnityPlayableWorldProjection world, string capturePath) + { + var renderer = root.GetComponent(); + if (renderer == null || world == null || string.IsNullOrWhiteSpace(capturePath)) return; + var directory = Path.GetDirectoryName(Path.GetFullPath(capturePath)); + foreach (var field in world.FieldVolumes) + { + if (!field.Props.TryGetValue("layerBindings", out var bindings)) continue; + foreach (var pair in (bindings ?? "").Split(';')) + { + var separator = pair.IndexOf('='); + if (separator <= 0) continue; + var layerKey = pair.Substring(0, separator).Trim(); + if (!renderer.TryGetTexture(layerKey, out var texture) || texture == null) continue; + var previous = RenderTexture.active; + RenderTexture.active = texture; + var copy = new Texture2D(texture.width, texture.height, TextureFormat.RGBA32, false, true); + copy.ReadPixels(new Rect(0, 0, texture.width, texture.height), 0, 0); + copy.Apply(); + RenderTexture.active = previous; + File.WriteAllBytes(Path.Combine(directory, $"field-{layerKey}.png"), copy.EncodeToPNG()); + UnityEngine.Object.DestroyImmediate(copy); + + RenderTexture.active = texture; + var floatCopy = new Texture2D(texture.width, texture.height, TextureFormat.RGBAFloat, false, true); + floatCopy.ReadPixels(new Rect(0, 0, texture.width, texture.height), 0, 0); + floatCopy.Apply(); + RenderTexture.active = previous; + File.WriteAllBytes( + Path.Combine(directory, $"field-{layerKey}.exr"), + floatCopy.EncodeToEXR(Texture2D.EXRFlags.OutputAsFloat)); + UnityEngine.Object.DestroyImmediate(floatCopy); + } + } + } + + private static void WriteFieldCloudCapture(GameObject root, string capturePath) + { + var renderer = root.GetComponent(); + if (renderer == null || string.IsNullOrWhiteSpace(capturePath)) return; + var raymarchField = typeof(EveUnityFieldsVolumeRenderer).GetField( + "_raymarchTexture", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var historyTexturesField = typeof(EveUnityFieldsVolumeRenderer).GetField( + "_historyTextures", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var historyIndexField = typeof(EveUnityFieldsVolumeRenderer).GetField( + "_historyTextureIndex", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var materialField = typeof(EveUnityFieldsVolumeRenderer).GetField( + "_material", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var metadataField = typeof(EveUnityFieldsVolumeRenderer).GetField( + "_programMetadata", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var raymarchTexture = raymarchField?.GetValue(renderer) as RenderTexture; + var historyTextures = historyTexturesField?.GetValue(renderer) as RenderTexture[]; + var historyIndex = historyIndexField?.GetValue(renderer) is int value ? value : -1; + var previous = RenderTexture.active; + var directory = Path.GetDirectoryName(Path.GetFullPath(capturePath)); + WriteFloatTexture(raymarchTexture, Path.Combine(directory, "field-volume-raymarch.exr")); + var historyTexture = historyIndex >= 0 && historyTextures != null && historyIndex < historyTextures.Length + ? historyTextures[historyIndex] + : null; + WriteFloatTexture(historyTexture, Path.Combine(directory, "field-volume-history.exr")); + WriteFloatTexture(historyTexture ?? raymarchTexture, Path.Combine(directory, "field-volume-cloud.exr")); + WriteVolumeMaterialFacts( + materialField?.GetValue(renderer) as Material, + metadataField?.GetValue(renderer) as IReadOnlyDictionary, + Path.Combine(directory, "field-volume-material.txt")); + RenderTexture.active = previous; + } + + private static void WriteVolumeMaterialFacts( + Material material, + IReadOnlyDictionary metadata, + string path) + { + if (material == null || metadata == null) return; + var facts = new List(); + foreach (var binding in metadata.OrderBy(value => value.Key, StringComparer.Ordinal)) + { + if (binding.Key.Contains(".floatPort.", StringComparison.Ordinal)) + facts.Add($"{binding.Key}={binding.Value}:{material.GetFloat(binding.Value):R}"); + else if (binding.Key.Contains(".vectorPort.", StringComparison.Ordinal)) + facts.Add($"{binding.Key}={binding.Value}:{material.GetVector(binding.Value)}"); + else if (binding.Key.Contains(".texturePort.", StringComparison.Ordinal)) + { + var texture = material.HasProperty(binding.Value) ? material.GetTexture(binding.Value) : null; + facts.Add($"{binding.Key}={binding.Value}:{texture?.name ?? ""}:{texture?.width ?? 0}x{texture?.height ?? 0}"); + } + } + File.WriteAllLines(path, facts); + } + + private static void WriteFloatTexture(RenderTexture texture, string path) + { + if (texture == null || string.IsNullOrWhiteSpace(path)) return; + var previous = RenderTexture.active; + RenderTexture.active = texture; + var copy = new Texture2D(texture.width, texture.height, TextureFormat.RGBAFloat, false, true); + copy.ReadPixels(new Rect(0, 0, texture.width, texture.height), 0, 0); + copy.Apply(); + RenderTexture.active = previous; + File.WriteAllBytes(path, copy.EncodeToEXR(Texture2D.EXRFlags.OutputAsFloat)); + UnityEngine.Object.DestroyImmediate(copy); } private static EveUnitySceneProviderSurfaceDocument WorldDocument() diff --git a/ReleaseConsumerProject/Assets/UniversalRenderPipelineGlobalSettings.asset b/ReleaseConsumerProject/Assets/UniversalRenderPipelineGlobalSettings.asset new file mode 100644 index 0000000..0c4c2e0 --- /dev/null +++ b/ReleaseConsumerProject/Assets/UniversalRenderPipelineGlobalSettings.asset @@ -0,0 +1,436 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 2ec995e51a6e251468d2a3fd8a686257, type: 3} + m_Name: UniversalRenderPipelineGlobalSettings + m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.UniversalRenderPipelineGlobalSettings + m_ShaderStrippingSetting: + m_Version: 0 + m_ExportShaderVariants: 1 + m_ShaderVariantLogLevel: 0 + m_StripRuntimeDebugShaders: 1 + m_URPShaderStrippingSetting: + m_Version: 0 + m_StripUnusedPostProcessingVariants: 0 + m_StripUnusedVariants: 1 + m_StripScreenCoordOverrideVariants: 1 + m_ShaderVariantLogLevel: 0 + m_ExportShaderVariants: 1 + m_StripDebugVariants: 1 + m_StripUnusedPostProcessingVariants: 0 + m_StripUnusedVariants: 1 + m_StripScreenCoordOverrideVariants: 1 + supportRuntimeDebugDisplay: 0 + m_Settings: + m_SettingsList: + m_List: + - rid: 1680211313492492288 + - rid: 1680211313492492289 + - rid: 1680211313492492290 + - rid: 1680211313492492291 + - rid: 1680211313492492292 + - rid: 1680211313492492293 + - rid: 1680211313492492294 + - rid: 1680211313492492295 + - rid: 1680211313492492296 + - rid: 1680211313492492297 + - rid: 1680211313492492298 + - rid: 1680211313492492299 + - rid: 1680211313492492300 + - rid: 1680211313492492301 + - rid: 1680211313492492302 + - rid: 1680211313492492303 + - rid: 1680211313492492304 + - rid: 1680211313492492305 + - rid: 1680211313492492306 + - rid: 1680211313492492307 + - rid: 1680211313492492308 + - rid: 1680211313492492309 + - rid: 1680211313492492310 + - rid: 1680211313492492311 + - rid: 1680211313492492312 + - rid: 1680211313492492313 + - rid: 1680211313492492314 + - rid: 1680211313492492315 + - rid: 1680211313492492316 + - rid: 1680211313492492317 + - rid: 1680211313492492318 + - rid: 1680211313492492319 + - rid: 1680211313492492320 + - rid: 1680211313492492321 + - rid: 1680211313492492322 + m_RuntimeSettings: + m_List: [] + m_AssetVersion: 10 + m_ObsoleteDefaultVolumeProfile: {fileID: 0} + m_RenderingLayerNames: + - Default + m_ValidRenderingLayers: 0 + lightLayerName0: + lightLayerName1: + lightLayerName2: + lightLayerName3: + lightLayerName4: + lightLayerName5: + lightLayerName6: + lightLayerName7: + apvScenesData: + obsoleteSceneBounds: + m_Keys: [] + m_Values: [] + obsoleteHasProbeVolumes: + m_Keys: [] + m_Values: + references: + version: 2 + RefIds: + - rid: 1680211313492492288 + type: {class: RayTracingRenderPipelineResources, ns: UnityEngine.Rendering.UnifiedRayTracing, asm: Unity.UnifiedRayTracing.Runtime} + data: + m_Version: 1 + m_GeometryPoolKernels: {fileID: 7200000, guid: 98e3d58cae7210c4786f67f504c9e899, type: 3} + m_CopyBuffer: {fileID: 7200000, guid: 1b95b5dcf48d1914c9e1e7405c7660e3, type: 3} + m_CopyPositions: {fileID: 7200000, guid: 1ad53a96b58d3c3488dde4f14db1aaeb, type: 3} + m_BitHistogram: {fileID: 7200000, guid: 8670f7ce4b60cef43bed36148aa1b0a2, type: 3} + m_BlockReducePart: {fileID: 7200000, guid: 4e034cc8ea2635c4e9f063e5ddc7ea7a, type: 3} + m_BlockScan: {fileID: 7200000, guid: 4d6d5de35fa45ef4a92119397a045cc9, type: 3} + m_BuildHlbvh: {fileID: 7200000, guid: 2d70cd6be91bd7843a39a54b51c15b13, type: 3} + m_RestructureBvh: {fileID: 7200000, guid: 56641cb88dcb31a4398a4997ef7a7a8c, type: 3} + m_Scatter: {fileID: 7200000, guid: a2eaeefdac4637a44b734e85b7be9186, type: 3} + - rid: 1680211313492492289 + type: {class: UniversalRenderPipelineRuntimeShaders, ns: UnityEngine.Rendering.Universal, asm: Unity.RenderPipelines.Universal.Runtime} + data: + m_Version: 0 + m_FallbackErrorShader: {fileID: 4800000, guid: e6e9a19c3678ded42a3bc431ebef7dbd, type: 3} + m_BlitHDROverlay: {fileID: 4800000, guid: a89bee29cffa951418fc1e2da94d1959, type: 3} + m_CoreBlitPS: {fileID: 4800000, guid: 93446b5c5339d4f00b85c159e1159b7c, type: 3} + m_CoreBlitColorAndDepthPS: {fileID: 4800000, guid: d104b2fc1ca6445babb8e90b0758136b, type: 3} + m_SamplingPS: {fileID: 4800000, guid: 04c410c9937594faa893a11dceb85f7e, type: 3} + m_TerrainDetailLit: {fileID: 0} + m_TerrainDetailGrassBillboard: {fileID: 0} + m_TerrainDetailGrass: {fileID: 0} + - rid: 1680211313492492290 + type: {class: URPTerrainShaderSetting, ns: UnityEngine.Rendering.Universal, asm: Unity.RenderPipelines.Universal.Runtime} + data: + m_Version: 0 + m_IncludeTerrainShaders: 1 + - rid: 1680211313492492291 + type: {class: UniversalRenderPipelineRuntimeTerrainShaders, ns: UnityEngine.Rendering.Universal, asm: Unity.RenderPipelines.Universal.Runtime} + data: + m_Version: 0 + m_TerrainDetailLit: {fileID: 4800000, guid: f6783ab646d374f94b199774402a5144, type: 3} + m_TerrainDetailGrassBillboard: {fileID: 4800000, guid: 29868e73b638e48ca99a19ea58c48d90, type: 3} + m_TerrainDetailGrass: {fileID: 4800000, guid: e507fdfead5ca47e8b9a768b51c291a1, type: 3} + - rid: 1680211313492492292 + type: {class: UniversalRendererResources, ns: UnityEngine.Rendering.Universal, asm: Unity.RenderPipelines.Universal.Runtime} + data: + m_Version: 0 + m_CopyDepthPS: {fileID: 4800000, guid: d6dae50ee9e1bfa4db75f19f99355220, type: 3} + m_CameraMotionVector: {fileID: 4800000, guid: c56b7e0d4c7cb484e959caeeedae9bbf, type: 3} + m_StencilDeferredPS: {fileID: 4800000, guid: e9155b26e1bc55942a41e518703fe304, type: 3} + m_ClusterDeferred: {fileID: 4800000, guid: 222cce62363a44a380c36bf03b392608, type: 3} + m_StencilDitherMaskSeedPS: {fileID: 4800000, guid: 8c3ee818f2efa514c889881ccb2e95a2, type: 3} + m_DBufferClear: {fileID: 4800000, guid: f056d8bd2a1c7e44e9729144b4c70395, type: 3} + - rid: 1680211313492492293 + type: {class: UniversalRenderPipelineDebugShaders, ns: UnityEngine.Rendering.Universal, asm: Unity.RenderPipelines.Universal.Runtime} + data: + m_DebugReplacementPS: {fileID: 4800000, guid: cf852408f2e174538bcd9b7fda1c5ae7, type: 3} + m_HdrDebugViewPS: {fileID: 4800000, guid: 573620ae32aec764abd4d728906d2587, type: 3} + m_ProbeVolumeSamplingDebugComputeShader: {fileID: 7200000, guid: 53626a513ea68ce47b59dc1299fe3959, type: 3} + - rid: 1680211313492492294 + type: {class: PostProcessData/ShaderResources, ns: UnityEngine.Rendering.Universal, asm: Unity.RenderPipelines.Universal.Runtime} + data: + stopNanPS: {fileID: 4800000, guid: 1121bb4e615ca3c48b214e79e841e823, type: 3} + subpixelMorphologicalAntialiasingPS: {fileID: 4800000, guid: 63eaba0ebfb82cc43bde059b4a8c65f6, type: 3} + gaussianDepthOfFieldPS: {fileID: 4800000, guid: 5e7134d6e63e0bc47a1dd2669cedb379, type: 3} + bokehDepthOfFieldPS: {fileID: 4800000, guid: 2aed67ad60045d54ba3a00c91e2d2631, type: 3} + cameraMotionBlurPS: {fileID: 4800000, guid: 1edcd131364091c46a17cbff0b1de97a, type: 3} + paniniProjectionPS: {fileID: 4800000, guid: a15b78cf8ca26ca4fb2090293153c62c, type: 3} + lutBuilderLdrPS: {fileID: 4800000, guid: 65df88701913c224d95fc554db28381a, type: 3} + lutBuilderHdrPS: {fileID: 4800000, guid: ec9fec698a3456d4fb18cf8bacb7a2bc, type: 3} + bloomPS: {fileID: 4800000, guid: 5f1864addb451f54bae8c86d230f736e, type: 3} + temporalAntialiasingPS: {fileID: 4800000, guid: 9c70c1a35ff15f340b38ea84842358bf, type: 3} + LensFlareDataDrivenPS: {fileID: 4800000, guid: 6cda457ac28612740adb23da5d39ea92, type: 3} + LensFlareScreenSpacePS: {fileID: 4800000, guid: 701880fecb344ea4c9cd0db3407ab287, type: 3} + scalingSetupPS: {fileID: 4800000, guid: e8ee25143a34b8c4388709ea947055d1, type: 3} + easuPS: {fileID: 4800000, guid: 562b7ae4f629f144aa97780546fce7c6, type: 3} + uberPostPS: {fileID: 4800000, guid: e7857e9d0c934dc4f83f270f8447b006, type: 3} + finalPostPassPS: {fileID: 4800000, guid: c49e63ed1bbcb334780a3bd19dfed403, type: 3} + m_ShaderResourcesVersion: 0 + - rid: 1680211313492492295 + type: {class: UniversalRenderPipelineEditorShaders, ns: UnityEngine.Rendering.Universal, asm: Unity.RenderPipelines.Universal.Runtime} + data: + m_AutodeskInteractive: {fileID: 4800000, guid: 0e9d5a909a1f7e84882a534d0d11e49f, type: 3} + m_AutodeskInteractiveTransparent: {fileID: 4800000, guid: 5c81372d981403744adbdda4433c9c11, type: 3} + m_AutodeskInteractiveMasked: {fileID: 4800000, guid: 80aa867ac363ac043847b06ad71604cd, type: 3} + m_DefaultSpeedTree7Shader: {fileID: 4800000, guid: 0f4122b9a743b744abe2fb6a0a88868b, type: 3} + m_DefaultSpeedTree8Shader: {fileID: -6465566751694194690, guid: 9920c1f1781549a46ba081a2a15a16ec, type: 3} + m_DefaultSpeedTree9Shader: {fileID: -6465566751694194690, guid: cbd3e1cc4ae141c42a30e33b4d666a61, type: 3} + - rid: 1680211313492492296 + type: {class: RenderGraphSettings, ns: UnityEngine.Rendering.Universal, asm: Unity.RenderPipelines.Universal.Runtime} + data: + m_Version: 0 + - rid: 1680211313492492297 + type: {class: ScreenSpaceAmbientOcclusionDynamicResources, ns: UnityEngine.Rendering.Universal, asm: Unity.RenderPipelines.Universal.Runtime} + data: + m_BlueNoise256Textures: + - {fileID: 2800000, guid: 36f118343fc974119bee3d09e2111500, type: 3} + - {fileID: 2800000, guid: 4b7b083e6b6734e8bb2838b0b50a0bc8, type: 3} + - {fileID: 2800000, guid: c06cc21c692f94f5fb5206247191eeee, type: 3} + - {fileID: 2800000, guid: cb76dd40fa7654f9587f6a344f125c9a, type: 3} + - {fileID: 2800000, guid: e32226222ff144b24bf3a5a451de54bc, type: 3} + - {fileID: 2800000, guid: 3302065f671a8450b82c9ddf07426f3a, type: 3} + - {fileID: 2800000, guid: 56a77a3e8d64f47b6afe9e3c95cb57d5, type: 3} + m_Version: 0 + - rid: 1680211313492492298 + type: {class: OnTilePostProcessResource, ns: UnityEngine.Rendering.Universal, asm: Unity.RenderPipelines.Universal.Runtime} + data: + m_Version: 0 + m_UberPostShader: {fileID: 4800000, guid: fe4f13c1004a07d4ea1e30bfd0326d9e, type: 3} + - rid: 1680211313492492299 + type: {class: UniversalRenderPipelineRuntimeXRResources, ns: UnityEngine.Rendering.Universal, asm: Unity.RenderPipelines.Universal.Runtime} + data: + m_xrOcclusionMeshPS: {fileID: 4800000, guid: 4431b1f1f743fbf4eb310a967890cbea, type: 3} + m_xrMirrorViewPS: {fileID: 4800000, guid: d5a307c014552314b9f560906d708772, type: 3} + m_xrMotionVector: {fileID: 4800000, guid: f89aac1e4f84468418fe30e611dff395, type: 3} + - rid: 1680211313492492300 + type: {class: URPReflectionProbeSettings, ns: UnityEngine.Rendering, asm: Unity.RenderPipelines.Universal.Runtime} + data: + version: 1 + useReflectionProbeRotation: 1 + - rid: 1680211313492492301 + type: {class: UniversalRenderPipelineRuntimeTextures, ns: UnityEngine.Rendering.Universal, asm: Unity.RenderPipelines.Universal.Runtime} + data: + m_Version: 1 + m_BlueNoise64LTex: {fileID: 2800000, guid: e3d24661c1e055f45a7560c033dbb837, type: 3} + m_BayerMatrixTex: {fileID: 2800000, guid: f9ee4ed84c1d10c49aabb9b210b0fc44, type: 3} + m_DebugFontTex: {fileID: 2800000, guid: 26a413214480ef144b2915d6ff4d0beb, type: 3} + - rid: 1680211313492492302 + type: {class: ScreenSpaceAmbientOcclusionPersistentResources, ns: UnityEngine.Rendering.Universal, asm: Unity.RenderPipelines.Universal.Runtime} + data: + m_Shader: {fileID: 4800000, guid: 0849e84e3d62649e8882e9d6f056a017, type: 3} + m_Version: 0 + - rid: 1680211313492492303 + type: {class: UniversalRenderPipelineEditorMaterials, ns: UnityEngine.Rendering.Universal, asm: Unity.RenderPipelines.Universal.Runtime} + data: + m_DefaultMaterial: {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2} + m_DefaultParticleMaterial: {fileID: 2100000, guid: e823cd5b5d27c0f4b8256e7c12ee3e6d, type: 2} + m_DefaultLineMaterial: {fileID: 2100000, guid: e823cd5b5d27c0f4b8256e7c12ee3e6d, type: 2} + m_DefaultTerrainMaterial: {fileID: 2100000, guid: 594ea882c5a793440b60ff72d896021e, type: 2} + m_DefaultDecalMaterial: {fileID: 2100000, guid: 31d0dcc6f2dd4e4408d18036a2c93862, type: 2} + m_DefaultSpriteMaterial: {fileID: 2100000, guid: 9dfc825aed78fcd4ba02077103263b40, type: 2} + - rid: 1680211313492492304 + type: {class: PostProcessData/TextureResources, ns: UnityEngine.Rendering.Universal, asm: Unity.RenderPipelines.Universal.Runtime} + data: + blueNoise16LTex: + - {fileID: 2800000, guid: 81200413a40918d4d8702e94db29911c, type: 3} + - {fileID: 2800000, guid: d50c5e07c9911a74982bddf7f3075e7b, type: 3} + - {fileID: 2800000, guid: 1134690bf9216164dbc75050e35b7900, type: 3} + - {fileID: 2800000, guid: 7ce2118f74614a94aa8a0cdf2e6062c3, type: 3} + - {fileID: 2800000, guid: 2ca97df9d1801e84a8a8f2c53cb744f0, type: 3} + - {fileID: 2800000, guid: e63eef8f54aa9dc4da9a5ac094b503b5, type: 3} + - {fileID: 2800000, guid: 39451254daebd6d40b52899c1f1c0c1b, type: 3} + - {fileID: 2800000, guid: c94ad916058dff743b0f1c969ddbe660, type: 3} + - {fileID: 2800000, guid: ed5ea7ce59ca8ec4f9f14bf470a30f35, type: 3} + - {fileID: 2800000, guid: 071e954febf155243a6c81e48f452644, type: 3} + - {fileID: 2800000, guid: 96aaab9cc247d0b4c98132159688c1af, type: 3} + - {fileID: 2800000, guid: fc3fa8f108657e14486697c9a84ccfc5, type: 3} + - {fileID: 2800000, guid: bfed3e498947fcb4890b7f40f54d85b9, type: 3} + - {fileID: 2800000, guid: d512512f4af60a442ab3458489412954, type: 3} + - {fileID: 2800000, guid: 47a45908f6db0cb44a0d5e961143afec, type: 3} + - {fileID: 2800000, guid: 4dcc0502f8586f941b5c4a66717205e8, type: 3} + - {fileID: 2800000, guid: 9d92991794bb5864c8085468b97aa067, type: 3} + - {fileID: 2800000, guid: 14381521ff11cb74abe3fe65401c23be, type: 3} + - {fileID: 2800000, guid: d36f0fe53425e08499a2333cf423634c, type: 3} + - {fileID: 2800000, guid: d4044ea2490d63b43aa1765f8efbf8a9, type: 3} + - {fileID: 2800000, guid: c9bd74624d8070f429e3f46d161f9204, type: 3} + - {fileID: 2800000, guid: d5c9b274310e5524ebe32a4e4da3df1f, type: 3} + - {fileID: 2800000, guid: f69770e54f2823f43badf77916acad83, type: 3} + - {fileID: 2800000, guid: 10b6c6d22e73dea46a8ab36b6eebd629, type: 3} + - {fileID: 2800000, guid: a2ec5cbf5a9b64345ad3fab0912ddf7b, type: 3} + - {fileID: 2800000, guid: 1c3c6d69a645b804fa232004b96b7ad3, type: 3} + - {fileID: 2800000, guid: d18a24d7b4ed50f4387993566d9d3ae2, type: 3} + - {fileID: 2800000, guid: c989e1ed85cf7154caa922fec53e6af6, type: 3} + - {fileID: 2800000, guid: ff47e5a0f105eb34883b973e51f4db62, type: 3} + - {fileID: 2800000, guid: fa042edbfc40fbd4bad0ab9d505b1223, type: 3} + - {fileID: 2800000, guid: 896d9004736809c4fb5973b7c12eb8b9, type: 3} + - {fileID: 2800000, guid: 179f794063d2a66478e6e726f84a65bc, type: 3} + filmGrainTex: + - {fileID: 2800000, guid: 654c582f7f8a5a14dbd7d119cbde215d, type: 3} + - {fileID: 2800000, guid: dd77ffd079630404e879388999033049, type: 3} + - {fileID: 2800000, guid: 1097e90e1306e26439701489f391a6c0, type: 3} + - {fileID: 2800000, guid: f0b67500f7fad3b4c9f2b13e8f41ba6e, type: 3} + - {fileID: 2800000, guid: 9930fb4528622b34687b00bbe6883de7, type: 3} + - {fileID: 2800000, guid: bd9e8c758250ef449a4b4bfaad7a2133, type: 3} + - {fileID: 2800000, guid: 510a2f57334933e4a8dbabe4c30204e4, type: 3} + - {fileID: 2800000, guid: b4db8180660810945bf8d55ab44352ad, type: 3} + - {fileID: 2800000, guid: fd2fd78b392986e42a12df2177d3b89c, type: 3} + - {fileID: 2800000, guid: 5cdee82a77d13994f83b8fdabed7c301, type: 3} + smaaAreaTex: {fileID: 2800000, guid: d1f1048909d55cd4fa1126ab998f617e, type: 3} + smaaSearchTex: {fileID: 2800000, guid: 51eee22c2a633ef4aada830eed57c3fd, type: 3} + m_TexturesResourcesVersion: 0 + - rid: 1680211313492492305 + type: {class: URPDefaultVolumeProfileSettings, ns: UnityEngine.Rendering.Universal, asm: Unity.RenderPipelines.Universal.Runtime} + data: + m_Version: 0 + m_VolumeProfile: {fileID: 11400000, guid: 5b8b49b0bf6036a4a823021e69f80309, type: 2} + - rid: 1680211313492492306 + type: {class: URPShaderStrippingSetting, ns: UnityEngine.Rendering.Universal, asm: Unity.RenderPipelines.Universal.Runtime} + data: + m_Version: 0 + m_StripUnusedPostProcessingVariants: 0 + m_StripUnusedVariants: 1 + m_StripScreenCoordOverrideVariants: 1 + - rid: 1680211313492492307 + type: {class: Renderer2DResources, ns: UnityEngine.Rendering.Universal, asm: Unity.RenderPipelines.Universal.Runtime} + data: + m_Version: 0 + m_LightShader: {fileID: 4800000, guid: 3f6c848ca3d7bca4bbe846546ac701a1, type: 3} + m_ProjectedShadowShader: {fileID: 4800000, guid: ce09d4a80b88c5a4eb9768fab4f1ee00, type: 3} + m_SpriteShadowShader: {fileID: 4800000, guid: 44fc62292b65ab04eabcf310e799ccf6, type: 3} + m_SpriteUnshadowShader: {fileID: 4800000, guid: de02b375720b5c445afe83cd483bedf3, type: 3} + m_GeometryShadowShader: {fileID: 4800000, guid: 19349a0f9a7ed4c48a27445bcf92e5e1, type: 3} + m_GeometryUnshadowShader: {fileID: 4800000, guid: 77774d9009bb81447b048c907d4c6273, type: 3} + m_CopyDepthPS: {fileID: 4800000, guid: d6dae50ee9e1bfa4db75f19f99355220, type: 3} + m_DefaultLitMaterial: {fileID: 2100000, guid: a97c105638bdf8b4a8650670310a4cd3, type: 2} + m_DefaultUnlitMaterial: {fileID: 2100000, guid: 9dfc825aed78fcd4ba02077103263b40, type: 2} + m_DefaultMaskMaterial: {fileID: 2100000, guid: 15d0c3709176029428a0da2f8cecf0b5, type: 2} + m_DefaultMesh2DLitMaterial: {fileID: 2100000, guid: 9452ae1262a74094f8a68013fbcd1834, type: 2} + - rid: 1680211313492492308 + type: {class: UniversalRenderPipelineEditorAssets, ns: UnityEngine.Rendering.Universal, asm: Unity.RenderPipelines.Universal.Runtime} + data: + m_DefaultSettingsVolumeProfile: {fileID: 11400000, guid: eda47df5b85f4f249abf7abd73db2cb2, type: 2} + - rid: 1680211313492492309 + type: {class: GPUResidentDrawerResources, ns: UnityEngine.Rendering, asm: Unity.RenderPipelines.GPUDriven.Runtime} + data: + m_Version: 0 + m_InstanceDataBufferCopyKernels: {fileID: 7200000, guid: f984aeb540ded8b4fbb8a2047ab5b2e2, type: 3} + m_InstanceDataBufferUploadKernels: {fileID: 7200000, guid: 53864816eb00f2343b60e1a2c5a262ef, type: 3} + m_TransformUpdaterKernels: {fileID: 7200000, guid: 2a567b9b2733f8d47a700c3c85bed75b, type: 3} + m_WindDataUpdaterKernels: {fileID: 7200000, guid: fde76746e4fd0ed418c224f6b4084114, type: 3} + m_OccluderDepthPyramidKernels: {fileID: 7200000, guid: 08b2b5fb307b0d249860612774a987da, type: 3} + m_InstanceOcclusionCullingKernels: {fileID: 7200000, guid: f6d223acabc2f974795a5a7864b50e6c, type: 3} + m_OcclusionCullingDebugKernels: {fileID: 7200000, guid: b23e766bcf50ca4438ef186b174557df, type: 3} + m_DebugOcclusionTestPS: {fileID: 4800000, guid: d3f0849180c2d0944bc71060693df100, type: 3} + m_DebugOccluderPS: {fileID: 4800000, guid: b3c92426a88625841ab15ca6a7917248, type: 3} + - rid: 1680211313492492310 + type: {class: VrsRenderPipelineRuntimeResources, ns: UnityEngine.Rendering, asm: Unity.RenderPipelines.Core.Runtime} + data: + m_TextureComputeShader: {fileID: 7200000, guid: cacb30de6c40c7444bbc78cb0a81fd2a, type: 3} + m_VisualizationShader: {fileID: 4800000, guid: 620b55b8040a88d468e94abe55bed5ba, type: 3} + m_VisualizationLookupTable: + m_Data: + - {r: 0.785, g: 0.23, b: 0.2, a: 1} + - {r: 1, g: 0.8, b: 0.8, a: 1} + - {r: 0.4, g: 0.2, b: 0.2, a: 1} + - {r: 0.51, g: 0.8, b: 0.6, a: 1} + - {r: 0.6, g: 0.8, b: 1, a: 1} + - {r: 0.2, g: 0.4, b: 0.6, a: 1} + - {r: 0.8, g: 1, b: 0.8, a: 1} + - {r: 0.2, g: 0.4, b: 0.2, a: 1} + - {r: 0.125, g: 0.22, b: 0.36, a: 1} + m_ConversionLookupTable: + m_Data: + - {r: 0.785, g: 0.23, b: 0.2, a: 1} + - {r: 1, g: 0.8, b: 0.8, a: 1} + - {r: 0.4, g: 0.2, b: 0.2, a: 1} + - {r: 0.51, g: 0.8, b: 0.6, a: 1} + - {r: 0.6, g: 0.8, b: 1, a: 1} + - {r: 0.2, g: 0.4, b: 0.6, a: 1} + - {r: 0.8, g: 1, b: 0.8, a: 1} + - {r: 0.2, g: 0.4, b: 0.2, a: 1} + - {r: 0.125, g: 0.22, b: 0.36, a: 1} + - rid: 1680211313492492311 + type: {class: STP/RuntimeResources, ns: UnityEngine.Rendering, asm: Unity.RenderPipelines.Core.Runtime} + data: + m_setupCS: {fileID: 7200000, guid: 33be2e9a5506b2843bdb2bdff9cad5e1, type: 3} + m_preTaaCS: {fileID: 7200000, guid: a679dba8ec4d9ce45884a270b0e22dda, type: 3} + m_taaCS: {fileID: 7200000, guid: 3923900e2b41b5e47bc25bfdcbcdc9e6, type: 3} + - rid: 1680211313492492312 + type: {class: RenderingDebuggerRuntimeResources, ns: UnityEngine.Rendering, asm: Unity.RenderPipelines.Core.Runtime} + data: + m_version: 0 + - rid: 1680211313492492313 + type: {class: RenderGraphUtilsResources, ns: UnityEngine.Rendering.RenderGraphModule.Util, asm: Unity.RenderPipelines.Core.Runtime} + data: + m_Version: 0 + m_CoreCopyPS: {fileID: 4800000, guid: 12dc59547ea167a4ab435097dd0f9add, type: 3} + - rid: 1680211313492492314 + type: {class: ProbeVolumeBakingResources, ns: UnityEngine.Rendering, asm: Unity.RenderPipelines.Core.Runtime} + data: + m_Version: 1 + dilationShader: {fileID: 7200000, guid: 6bb382f7de370af41b775f54182e491d, type: 3} + subdivideSceneCS: {fileID: 7200000, guid: bb86f1f0af829fd45b2ebddda1245c22, type: 3} + voxelizeSceneShader: {fileID: 4800000, guid: c8b6a681c7b4e2e4785ffab093907f9e, type: 3} + traceVirtualOffsetCS: {fileID: -6772857160820960102, guid: ff2cbab5da58bf04d82c5f34037ed123, type: 3} + traceVirtualOffsetRT: {fileID: -5126288278712620388, guid: ff2cbab5da58bf04d82c5f34037ed123, type: 3} + skyOcclusionCS: {fileID: -6772857160820960102, guid: 5a2a534753fbdb44e96c3c78b5a6999d, type: 3} + skyOcclusionRT: {fileID: -5126288278712620388, guid: 5a2a534753fbdb44e96c3c78b5a6999d, type: 3} + renderingLayerCS: {fileID: -6772857160820960102, guid: 94a070d33e408384bafc1dea4a565df9, type: 3} + renderingLayerRT: {fileID: -5126288278712620388, guid: 94a070d33e408384bafc1dea4a565df9, type: 3} + - rid: 1680211313492492315 + type: {class: LightmapSamplingSettings, ns: UnityEngine.Rendering, asm: Unity.RenderPipelines.Core.Runtime} + data: + m_Version: 1 + m_UseBicubicLightmapSampling: 0 + - rid: 1680211313492492316 + type: {class: RenderGraphGlobalSettings, ns: UnityEngine.Rendering, asm: Unity.RenderPipelines.Core.Runtime} + data: + m_version: 0 + m_EnableCompilationCaching: 1 + m_EnableValidityChecks: 1 + - rid: 1680211313492492317 + type: {class: IncludeAdditionalRPAssets, ns: UnityEngine.Rendering, asm: Unity.RenderPipelines.Core.Runtime} + data: + m_version: 0 + m_IncludeReferencedInScenes: 0 + m_IncludeAssetsByLabel: 0 + m_LabelToInclude: + - rid: 1680211313492492318 + type: {class: ProbeVolumeDebugResources, ns: UnityEngine.Rendering, asm: Unity.RenderPipelines.Core.Runtime} + data: + m_Version: 1 + probeVolumeDebugShader: {fileID: 4800000, guid: 3b21275fd12d65f49babb5286f040f2d, type: 3} + probeVolumeFragmentationDebugShader: {fileID: 4800000, guid: 3a80877c579b9144ebdcc6d923bca303, type: 3} + probeVolumeSamplingDebugShader: {fileID: 4800000, guid: bf54e6528c79a224e96346799064c393, type: 3} + probeVolumeOffsetDebugShader: {fileID: 4800000, guid: db8bd7436dc2c5f4c92655307d198381, type: 3} + probeSamplingDebugMesh: {fileID: -3555484719484374845, guid: 20be25aac4e22ee49a7db76fb3df6de2, type: 3} + numbersDisplayTex: {fileID: 2800000, guid: 73fe53b428c5b3440b7e87ee830b608a, type: 3} + - rid: 1680211313492492319 + type: {class: ShaderStrippingSetting, ns: UnityEngine.Rendering, asm: Unity.RenderPipelines.Core.Runtime} + data: + m_Version: 0 + m_ExportShaderVariants: 1 + m_ShaderVariantLogLevel: 0 + m_StripRuntimeDebugShaders: 1 + - rid: 1680211313492492320 + type: {class: ProbeVolumeGlobalSettings, ns: UnityEngine.Rendering, asm: Unity.RenderPipelines.Core.Runtime} + data: + m_Version: 1 + m_ProbeVolumeDisableStreamingAssets: 0 + - rid: 1680211313492492321 + type: {class: ProbeVolumeRuntimeResources, ns: UnityEngine.Rendering, asm: Unity.RenderPipelines.Core.Runtime} + data: + m_Version: 1 + probeVolumeBlendStatesCS: {fileID: 7200000, guid: a3f7b8c99de28a94684cb1daebeccf5d, type: 3} + probeVolumeUploadDataCS: {fileID: 7200000, guid: 0951de5992461754fa73650732c4954c, type: 3} + probeVolumeUploadDataL2CS: {fileID: 7200000, guid: 6196f34ed825db14b81fb3eb0ea8d931, type: 3} + - rid: 1680211313492492322 + type: {class: WorldRenderPipelineResources, ns: UnityEngine.PathTracing.Core, asm: Unity.PathTracing.Runtime} + data: + _version: 3 + _blitCubemap: {fileID: 7200000, guid: 5a992812cb320d146a66cc600200cce7, type: 3} + _blitGrayScaleCookie: {fileID: 7200000, guid: 557fa399e33bf7647bda5697c5c158df, type: 3} + _setAlphaChannelShader: {fileID: 7200000, guid: 5efaea0e81c66334aa9d062d6573e6fd, type: 3} + _environmentImportanceSamplingBuild: {fileID: 7200000, guid: 5bb2534d2411d344cbc54f880232640f, type: 3} + _skyBoxMesh: {fileID: 4300000, guid: 0529e6c5f6dea8c4a8c2835ed7de57cb, type: 2} + _sixFaceSkyBoxMesh: {fileID: 4300000, guid: a80925ceebd011741b42509226cefc74, type: 2} + _buildLightGridShader: {fileID: 7200000, guid: 16e47c1641bd0104e92b624601457bb0, type: 3} diff --git a/ReleaseConsumerProject/Assets/UniversalRenderPipelineGlobalSettings.asset.meta b/ReleaseConsumerProject/Assets/UniversalRenderPipelineGlobalSettings.asset.meta new file mode 100644 index 0000000..b0ee24c --- /dev/null +++ b/ReleaseConsumerProject/Assets/UniversalRenderPipelineGlobalSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9374f1d73c9ee8940895d44ff5dd560e +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/ReleaseConsumerProject/Packages/manifest.json b/ReleaseConsumerProject/Packages/manifest.json index 246c0a8..b82d19d 100644 --- a/ReleaseConsumerProject/Packages/manifest.json +++ b/ReleaseConsumerProject/Packages/manifest.json @@ -5,11 +5,12 @@ "com.unity.modules.imageconversion": "1.0.0", "com.unity.modules.physics": "1.0.0", "com.unity.modules.uielements": "1.0.0", - "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.7", - "org.gamecult.eve.plugin-fields": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.1.1", - "org.gamecult.eve.surface": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.surface#b6f001b300a88aff0bc13372b5c1dbc2002954f8", - "org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#b6f001b300a88aff0bc13372b5c1dbc2002954f8", - "org.gamecult.eve.unity-uitoolkit": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#eveunity-uitoolkit-v0.1.0" + "com.unity.render-pipelines.universal": "17.4.0", + "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#334e60f1928b4212a29dd8b0d19b2c099fe6365e", + "org.gamecult.eve.plugin-fields": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3", + "org.gamecult.eve.surface": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#96839ad34c8d464ef622d8bbdd5d277e1ca9d825", + "org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#d1663b36d12b60ff5c7249135eff48edf6227ca9", + "org.gamecult.eve.unity-uitoolkit": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#d1663b36d12b60ff5c7249135eff48edf6227ca9" }, "testables": [ "org.gamecult.eve.unity-scene", diff --git a/ReleaseConsumerProject/Packages/packages-lock.json b/ReleaseConsumerProject/Packages/packages-lock.json index 8d56d72..8ad82a2 100644 --- a/ReleaseConsumerProject/Packages/packages-lock.json +++ b/ReleaseConsumerProject/Packages/packages-lock.json @@ -1,5 +1,27 @@ { "dependencies": { + "com.unity.burst": { + "version": "1.8.29", + "depth": 2, + "source": "registry", + "dependencies": { + "com.unity.mathematics": "1.2.1", + "com.unity.modules.jsonserialize": "1.0.0" + }, + "url": "https://packages.unity.com" + }, + "com.unity.collections": { + "version": "6.4.0", + "depth": 2, + "source": "builtin", + "dependencies": { + "com.unity.burst": "1.8.23", + "com.unity.mathematics": "1.3.2", + "com.unity.nuget.mono-cecil": "1.11.5", + "com.unity.test-framework": "1.4.6", + "com.unity.test-framework.performance": "3.0.3" + } + }, "com.unity.ext.nunit": { "version": "2.0.5", "depth": 1, @@ -13,6 +35,60 @@ "dependencies": {}, "url": "https://packages.unity.com" }, + "com.unity.nuget.mono-cecil": { + "version": "1.11.6", + "depth": 3, + "source": "registry", + "dependencies": {}, + "url": "https://packages.unity.com" + }, + "com.unity.render-pipelines.core": { + "version": "17.4.0", + "depth": 1, + "source": "builtin", + "dependencies": { + "com.unity.burst": "1.8.14", + "com.unity.mathematics": "1.3.2", + "com.unity.ugui": "2.0.0", + "com.unity.collections": "2.4.3", + "com.unity.modules.terrain": "1.0.0", + "com.unity.modules.jsonserialize": "1.0.0" + } + }, + "com.unity.render-pipelines.universal": { + "version": "17.4.0", + "depth": 0, + "source": "builtin", + "dependencies": { + "com.unity.render-pipelines.core": "17.4.0", + "com.unity.shadergraph": "17.4.0", + "com.unity.render-pipelines.universal-config": "17.4.0" + } + }, + "com.unity.render-pipelines.universal-config": { + "version": "17.4.0", + "depth": 1, + "source": "builtin", + "dependencies": { + "com.unity.render-pipelines.core": "17.4.0" + } + }, + "com.unity.searcher": { + "version": "4.9.4", + "depth": 2, + "source": "registry", + "dependencies": {}, + "url": "https://packages.unity.com" + }, + "com.unity.shadergraph": { + "version": "17.4.0", + "depth": 1, + "source": "builtin", + "dependencies": { + "com.unity.render-pipelines.core": "17.4.0", + "com.unity.searcher": "4.9.3" + } + }, "com.unity.test-framework": { "version": "1.6.0", "depth": 0, @@ -23,49 +99,73 @@ "com.unity.modules.jsonserialize": "1.0.0" } }, + "com.unity.test-framework.performance": { + "version": "3.4.0", + "depth": 3, + "source": "registry", + "dependencies": { + "com.unity.test-framework": "1.1.33", + "com.unity.modules.jsonserialize": "1.0.0" + }, + "url": "https://packages.unity.com" + }, + "com.unity.ugui": { + "version": "2.0.0", + "depth": 2, + "source": "builtin", + "dependencies": { + "com.unity.modules.ui": "1.0.0", + "com.unity.modules.imgui": "1.0.0" + } + }, "org.gamecult.cultlib": { - "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.7", + "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#334e60f1928b4212a29dd8b0d19b2c099fe6365e", "depth": 0, "source": "git", "dependencies": {}, - "hash": "1f8f832a788e2bdf59df90fb91da583574d3a42d" + "hash": "334e60f1928b4212a29dd8b0d19b2c099fe6365e" }, "org.gamecult.eve.plugin-fields": { - "version": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.1.1", + "version": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3", "depth": 0, "source": "git", - "dependencies": {}, - "hash": "0b099274a4e2b4c301eced4322166837792f585a" + "dependencies": { + "org.gamecult.cultlib": "1.0.13" + }, + "hash": "c5a4a75c1b727499b16c2dae1895f29e2a9f72f0" }, "org.gamecult.eve.surface": { - "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.surface#b6f001b300a88aff0bc13372b5c1dbc2002954f8", + "version": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#96839ad34c8d464ef622d8bbdd5d277e1ca9d825", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.cultlib": "1.0.6" + "org.gamecult.cultlib": "1.0.56" }, - "hash": "b6f001b300a88aff0bc13372b5c1dbc2002954f8" + "hash": "96839ad34c8d464ef622d8bbdd5d277e1ca9d825" }, "org.gamecult.eve.unity-scene": { - "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#b6f001b300a88aff0bc13372b5c1dbc2002954f8", + "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#d1663b36d12b60ff5c7249135eff48edf6227ca9", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.cultlib": "1.0.6", - "org.gamecult.eve.plugin-fields": "0.1.1", - "org.gamecult.eve.surface": "0.1.0", - "com.unity.mathematics": "1.3.2" + "org.gamecult.cultlib": "1.0.56", + "org.gamecult.eve.plugin-fields": "0.2.3", + "org.gamecult.eve.surface": "0.3.6", + "org.gamecult.eve.unity-uitoolkit": "0.1.9", + "com.unity.mathematics": "1.3.2", + "com.unity.render-pipelines.universal": "17.4.0", + "com.unity.modules.particlesystem": "1.0.0" }, - "hash": "b6f001b300a88aff0bc13372b5c1dbc2002954f8" + "hash": "d1663b36d12b60ff5c7249135eff48edf6227ca9" }, "org.gamecult.eve.unity-uitoolkit": { - "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#eveunity-uitoolkit-v0.1.0", + "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#d1663b36d12b60ff5c7249135eff48edf6227ca9", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.eve.surface": "0.1.0" + "org.gamecult.eve.surface": "0.3.6" }, - "hash": "e10821f1cc81f619479c6507438f6a097d1f0ce2" + "hash": "d1663b36d12b60ff5c7249135eff48edf6227ca9" }, "com.unity.modules.assetbundle": { "version": "1.0.0", @@ -97,12 +197,24 @@ "source": "builtin", "dependencies": {} }, + "com.unity.modules.particlesystem": { + "version": "1.0.0", + "depth": 1, + "source": "builtin", + "dependencies": {} + }, "com.unity.modules.physics": { "version": "1.0.0", "depth": 0, "source": "builtin", "dependencies": {} }, + "com.unity.modules.terrain": { + "version": "1.0.0", + "depth": 2, + "source": "builtin", + "dependencies": {} + }, "com.unity.modules.ui": { "version": "1.0.0", "depth": 1, diff --git a/ReleaseConsumerProject/ProjectSettings/GraphicsSettings.asset b/ReleaseConsumerProject/ProjectSettings/GraphicsSettings.asset index 4b152c0..8b7efdf 100644 --- a/ReleaseConsumerProject/ProjectSettings/GraphicsSettings.asset +++ b/ReleaseConsumerProject/ProjectSettings/GraphicsSettings.asset @@ -37,7 +37,7 @@ GraphicsSettings: m_PreloadedShaders: [] m_PreloadShadersBatchTimeLimit: -1 m_SpritesDefaultMaterial: {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0} - m_CustomRenderPipeline: {fileID: 0} + m_CustomRenderPipeline: {fileID: 11400000, guid: 43f32b840bd83ce47af920dadd59d4b3, type: 2} m_TransparencySortMode: 0 m_TransparencySortAxis: {x: 0, y: 0, z: 1} m_DefaultRenderingPath: 1 @@ -57,7 +57,8 @@ GraphicsSettings: m_FogKeepExp: 1 m_FogKeepExp2: 1 m_AlbedoSwatchInfos: [] - m_RenderPipelineGlobalSettingsMap: {} + m_RenderPipelineGlobalSettingsMap: + UnityEngine.Rendering.Universal.UniversalRenderPipeline: {fileID: 11400000, guid: 9374f1d73c9ee8940895d44ff5dd560e, type: 2} m_ShaderBuildSettings: keywordDeclarationOverrides: [] m_LightsUseLinearIntensity: 0 diff --git a/ReleaseConsumerProject/ProjectSettings/QualitySettings.asset b/ReleaseConsumerProject/ProjectSettings/QualitySettings.asset index 64f8aba..55e71a7 100644 --- a/ReleaseConsumerProject/ProjectSettings/QualitySettings.asset +++ b/ReleaseConsumerProject/ProjectSettings/QualitySettings.asset @@ -313,7 +313,7 @@ QualitySettings: asyncUploadBufferSize: 16 asyncUploadPersistentBuffer: 1 resolutionScalingFixedDPIFactor: 1 - customRenderPipeline: {fileID: 0} + customRenderPipeline: {fileID: 11400000, guid: 43f32b840bd83ce47af920dadd59d4b3, type: 2} terrainQualityOverrides: 0 terrainPixelError: 1 terrainDetailDensityScale: 1 diff --git a/TestProject/Assets/Tests/PlayMode/GameCult.EveUnity.GenericClient.PlayModeTests.asmdef b/TestProject/Assets/Tests/PlayMode/GameCult.EveUnity.GenericClient.PlayModeTests.asmdef index 4d21cb3..e7db487 100644 --- a/TestProject/Assets/Tests/PlayMode/GameCult.EveUnity.GenericClient.PlayModeTests.asmdef +++ b/TestProject/Assets/Tests/PlayMode/GameCult.EveUnity.GenericClient.PlayModeTests.asmdef @@ -4,8 +4,10 @@ "references": [ "GameCult.CultLib", "GameCult.Eve.Surface", + "GameCult.Eve.UnityUIToolkit", "GameCult.Eve.UnityScene", - "GameCult.Eve.UnityScene.CultMesh" + "GameCult.Eve.UnityScene.CultMesh", + "Unity.RenderPipelines.Universal.Runtime" ], "includePlatforms": [], "excludePlatforms": [], @@ -14,6 +16,7 @@ "GameCult.Caching.dll", "GameCult.Caching.MessagePack.dll", "GameCult.Mesh.dll", + "GameCult.Mesh.Quic.Native.dll", "GameCult.Networking.dll" ], "autoReferenced": false, diff --git a/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index d5dc99b..8d8cef4 100644 --- a/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -2,13 +2,24 @@ using System; using System.Collections; using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; using GameCult.Eve.Surface; +using GameCult.Eve.UnityUIToolkit; using GameCult.Eve.UnityScene; +using GameCult.Eve.UnityScene.Fields; +using GameCult.Mesh; +using GameCult.Mesh.Quic.Native; using NUnit.Framework; using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.Rendering.Universal; using UnityEngine.TestTools; +using UnityEngine.UIElements; +using Stopwatch = System.Diagnostics.Stopwatch; namespace GameCult.EveUnity.GenericClient.PlayModeTests { @@ -171,34 +182,111 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests var providerId = Environment.GetEnvironmentVariable("EVEUNITY_PROVIDER_ID") ?? ""; var surfaceId = Environment.GetEnvironmentVariable("EVEUNITY_SURFACE_ID") ?? ""; - var replicaPath = Environment.GetEnvironmentVariable("EVEUNITY_REPLICA_PATH") ?? - Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}.cc"); + var cacheDirectory = Environment.GetEnvironmentVariable("EVEUNITY_CACHE_DIRECTORY") ?? + Path.Combine(Application.temporaryCachePath, $"eve-unity-{Guid.NewGuid():N}"); var capturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_CAPTURE_PATH") ?? Path.Combine(Application.temporaryCachePath, "aetheria-daemon-world.png"); + var mapCapturePath = Environment.GetEnvironmentVariable("EVEUNITY_AETHERIA_MAP_CAPTURE_PATH") ?? + Path.Combine(Application.temporaryCachePath, "aetheria-daemon-map.png"); var root = new GameObject("Generic Eve World Projection"); var cameraObject = new GameObject("Generic Eve Capture Camera"); - var lightObject = new GameObject("Generic Eve World Light"); + var mapCameraObject = new GameObject("Generic Eve Map Camera"); RenderTexture target = null; Texture2D pixels = null; EveUnityCultMeshPlayableWorldProvider provider = null; + EveUnityPlayableWorldClientHost host = null; EveUnityPlayableWorldRuntime runtime = null; WitnessReceipt movementReceipt = null; + WitnessReceipt lookReceipt = null; WitnessReceipt focusReceipt = null; + WitnessReceipt tractorReceipt = null; + WitnessReceipt tractorReleaseReceipt = null; WitnessReceipt actionReceipt = null; + WitnessReceipt tradePurchaseReceipt = null; + WitnessReceipt tradeSaleReceipt = null; + bool tradeRoundTrip = false; + string tradeItemKey = ""; + EveUnityShotReceipt observedShot = null; + EveUnityCombatPresentation observedCombat = null; + float shieldRatioBefore = 0f; + float hullRatioBefore = 0f; long initialVersion = 0; float movementDistance = 0f; + float aimDotDistance = 0f; + int cockpitProgressCount = 0; + float tractorBeamPower = 0f; + float tractorReleasedPower = 1f; + int tractorBeamParticleSystemCount = 0; + EveUnityFeedbackEvent pickupCollection = null; + EveUnityFeedbackEvent pickupRejection = null; + int pickupCollectionEventCount = 0; + int pickupRejectionEventCount = 0; + var pickupRejectionEventIds = new HashSet(StringComparer.Ordinal); + int initialPickupEntityCount = 0; + int finalPickupEntityCount = -1; + bool destructionPickupObserved = false; + int maximumTrajectoryCount = 0; + bool hitMarkerObserved = false; + var witnessProfile = Environment.GetEnvironmentVariable("EVEUNITY_WITNESS_PROFILE") ?? + "full-session-gameplay"; + Assert.That(witnessProfile, + Is.EqualTo("cold-start-lowering").Or.EqualTo("full-session-gameplay"), + "The witness profile must name an explicit proof boundary."); + var requireSessionGameplay = string.Equals( + witnessProfile, + "full-session-gameplay", + StringComparison.Ordinal); + var gameplayScenario = Environment.GetEnvironmentVariable("EVEUNITY_WITNESS_GAMEPLAY_SCENARIO") ?? + "released-client-proof"; + Assert.That(gameplayScenario, + Is.EqualTo("released-client-proof").Or.EqualTo("cargo-capacity-rejection-proof"), + "The live witness must select an explicit daemon-owned gameplay scenario."); + var expectCargoRejection = string.Equals( + gameplayScenario, + "cargo-capacity-rejection-proof", + StringComparison.Ordinal); + EveUnityAimPresentationRenderer aimRenderer = null; + EveUnityBeamPresentation beamPresentation = null; + EveUnityBeamPresentationRenderer beamRenderer = null; + EveUnityShotTrajectoryRenderer trajectories = null; try { + var providerReadyPath = Environment.GetEnvironmentVariable("EVEUNITY_PROVIDER_READY_PATH"); + if (!string.IsNullOrWhiteSpace(providerReadyPath)) + File.WriteAllText(providerReadyPath, DateTimeOffset.UtcNow.ToString("O")); provider = root.AddComponent(); provider.Configure( rendezvousEndpoint, - replicaPath, + cacheDirectory, providerId, surfaceId, requiredSurfaceKind: "interactive-world", clientRuntimeId: $"eve-unity-test-{Guid.NewGuid():N}"); - var publicationDeadline = Time.realtimeSinceStartup + 10f; + var preparationDeadline = Time.realtimeSinceStartup + 30f; + while (true) + { + var preparation = provider.PrepareAsync(); + while (!preparation.IsCompleted && Time.realtimeSinceStartup < preparationDeadline) + yield return new WaitForSecondsRealtime(0.1f); + if (!preparation.IsCompleted) + throw new TimeoutException("Timed out preparing the advertised Eve provider."); + var prepared = false; + try + { + preparation.GetAwaiter().GetResult(); + prepared = true; + } + catch (InvalidOperationException) when (Time.realtimeSinceStartup < preparationDeadline) + { + } + catch (TimeoutException) when (Time.realtimeSinceStartup < preparationDeadline) + { + } + if (prepared) break; + yield return new WaitForSecondsRealtime(0.1f); + } + var publicationDeadline = Time.realtimeSinceStartup + 30f; while (true) { var published = false; @@ -210,6 +298,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests catch (InvalidOperationException) when (Time.realtimeSinceStartup < publicationDeadline) { } + catch (TimeoutException) when (Time.realtimeSinceStartup < publicationDeadline) + { + } if (published) break; yield return new WaitForSecondsRealtime(0.1f); } @@ -217,168 +308,1084 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests Assert.That(provider.Selection.VerseId, Is.Not.Empty); Assert.That(provider.Selection.ProviderId, Is.Not.Empty); Assert.That(provider.Selection.SurfaceId, Is.Not.Empty); - runtime = EveUnityPlayableWorldRuntime.CreateForGameObjectScene( + var quicProof = ReceiveAdvertisedRealtimeFrameAsync( + rendezvousEndpoint, + provider.Selection.VerseId, + provider.Selection.AuthorityRuntimeId); + var quicDeadline = Time.realtimeSinceStartup + 15f; + while (!quicProof.IsCompleted && Time.realtimeSinceStartup < quicDeadline) + yield return new WaitForSecondsRealtime(0.05f); + if (!quicProof.IsCompleted) + throw new TimeoutException("Timed out receiving advertised CultMesh QUIC state in Unity."); + var quicFrame = quicProof.GetAwaiter().GetResult(); + Assert.That(quicFrame.Delivery, Is.EqualTo(CultMeshRealtimeDelivery.LatestOnly)); + Assert.That(quicFrame.BodyId, Is.Not.Empty); + Assert.That(quicFrame.SchemaId, Is.Not.Empty); + Assert.That(provider.CurrentAssetBodyTransportKind, Is.Null, + "Publishing the provider catalog eagerly transferred an asset bundle."); + host = root.AddComponent(); + host.Configure( root.transform, provider, provider, - null, + provider, provider, provider); - var initial = runtime.Connect(); - Assert.That(initial.ActiveEntities, Is.GreaterThan(0)); + host.Connect(); + var loweredSurface = new EveUiToolkitSurfaceLowerer().Lower( + provider.CurrentDocument.SurfaceDocument, + provider.Submit); + var cockpit = loweredSurface.Q("aetheria.daemon.game.cockpit"); + Assert.That(cockpit, Is.Not.Null, "The provider did not publish a generic cockpit overlay."); + cockpitProgressCount = cockpit.Query().ToList().Count; + Assert.That(cockpitProgressCount, Is.GreaterThanOrEqualTo(7)); + Assert.That(loweredSurface.Q("aetheria.daemon.game.frame").style.display.value, + Is.EqualTo(DisplayStyle.None), "Daemon diagnostics leaked into the pilot UI."); + trajectories = root.GetComponent(); + Assert.That(trajectories, Is.Not.Null); + host.ShotAvailable += shot => + { + observedShot = shot; + maximumTrajectoryCount = Math.Max(maximumTrajectoryCount, trajectories.ActiveTrajectoryCount); + }; + runtime = host.Runtime; + Assert.That(runtime, Is.Not.Null); Assert.That(runtime.ActiveWorld.PlayerEntityId, Is.Not.Empty); - + var entityDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < entityDeadline && + (host.PresentedEntities?.CurrentGeneration == null || + host.PresentedEntities.CurrentGeneration.Entities.Count == 0)) + { + yield return new WaitForSecondsRealtime(0.1f); + host.Refresh(); + } + Assert.That(host.PresentedEntities?.CurrentGeneration, Is.Not.Null); + Assert.That(host.PresentedEntities.CurrentGeneration.Entities.Count, Is.GreaterThan(0)); + initialPickupEntityCount = host.PresentedEntities.CurrentGeneration.Entities.Count(entity => + string.Equals(entity.EntityKind, "pickup", StringComparison.Ordinal)); + if (requireSessionGameplay) + Assert.That(initialPickupEntityCount, Is.Zero, + "The witness scenario must obtain salvage from daemon-owned destruction, not a boot-seeded pickup."); var playerId = runtime.ActiveWorld.PlayerEntityId; + var playerFact = host.PresentedEntities.CurrentGeneration.Entities + .Single(entity => entity.EntityId == playerId); + runtime.FeedbackAvailable += feedback => + { + if (feedback.TargetEntityIndex != playerFact.SourceIndex) return; + if (string.Equals(feedback.Kind, "pickup.collected", StringComparison.Ordinal)) + { + pickupCollectionEventCount++; + pickupCollection = feedback; + } + else if (string.Equals(feedback.Kind, "pickup.rejected", StringComparison.Ordinal)) + { + pickupRejectionEventCount++; + pickupRejectionEventIds.Add(feedback.EventId); + pickupRejection = feedback; + } + }; + var marker = FindMarker(root, playerId); + + if (requireSessionGameplay) + { + EveUnitySceneCommandReceipt observedDockReceipt = null; + string dockCommandId = null; + runtime.ReceiptAvailable += receipt => + { + if (receipt.CommandId == dockCommandId) + observedDockReceipt = receipt; + }; + host.SubmitAdvertisedActionIntent(playerId, "simulation.step"); + var dockVersion = runtime.ActiveVersion; + var dock = host.SubmitAdvertisedActionIntent(playerId, "pilot.dock"); + dockCommandId = dock.CommandId; + var dockDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < dockDeadline && + (observedDockReceipt == null || + !string.Equals(observedDockReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < observedDockReceipt.SourceVersion || + !(host.InputCapability?.Actions ?? Array.Empty()).Any(candidate => + candidate != null && + candidate.ActionId.StartsWith("trade.buy.", StringComparison.Ordinal) && + string.Equals(candidate.Availability, "available", StringComparison.OrdinalIgnoreCase)))) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(observedDockReceipt, dock.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, dockVersion); + var buyAction = (host.InputCapability?.Actions ?? Array.Empty()) + .FirstOrDefault(candidate => candidate != null && + candidate.ActionId.StartsWith("trade.buy.", StringComparison.Ordinal) && + string.Equals(candidate.Availability, "available", StringComparison.OrdinalIgnoreCase)); + Assert.That(buyAction, Is.Not.Null, + "Docking did not expose an available provider-authored purchase action."); + Assert.That(buyAction.Payload.TryGetValue("itemKey", out tradeItemKey), Is.True); + Assert.That(tradeItemKey, Is.Not.Empty); + + var purchaseVersion = runtime.ActiveVersion; + var purchase = host.SubmitAdvertisedActionIntent(playerId, buyAction.ActionId); + var purchaseDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < purchaseDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != purchase.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion || + !(host.InputCapability?.Actions ?? Array.Empty()).Any(candidate => + candidate != null && + candidate.ActionId.StartsWith("trade.sell.", StringComparison.Ordinal) && + candidate.Payload.TryGetValue("itemKey", out var itemKey) && + string.Equals(itemKey, tradeItemKey, StringComparison.Ordinal) && + string.Equals(candidate.Availability, "available", StringComparison.OrdinalIgnoreCase)))) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, purchase.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, purchaseVersion); + tradePurchaseReceipt = WitnessReceipt.From("trade-purchase", runtime.LastReceipt); + var sellAction = (host.InputCapability?.Actions ?? Array.Empty()) + .FirstOrDefault(candidate => candidate != null && + candidate.ActionId.StartsWith("trade.sell.", StringComparison.Ordinal) && + candidate.Payload.TryGetValue("itemKey", out var itemKey) && + string.Equals(itemKey, tradeItemKey, StringComparison.Ordinal) && + string.Equals(candidate.Availability, "available", StringComparison.OrdinalIgnoreCase)); + Assert.That(sellAction, Is.Not.Null, + "The authoritative purchase did not expose the purchased cargo as an available sale action."); + + var saleCapabilityVersion = host.InputCapability.Version; + var saleVersion = runtime.ActiveVersion; + var sale = host.SubmitAdvertisedActionIntent(playerId, sellAction.ActionId); + var saleDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < saleDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != sale.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion || + host.InputCapability.Version <= saleCapabilityVersion || + (host.InputCapability.Actions ?? Array.Empty()).Any(candidate => + candidate != null && + candidate.ActionId.StartsWith("trade.sell.", StringComparison.Ordinal) && + candidate.Payload.TryGetValue("itemKey", out var itemKey) && + string.Equals(itemKey, tradeItemKey, StringComparison.Ordinal)))) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, sale.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, saleVersion); + tradeSaleReceipt = WitnessReceipt.From("trade-sale", runtime.LastReceipt); + tradeRoundTrip = !(host.InputCapability.Actions ?? Array.Empty()).Any(candidate => + candidate != null && + candidate.ActionId.StartsWith("trade.sell.", StringComparison.Ordinal) && + candidate.Payload.TryGetValue("itemKey", out var itemKey) && + string.Equals(itemKey, tradeItemKey, StringComparison.Ordinal)); + Assert.That(tradeRoundTrip, Is.True, + "The sold unit remained in the daemon-authored cargo action catalog."); + + var resumeVersion = runtime.ActiveVersion; + var resume = host.SubmitAdvertisedActionIntent(playerId, "simulation.rate.realtime"); + var resumeDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < resumeDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != resume.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, resume.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, resumeVersion); + + var undockVersion = runtime.ActiveVersion; + var undock = host.SubmitAdvertisedActionIntent(playerId, "pilot.undock"); + var undockDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < undockDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != undock.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion || + (host.InputCapability?.Actions ?? Array.Empty()).Any(candidate => + candidate != null && candidate.ActionId.StartsWith("trade.", StringComparison.Ordinal)))) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, undock.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, undockVersion); + } + + var playerPrefab = provider.ResolvePrefab(new EveUnityPlayableWorldAssetBinding( + playerFact.AssetRef, + playerFact.EntityKind, + "provider-asset-ref")); + Assert.That(playerPrefab, Is.Not.Null, + $"Provider asset catalog did not resolve player AssetRef '{playerFact.AssetRef}'."); + Assert.That(provider.CurrentAssetBodyTransportKind, + Is.EqualTo(CultMeshBodyTransportKind.SharedFileMapping), + "The first requested provider bundle was not negotiated as a mapped body."); 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; - initialVersion = runtime.ActiveVersion; - var request = runtime.SubmitMoveVectorIntent(playerId, 1f, 0f, 1f); - var deadline = Time.realtimeSinceStartup + 12f; - while (Time.realtimeSinceStartup < deadline && - (runtime.LastReceipt == null || runtime.ActiveVersion <= initialVersion || - Vector3.Distance(FindMarker(root, playerId).transform.position, initialPosition) < 0.01f)) + initialVersion = runtime.ActiveVersion; + if (requireSessionGameplay) { - yield return new WaitForSecondsRealtime(0.1f); - runtime.Refresh(); + var initialPosition = marker.transform.position; + var request = runtime.SubmitMoveVectorIntent(playerId, 1f, 0f, 1f); + + var deadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < deadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != request.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion <= initialVersion || + Vector3.Distance(FindMarker(root, playerId).transform.position, initialPosition) < 0.01f)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + + AssertReconciledProviderReceipt( + runtime.LastReceipt, + request.CommandId, + provider.Selection.ProviderId, + provider.Selection.SurfaceId, + initialVersion); + Assert.That(runtime.ActiveVersion, Is.GreaterThan(initialVersion)); + Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(runtime.LastReceipt.SourceVersion)); + movementDistance = Vector3.Distance(FindMarker(root, playerId).transform.position, initialPosition); + Assert.That(movementDistance, Is.GreaterThan(0.01f)); + movementReceipt = WitnessReceipt.From("movement", runtime.LastReceipt); + + var releaseVersion = runtime.ActiveVersion; + var release = runtime.SubmitMoveVectorIntent(playerId, 0f, 0f, 0f); + var releaseDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < releaseDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != release.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase))) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, release.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, releaseVersion); } - Assert.That(runtime.LastReceipt, Is.Not.Null); - Assert.That(runtime.LastReceipt.CommandId, Is.EqualTo(request.CommandId)); - Assert.That( - runtime.LastReceipt.State, - Is.EqualTo("accepted").Or.EqualTo("reconciled"), - runtime.LastReceipt.Message); - Assert.That(runtime.LastReceipt.SourceVersion, Is.GreaterThan(initialVersion)); - Assert.That(runtime.ActiveVersion, Is.GreaterThan(initialVersion)); - movementDistance = Vector3.Distance(FindMarker(root, playerId).transform.position, initialPosition); - Assert.That(movementDistance, Is.GreaterThan(0.01f)); - movementReceipt = WitnessReceipt.From("movement", runtime.LastReceipt); - + if (requireSessionGameplay) + { var movementVersion = runtime.ActiveVersion; var focus = runtime.SubmitFocusIntent(playerId); var focusDeadline = Time.realtimeSinceStartup + 12f; while (Time.realtimeSinceStartup < focusDeadline && (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != focus.CommandId || - runtime.ActiveVersion < runtime.LastReceipt.SourceVersion)) + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion || + string.IsNullOrWhiteSpace(EveUnityCombatPresentation.Find(runtime.ActiveProjection)?.SelectedTargetEntityId))) { yield return new WaitForSecondsRealtime(0.1f); runtime.Refresh(); } - Assert.That(runtime.LastReceipt, Is.Not.Null); - Assert.That(runtime.LastReceipt.CommandId, Is.EqualTo(focus.CommandId)); - Assert.That(runtime.LastReceipt.State, Is.EqualTo("accepted").Or.EqualTo("reconciled")); - Assert.That(runtime.LastReceipt.SourceVersion, Is.GreaterThan(movementVersion)); + AssertReconciledProviderReceipt( + runtime.LastReceipt, + focus.CommandId, + provider.Selection.ProviderId, + provider.Selection.SurfaceId, + movementVersion); + Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(runtime.LastReceipt.SourceVersion)); focusReceipt = WitnessReceipt.From("targeting", runtime.LastReceipt); + observedCombat = EveUnityCombatPresentation.Find(runtime.ActiveProjection); + Assert.That(observedCombat, Is.Not.Null); + Assert.That(observedCombat.TargetVisible, Is.True); + Assert.That(observedCombat.TargetHostile, Is.True); + var targetEntityId = observedCombat.SelectedTargetEntityId; + var targetEntityIndex = observedCombat.SelectedTargetEntityIndex; + Assert.That(targetEntityId, Is.Not.Empty); + Assert.That(host.PresentedEntities.CurrentGeneration.Entities.Any(entity => entity.EntityId == targetEntityId), Is.True, + "The provider-selected combat target is absent from the advertised SoA generation."); + shieldRatioBefore = observedCombat.ShieldRatio; + hullRatioBefore = observedCombat.HullRatio; + var targetFact = host.PresentedEntities.CurrentGeneration.Entities + .Single(entity => entity.EntityId == targetEntityId); + playerFact = host.PresentedEntities.CurrentGeneration.Entities + .Single(entity => entity.EntityId == playerId); + var aim = targetFact.Position - playerFact.Position; + aim.y = 0; + Assert.That(aim.sqrMagnitude, Is.GreaterThan(0.0001f)); + aim.Normalize(); + var lookVersion = runtime.ActiveVersion; + var look = runtime.SubmitLookDirectionIntent(playerId, aim.x, 0f, aim.z); + var lookDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < lookDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != look.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion || + Vector3.Dot(FindMarker(root, playerId).transform.forward, aim) < 0.99f)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, look.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, lookVersion); + Assert.That(Vector3.Dot(FindMarker(root, playerId).transform.forward, aim), Is.GreaterThan(0.99f)); + lookReceipt = WitnessReceipt.From("look", runtime.LastReceipt); + aimRenderer = root.GetComponent(); + Assert.That(aimRenderer, Is.Not.Null); + + beamPresentation = EveUnityBeamPresentation.FindAll(runtime.ActiveProjection).SingleOrDefault(); + Assert.That(beamPresentation, Is.Not.Null, + "The provider did not advertise its continuous beam presentation."); + Assert.That(beamPresentation.ActivationActionId, Is.Not.Empty, + "The beam presentation does not name an advertised generic action."); + var tractorVersion = runtime.ActiveVersion; + var tractor = host.SubmitAdvertisedActionValueIntent(playerId, beamPresentation.ActivationActionId, 1f); + var tractorDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < tractorDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != tractor.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion || + EveUnityBeamPresentation.FindAll(runtime.ActiveProjection).Single().Power < 0.99f)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, tractor.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, tractorVersion); + tractorReceipt = WitnessReceipt.From("beam", runtime.LastReceipt); + Assert.That(beamPresentation.SourceEntityId, Is.EqualTo(playerId), + "The daemon beam source does not identify the presented player entity."); + Assert.That(host.PresentedEntities.TryGetByEntityId(beamPresentation.SourceEntityId, out _), Is.True, + "The generic beam lowerer cannot resolve the advertised source entity."); + Assert.That(provider.ResolvePrefab(new EveUnityPlayableWorldAssetBinding( + beamPresentation.AssetRole, beamPresentation.AssetRole, "provider-asset-ref")), Is.Not.Null, + $"The provider asset catalog did not resolve beam role '{beamPresentation.AssetRole}'."); + beamRenderer = root.GetComponent(); + Assert.That(beamRenderer, Is.Not.Null); + beamRenderer.RefreshNow(); + Assert.That(beamRenderer.ActiveBeamCount, Is.EqualTo(1)); + Assert.That(beamRenderer.TryGetPower(beamPresentation.Id, out tractorBeamPower), Is.True); + Assert.That(tractorBeamPower, Is.GreaterThanOrEqualTo(0.99f), + "Held input did not reach full daemon-authored tractor power before capture."); + tractorBeamParticleSystemCount = beamRenderer.ActiveParticleSystemCount; + Assert.That(tractorBeamParticleSystemCount, Is.GreaterThan(0), + "The generic beam lowerer did not instantiate the provider-owned effect prefab."); + targetFact = host.PresentedEntities.CurrentGeneration.Entities + .Single(entity => entity.EntityId == targetEntityId); + playerFact = host.PresentedEntities.CurrentGeneration.Entities + .Single(entity => entity.EntityId == playerId); + aim = targetFact.Position - playerFact.Position; + aim.y = 0; + Assert.That(aim.sqrMagnitude, Is.GreaterThan(0.0001f)); + aim.Normalize(); + lookVersion = runtime.ActiveVersion; + look = runtime.SubmitLookDirectionIntent(playerId, aim.x, 0f, aim.z); + lookDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < lookDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != look.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion || + Vector3.Dot(FindMarker(root, playerId).transform.forward, aim) < 0.99f)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, look.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, lookVersion); + Assert.That(Vector3.Dot(FindMarker(root, playerId).transform.forward, aim), Is.GreaterThan(0.99f)); + lookReceipt = WitnessReceipt.From("look", runtime.LastReceipt); + + var combatRenderer = root.GetComponent(); + Assert.That(combatRenderer, Is.Not.Null); var focusVersion = runtime.ActiveVersion; - var action = runtime.SubmitActionIntent(playerId, "0"); + var inputDriver = root.GetComponent() ?? + root.AddComponent(); + inputDriver.Host = host; + var primaryActionId = EveUnityPlayableWorldInputDriver.ResolvePrimaryActionId(host.InputCapability); + Assert.That(primaryActionId, Is.Not.Empty, + "The advertised input capability did not bind a primary action for this generic client."); + var primaryAction = EveUnityAdvertisedInputAction.Resolve(host.InputCapability, primaryActionId); + Assert.That(primaryAction.IsButtonHold, Is.True, + "The witness primary action must be driven through its advertised held-input semantics."); + var action = inputDriver.SubmitActionValue(primaryActionId, 1f); + Assert.That(action, Is.Not.Null, + "The advertised input capability did not bind a primary action for this generic client."); var actionDeadline = Time.realtimeSinceStartup + 12f; while (Time.realtimeSinceStartup < actionDeadline && - (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != action.CommandId || + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != action!.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || runtime.ActiveVersion < runtime.LastReceipt.SourceVersion)) { yield return new WaitForSecondsRealtime(0.1f); runtime.Refresh(); + combatRenderer.RefreshNow(); + hitMarkerObserved |= combatRenderer.HitMarkerVisible; } - Assert.That(runtime.LastReceipt, Is.Not.Null); - Assert.That(runtime.LastReceipt.CommandId, Is.EqualTo(action.CommandId)); - Assert.That( - runtime.LastReceipt.State, - Is.EqualTo("accepted").Or.EqualTo("reconciled"), - runtime.LastReceipt.Message); - Assert.That(runtime.LastReceipt.SourceVersion, Is.GreaterThan(focusVersion)); + AssertReconciledProviderReceipt( + runtime.LastReceipt, + action!.CommandId, + provider.Selection.ProviderId, + provider.Selection.SurfaceId, + focusVersion); Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(runtime.LastReceipt.SourceVersion)); actionReceipt = WitnessReceipt.From("action", runtime.LastReceipt); - var light = lightObject.AddComponent(); - light.type = LightType.Directional; - light.intensity = 1.4f; - lightObject.transform.rotation = Quaternion.Euler(45f, -35f, 0f); + var shotDeadline = Time.realtimeSinceStartup + 20f; + while (Time.realtimeSinceStartup < shotDeadline && + (observedShot == null || + host.PresentedEntities.CurrentGeneration.Entities.Any(entity => + entity.EntityId == targetEntityId) || + (!destructionPickupObserved && pickupCollection == null && pickupRejection == null))) + { + yield return new WaitForSecondsRealtime(0.05f); + runtime.Refresh(); + destructionPickupObserved |= host.PresentedEntities.CurrentGeneration.Entities.Any(entity => + string.Equals(entity.EntityKind, "pickup", StringComparison.Ordinal)); + maximumTrajectoryCount = Math.Max(maximumTrajectoryCount, trajectories.ActiveTrajectoryCount); + combatRenderer.RefreshNow(); + hitMarkerObserved |= combatRenderer.HitMarkerVisible; + } + Assert.That(observedShot, Is.Not.Null, + "A reconciled fire request never reached daemon-owned lock acquisition and shot resolution."); + Assert.That(host.PresentedEntities.CurrentGeneration.Entities.Any(entity => + entity.EntityId == targetEntityId), Is.False, + "The low-hull witness raider was not destroyed by daemon combat."); + Assert.That(destructionPickupObserved || pickupCollection != null || pickupRejection != null, Is.True, + "Daemon destruction did not publish its guaranteed cargo as a pickup."); + // The provider advances a fixed-step simulation while also publishing the large SoA witness. + // Allow enough wall time for the tractor to cover its advertised volume without making the + // generic client manufacture ship motion or loot authority. + var collectionDeadline = Time.realtimeSinceStartup + 45f; + var nextTractorAimAt = 0f; + while (Time.realtimeSinceStartup < collectionDeadline && + (expectCargoRejection + ? pickupRejection == null + : pickupCollection == null || host.PresentedEntities.CurrentGeneration.Entities.Any(entity => + string.Equals(entity.EntityKind, "pickup", StringComparison.Ordinal)))) + { + yield return new WaitForSecondsRealtime(0.05f); + runtime.Refresh(); + maximumTrajectoryCount = Math.Max(maximumTrajectoryCount, trajectories.ActiveTrajectoryCount); + combatRenderer.RefreshNow(); + hitMarkerObserved |= combatRenderer.HitMarkerVisible; + var pickupFact = host.PresentedEntities.CurrentGeneration.Entities.FirstOrDefault(entity => + string.Equals(entity.EntityKind, "pickup", StringComparison.Ordinal)); + if (pickupFact == null || Time.realtimeSinceStartup < nextTractorAimAt) + continue; + playerFact = host.PresentedEntities.CurrentGeneration.Entities + .Single(entity => entity.EntityId == playerId); + var tractorAim = pickupFact.Position - playerFact.Position; + tractorAim.y = 0; + if (tractorAim.sqrMagnitude <= 0.0001f) + continue; + tractorAim.Normalize(); + runtime.SubmitLookDirectionIntent(playerId, tractorAim.x, 0f, tractorAim.z); + nextTractorAimAt = Time.realtimeSinceStartup + 0.5f; + } + finalPickupEntityCount = host.PresentedEntities.CurrentGeneration.Entities.Count(entity => + string.Equals(entity.EntityKind, "pickup", StringComparison.Ordinal)); + if (expectCargoRejection) + { + Assert.That(pickupRejection, Is.Not.Null, + "Full cargo did not reject destruction salvage at daemon pickup proximity."); + Assert.That(pickupRejectionEventCount, Is.EqualTo(1), + "The first destruction-loot proximity attempt must emit one rejection event."); + Assert.That(pickupRejectionEventIds.Count, Is.EqualTo(pickupRejectionEventCount), + "One daemon proximity attempt emitted duplicate player rejection feedback."); + Assert.That(pickupCollectionEventCount, Is.Zero, + "Capacity-rejected salvage was also collected."); + Assert.That(pickupRejection.ItemKey, Is.Not.Empty); + Assert.That(pickupRejection.EventId, Does.StartWith("frame:"), + "Capacity feedback must retain its daemon frame/zone/pickup attempt identity."); + Assert.That(pickupRejection.Reason, Is.EqualTo("cargo-capacity")); + Assert.That(pickupRejection.ScalarValue, Is.EqualTo(1)); + Assert.That(pickupRejection.CargoQuantityAfter, + Is.EqualTo(pickupRejection.CargoQuantityBefore)); + Assert.That(finalPickupEntityCount, Is.EqualTo(1), + "Capacity-rejected destruction loot disappeared from provider state."); + } + else + { + Assert.That(pickupCollection, Is.Not.Null, + "Destruction-created salvage never reached cargo through daemon pickup proximity."); + Assert.That(pickupCollectionEventCount, Is.EqualTo(1), + "One durable destruction pickup must emit one collection event."); + Assert.That(pickupRejectionEventCount, Is.Zero, + "Collectible destruction salvage was also rejected."); + Assert.That(pickupCollection.ItemKey, Is.Not.Empty, + "Destruction loot must retain its canonical typed item identity."); + Assert.That(pickupCollection.EventId, Does.StartWith("zone:"), + "Collection feedback must retain its stable daemon zone/pickup identity."); + Assert.That(pickupCollection.ScalarValue, Is.EqualTo(1)); + Assert.That(pickupCollection.CargoQuantityBefore, Is.Zero); + Assert.That(pickupCollection.CargoQuantityAfter, Is.EqualTo(1)); + Assert.That(finalPickupEntityCount, Is.Zero, + "Collected destruction loot remained in the provider-authored entity generation."); + } + observedCombat = EveUnityCombatPresentation.Find(runtime.ActiveProjection); + Assert.That(observedShot.TargetEntityIndex, Is.EqualTo(targetEntityIndex)); + Assert.That(observedShot.LockQuality, Is.GreaterThanOrEqualTo(0.99)); + Assert.That(maximumTrajectoryCount, Is.GreaterThan(0)); + combatRenderer.RefreshNow(); + if (observedCombat != null && !string.IsNullOrWhiteSpace(observedCombat.SelectedTargetEntityId)) + { + Assert.That(combatRenderer.ReticleVisible, Is.True); + Assert.That(combatRenderer.LockVisible, Is.True); + } + if (observedShot.Hit && (observedShot.AppliedDamage > 0 || observedShot.ShieldAbsorbedDamage > 0)) + Assert.That(hitMarkerObserved, Is.True); + } + + aimRenderer ??= root.GetComponent(); + Assert.That(aimRenderer, Is.Not.Null); + beamPresentation ??= EveUnityBeamPresentation.FindAll(runtime.ActiveProjection).SingleOrDefault(); + Assert.That(beamPresentation, Is.Not.Null, + "The provider did not advertise its continuous beam presentation."); + beamRenderer ??= root.GetComponent(); + finalPickupEntityCount = Math.Max(0, finalPickupEntityCount); + var camera = cameraObject.AddComponent(); camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = new Color(0.035f, 0.055f, 0.08f, 1f); - foreach (var entityMarker in root.GetComponentsInChildren()) - { - 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); - } - } + camera.cullingMask = -1; var playerMarker = FindMarker(root, playerId); var playerRenderers = playerMarker.GetComponentsInChildren(); Assert.That(playerRenderers, Is.Not.Empty, "The provider-authored player prefab has no renderable visual."); - var playerBounds = playerRenderers[0].bounds; + Assert.That(playerRenderers.Any(renderer => renderer.name == "Shield Visual"), Is.False, + "The provider hull retained an always-on shield envelope instead of using receipt-driven shield feedback."); + Assert.That(playerMarker.GetComponentsInChildren(), Is.Empty, + "The provider-authored player presentation retained gameplay colliders."); + Assert.That(playerMarker.GetComponentsInChildren(), Is.Empty, + "The provider-authored player presentation retained gameplay rigid bodies."); foreach (var renderer in playerRenderers) - playerBounds.Encapsulate(renderer.bounds); - var halfFovRadians = camera.fieldOfView * 0.5f * Mathf.Deg2Rad; - var cameraDistance = Mathf.Max(8f, playerBounds.extents.magnitude / Mathf.Tan(halfFovRadians) * 1.35f); - camera.nearClipPlane = Mathf.Max(0.01f, cameraDistance * 0.001f); - camera.farClipPlane = Mathf.Max(1000f, cameraDistance + playerBounds.size.magnitude * 4f); - camera.transform.position = playerBounds.center + new Vector3(1f, 0.65f, -1.2f).normalized * cameraDistance; - camera.transform.LookAt(playerBounds.center); - target = new RenderTexture(640, 360, 24, RenderTextureFormat.ARGB32); + foreach (var material in renderer.sharedMaterials) + { + Assert.That(material, Is.Not.Null, $"Provider renderer {renderer.name} has a null material."); + Assert.That(material.shader, Is.Not.Null, $"Provider material {material.name} has no shader."); + Assert.That(material.shader.isSupported, Is.True, + $"Provider material {material.name} uses unsupported shader {material.shader.name}."); + Assert.That(material.shader.name, Does.Not.Contain("FallbackError"), + $"Provider material {material.name} resolved to Unity's error shader."); + } + Assert.That(provider.TryGetRenderChannelLayer("map", out var mapLayer), Is.True); + var mapRenderers = new List(); + foreach (var renderer in root.GetComponentsInChildren(includeInactive: true)) + { + if (renderer.gameObject.layer == mapLayer) + mapRenderers.Add(renderer); + } + Assert.That(mapRenderers, Is.Not.Empty, "The live provider prefab has no renderer assigned to its advertised map channel."); + + const int captureWidth = 1280; + const int captureHeight = 720; + target = new RenderTexture(captureWidth, captureHeight, 24, RenderTextureFormat.ARGB32); camera.targetTexture = target; + var cameraRig = cameraObject.AddComponent(); + cameraRig.Host = host; + cameraRig.CameraTransform = camera.transform; + cameraRig.RenderPolicySource = provider; + Assert.That(cameraRig.ApplyRig(0f), Is.True); + var activePipelineAsset = GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset; + Assert.That(activePipelineAsset, Is.Not.Null, + "The generic client has no active URP asset for the advertised grading space."); + Assert.That(activePipelineAsset.colorGradingMode, Is.EqualTo(ColorGradingMode.HighDynamicRange), + "The advertised HDR-before-tonemap grading space did not reach the active URP pipeline."); + var reflection = provider.ResolveAsset(new EveUnityPlayableWorldAssetBinding( + runtime.ActiveWorld.ReflectionAssetRef, "", "provider-asset-ref"), typeof(Cubemap)) as Cubemap; + Assert.That(runtime.ActiveWorld.SkyboxAssetRef, Is.Empty, + "Aetheria's raymarch owns the frame; its reflection HDRI must not become a skybox-lighting authority."); + Assert.That(runtime.ActiveWorld.ReflectionAssetRef, Is.Not.Empty); + Assert.That(reflection, Is.Not.Null); + Assert.That(RenderSettings.ambientMode, Is.EqualTo(AmbientMode.Flat)); + Assert.That(RenderSettings.ambientLight.r, + Is.EqualTo(runtime.ActiveWorld.AmbientLightR * runtime.ActiveWorld.AmbientLightIntensity).Within(0.001f)); + Assert.That(RenderSettings.ambientLight.g, + Is.EqualTo(runtime.ActiveWorld.AmbientLightG * runtime.ActiveWorld.AmbientLightIntensity).Within(0.001f)); + Assert.That(RenderSettings.ambientLight.b, + Is.EqualTo(runtime.ActiveWorld.AmbientLightB * runtime.ActiveWorld.AmbientLightIntensity).Within(0.001f)); + Assert.That(RenderSettings.customReflectionTexture, Is.SameAs(reflection)); + var environmentPresentation = RenderSettings.ambientMode == AmbientMode.Flat && + RenderSettings.customReflectionTexture == reflection; + aimRenderer.RefreshNow(); + Assert.That(aimRenderer.ViewDotVisible, Is.True); + aimDotDistance = Vector3.Distance(playerMarker.transform.position, aimRenderer.ViewDotPosition); + Assert.That(aimDotDistance, Is.GreaterThanOrEqualTo(49.9f)); + Assert.That(camera.cullingMask & (1 << mapLayer), Is.Zero); + Assert.That(runtime.ActiveWorld.CameraRig, Is.EqualTo("perspective.entity-forward-follow.v1")); + Assert.That(runtime.ActiveWorld.CameraLookAt, Is.EqualTo("aim.convergence-point.v1")); + Assert.That(runtime.ActiveWorld.CameraDistance, Is.EqualTo(30f).Within(0.001f)); + Assert.That(camera.fieldOfView, Is.EqualTo(60f).Within(0.001f)); + Assert.That(runtime.ActiveWorld.CameraPositionDamping, Is.EqualTo(0f).Within(0.001f)); + var playerViewport = camera.WorldToViewportPoint(playerMarker.transform.position); + Assert.That(runtime.ActiveWorld.CameraTargetScreenX, Is.EqualTo(0.64f).Within(0.001f)); + Assert.That(runtime.ActiveWorld.CameraTargetScreenY, Is.EqualTo(0.19f).Within(0.001f)); + Assert.That(playerViewport.x, Is.EqualTo(runtime.ActiveWorld.CameraTargetScreenX).Within(0.01f)); + Assert.That(playerViewport.y, Is.EqualTo(runtime.ActiveWorld.CameraTargetScreenY).Within(0.01f)); + MeasureViewportCoverage(camera, playerRenderers, out var playerViewportWidth, out var playerViewportHeight); + var playerRendererFacts = playerRenderers + .Select(renderer => RendererFact.From(camera, renderer)) + .ToArray(); + var texturedPlayerMaterialCount = playerRenderers + .SelectMany(renderer => renderer.sharedMaterials) + .Where(material => material != null) + .Distinct() + .Count(HasAnyTexture); + var expectedTexturedPlayerMaterialCount = requireSessionGameplay ? 8 : 7; + Assert.That(texturedPlayerMaterialCount, Is.GreaterThanOrEqualTo(expectedTexturedPlayerMaterialCount), + "The provider bundle must retain its advertised native material textures."); + var directionalLightIntensity = UnityEngine.Object.FindObjectsOfType() + .Where(light => light != null && light.enabled && light.type == LightType.Directional) + .Sum(light => light.intensity); + Assert.That(directionalLightIntensity, + Is.EqualTo(runtime.ActiveWorld.KeyLightIntensity).Within(0.001f), + "The live witness contains directional lighting not owned by the advertised provider surface."); + + var mapCamera = mapCameraObject.AddComponent(); + mapCamera.cullingMask = 1 << mapLayer; + Assert.That(mapCamera.cullingMask, Is.EqualTo(1 << mapLayer)); + var captureFieldVolume = root.GetComponent(); + Assert.That(captureFieldVolume, Is.Not.Null, + "The generic client did not install its Fields volume lowerer before temporal settling."); + const int temporalSettlingFrames = 128; + var temporalSettlingElapsed = Stopwatch.StartNew(); + var initialCompositeCount = captureFieldVolume.CompositeCount; + var temporalSettlingDeadline = Time.realtimeSinceStartup + 20f; + while (captureFieldVolume.CompositeCount - initialCompositeCount < temporalSettlingFrames && + Time.realtimeSinceStartup < temporalSettlingDeadline) + { + camera.Render(); + yield return null; + } + Assert.That(captureFieldVolume.CompositeCount - initialCompositeCount, + Is.GreaterThanOrEqualTo(temporalSettlingFrames), + "The pilot capture ran before the advertised temporal reconstruction had settled."); + Debug.Log($"EveUnity witness phase temporal-settling-128-frames took {temporalSettlingElapsed.Elapsed.TotalMilliseconds:0.###}ms."); + var pilotCaptureElapsed = Stopwatch.StartNew(); yield return null; - camera.Render(); RenderTexture.active = target; - pixels = new Texture2D(640, 360, TextureFormat.RGB24, false); - pixels.ReadPixels(new Rect(0, 0, 640, 360), 0, 0); + pixels = new Texture2D(captureWidth, captureHeight, TextureFormat.RGB24, false); + pixels.ReadPixels(new Rect(0, 0, captureWidth, captureHeight), 0, 0); pixels.Apply(); + var pilotChangedPixels = CountChangedPixels(pixels, camera.backgroundColor); + MeasureLuminance(pixels, out var pilotAverageLuminance, out var pilotBrightPixelCount); + Assert.That(pilotChangedPixels, Is.GreaterThan(25), "The pilot camera rendered no provider-authored world pixels."); Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(capturePath))); File.WriteAllBytes(capturePath, pixels.EncodeToPNG()); Assert.That(new FileInfo(capturePath).Length, Is.GreaterThan(1024)); + WriteFieldLayerCaptures(root, host.Runtime.ActiveWorld, capturePath); + WriteFieldCloudCapture(root, capturePath); + MeasureRendererOutputs( + camera, + target, + pixels, + root.GetComponentsInChildren(includeInactive: true), + playerRenderers, + playerRendererFacts); + var playerMapRendererFacts = playerRendererFacts + .Where(fact => fact.layer == mapLayer) + .ToArray(); + Assert.That(playerMapRendererFacts, Is.Not.Empty, + "The provider-authored player prefab has no renderer on its advertised map channel."); + Assert.That(playerMapRendererFacts.Sum(fact => fact.renderedPixelCount), Is.Zero, + "A provider-prefab map-channel renderer leaked into the pilot camera."); + Debug.Log($"EveUnity witness phase pilot-capture-and-renderer-probes took {pilotCaptureElapsed.Elapsed.TotalMilliseconds:0.###}ms."); + + var mapCaptureElapsed = Stopwatch.StartNew(); + mapRenderers = mapRenderers.Where(renderer => renderer != null).ToList(); + Assert.That(mapRenderers, Is.Not.Empty, + "All advertised map-channel renderers were destroyed before map capture."); + var mapBounds = mapRenderers[0].bounds; + foreach (var renderer in mapRenderers) + mapBounds.Encapsulate(renderer.bounds); + mapCamera.clearFlags = CameraClearFlags.SolidColor; + mapCamera.backgroundColor = camera.backgroundColor; + mapCamera.orthographic = true; + mapCamera.orthographicSize = Mathf.Max(0.1f, mapBounds.extents.x, mapBounds.extents.z) * 1.25f; + mapCamera.nearClipPlane = 0.01f; + mapCamera.farClipPlane = Mathf.Max(10f, mapBounds.size.magnitude * 4f); + mapCamera.transform.position = mapBounds.center + Vector3.up * (mapCamera.farClipPlane * 0.25f); + mapCamera.transform.rotation = Quaternion.Euler(90f, 0f, 0f); + mapCamera.targetTexture = target; + var fieldParticlesForMap = root.GetComponent(); + var particleDrawsBeforeMap = fieldParticlesForMap?.DrawCount ?? 0; + mapCamera.Render(); + var particleMapCameraIsolated = fieldParticlesForMap != null && + fieldParticlesForMap.DrawCount == particleDrawsBeforeMap; + Assert.That(particleMapCameraIsolated, Is.True, + "The map camera executed the pilot-only field-particle pass."); + RenderTexture.active = target; + pixels.ReadPixels(new Rect(0, 0, captureWidth, captureHeight), 0, 0); + pixels.Apply(); + var mapChangedPixels = CountChangedPixels(pixels, mapCamera.backgroundColor); + Assert.That(mapChangedPixels, Is.GreaterThan(25), "The map-only camera rendered no provider-authored map glyph pixels."); + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(mapCapturePath))); + File.WriteAllBytes(mapCapturePath, pixels.EncodeToPNG()); + Assert.That(new FileInfo(mapCapturePath).Length, Is.GreaterThan(1024)); + Debug.Log($"EveUnity witness phase map-capture took {mapCaptureElapsed.Elapsed.TotalMilliseconds:0.###}ms."); + if (requireSessionGameplay) + { + var tractorReleaseVersion = runtime.ActiveVersion; + var tractorRelease = host.SubmitAdvertisedActionValueIntent( + playerId, + beamPresentation.ActivationActionId, + 0f); + var tractorReleaseDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < tractorReleaseDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != tractorRelease.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion)) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, tractorRelease.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, tractorReleaseVersion); + tractorReleaseReceipt = WitnessReceipt.From("beam-release", runtime.LastReceipt); + beamRenderer.RefreshNow(); + Assert.That(beamRenderer.TryGetPower(beamPresentation.Id, out tractorReleasedPower), Is.True); + } + var fieldVolume = root.GetComponent(); + Assert.That(fieldVolume, Is.Not.Null, "The generic client did not install its Fields volume lowerer."); + Assert.That(fieldVolume.PresentedFrameId, Is.GreaterThanOrEqualTo(0), + "The advertised Fields splat document never reached the generic volume lowerer."); + Assert.That(fieldVolume.PresentedLayerCount, Is.GreaterThan(0), + "The generic volume lowerer did not rasterize any advertised Fields layers."); + Assert.That(fieldVolume.CompositeCount, Is.GreaterThan(0), + "The generic volume lowerer never composited into the pilot camera."); + var fieldParticles = fieldParticlesForMap; + Assert.That(fieldParticles, Is.Not.Null, + "The generic client did not install its field-particle lowerer."); + var particleProjection = host.ActiveWorld.FieldParticles.Single(); + var particleComputeBinding = new EveUnityPlayableWorldAssetBinding( + particleProjection.ComputeProgramAssetRef, "", "provider-asset-ref"); + var particleMaterialBinding = new EveUnityPlayableWorldAssetBinding( + particleProjection.MaterialAssetRef, "", "provider-asset-ref"); + var particleCompute = host.NativeAssetProvider.ResolveAsset( + particleComputeBinding, typeof(ComputeShader)) as ComputeShader; + var particleMaterial = host.NativeAssetProvider.ResolveAsset( + particleMaterialBinding, typeof(Material)) as Material; + Assert.That(particleCompute, Is.Not.Null, + $"Provider asset '{particleProjection.ComputeProgramAssetRef}' did not lower as a ComputeShader."); + Assert.That(particleMaterial, Is.Not.Null, + $"Provider asset '{particleProjection.MaterialAssetRef}' did not lower as a Material."); + var particleMetadata = host.NativeAssetProvider as IEveUnityNativeAssetMetadataProvider; + Assert.That(particleMetadata, Is.Not.Null); + Assert.That(particleMetadata.TryResolveAssetMetadata( + particleComputeBinding, out var particleComputeMetadata), Is.True); + Assert.That(particleMetadata.TryResolveAssetMetadata( + particleMaterialBinding, out var particleMaterialMetadata), Is.True); + Assert.That(EveUnityFieldsParticleRenderer.TryValidateProgramMetadata( + particleComputeMetadata, particleMaterialMetadata, out var particleAbiError), Is.True, + particleAbiError); + Assert.That(fieldParticles.ProgramReady, Is.True, + "The generic field-particle lowerer rejected the advertised provider ABI or assets."); + var liveParticleMaterial = typeof(EveUnityFieldsParticleRenderer) + .GetField("_material", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + ?.GetValue(fieldParticles) as Material; + Assert.That(liveParticleMaterial, Is.Not.Null); + var ditherTextureProperty = particleMaterialMetadata["unity.particles.texturePort.dither"]; + var ditherCoordinatesProperty = particleMaterialMetadata["unity.particles.vectorPort.ditherCoordinates"]; + var renderFrameProperty = particleMaterialMetadata["unity.particles.intPort.frameIndex"]; + var liveDitherTexture = liveParticleMaterial.GetTexture(ditherTextureProperty); + Assert.That(liveDitherTexture, Is.Not.Null, + "The generic lowerer did not bind the advertised provider dither texture."); + Assert.That(liveParticleMaterial.GetVector(ditherCoordinatesProperty).x, + Is.EqualTo((float)camera.pixelWidth / liveDitherTexture.width).Within(0.0001f)); + Assert.That(liveParticleMaterial.GetVector(ditherCoordinatesProperty).y, + Is.EqualTo((float)camera.pixelHeight / liveDitherTexture.height).Within(0.0001f)); + Assert.That(liveParticleMaterial.GetInt(renderFrameProperty), Is.GreaterThan(0), + "The generic lowerer did not advance the advertised render-frame port."); + Assert.That(fieldParticles.PresentedFrameId, Is.GreaterThanOrEqualTo(0)); + Assert.That(fieldParticles.ParticleCount, Is.EqualTo(65536)); + Assert.That(fieldParticles.DispatchCount, Is.GreaterThan(0)); + Assert.That(fieldParticles.DrawCount, Is.GreaterThan(0)); + Assert.That(fieldVolume.LastGridCenter, Is.EqualTo(fieldParticles.LastGridCenter), + "Fog and stateless particles were lowered against different world-grid frames."); + var pilotCameraData = camera.GetUniversalAdditionalCameraData(); + Assert.That(pilotCameraData.antialiasing, Is.EqualTo(AntialiasingMode.TemporalAntiAliasing), + "The advertised temporal reconstruction contract did not reach the pilot camera."); + Assert.That(pilotCameraData.taaSettings.baseBlendFactor, Is.EqualTo(0.99f).Within(0.0001f)); + Assert.That(pilotCameraData.taaSettings.jitterScale, Is.EqualTo(0.1f).Within(0.0001f)); + var fieldDocument = typeof(EveUnityFieldsVolumeRenderer) + .GetField("_document", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + ?.GetValue(fieldVolume); + Assert.That(fieldDocument, Is.Not.Null, "The volume lowerer did not retain its presented Fields document."); + var fieldViewport = fieldDocument.GetType().GetProperty("Viewport")?.GetValue(fieldDocument); + var fieldSplats = fieldDocument.GetType().GetProperty("Splats")?.GetValue(fieldDocument); + Assert.That(fieldViewport, Is.Not.Null); + Assert.That(fieldSplats, Is.Not.Null); WriteWitnessFacts( + witnessProfile, + gameplayScenario, + provider.CurrentAssetBodyTransportKind?.ToString() ?? "", initialVersion, runtime.ActiveVersion, movementDistance, + playerRenderers.Length, + pilotChangedPixels, + mapRenderers.Count, + mapChangedPixels, + (camera.cullingMask & (1 << mapLayer)) == 0, + (mapCamera.cullingMask & (1 << mapLayer)) != 0, + environmentPresentation, + fieldVolume.PresentedFrameId, + fieldVolume.PresentedLayerCount, + fieldVolume.CompositeCount, + fieldParticles.PresentedFrameId, + fieldParticles.ParticleCount, + fieldParticles.DispatchCount, + fieldParticles.DrawCount, + particleMapCameraIsolated, + fieldParticles.LastGridCenter, movementReceipt, + lookReceipt, focusReceipt, - actionReceipt); + tractorReceipt, + tractorReleaseReceipt, + actionReceipt, + tradePurchaseReceipt, + tradeSaleReceipt, + tradeRoundTrip, + tradeItemKey, + observedShot, + shieldRatioBefore, + observedCombat?.ShieldRatio ?? shieldRatioBefore, + hullRatioBefore, + observedCombat?.HullRatio ?? hullRatioBefore, + (float)(observedShot?.LockQuality ?? 0), + aimDotDistance, + playerViewportWidth, + playerViewportHeight, + texturedPlayerMaterialCount, + directionalLightIntensity, + pilotAverageLuminance, + pilotBrightPixelCount, + cockpitProgressCount, + tractorBeamPower, + tractorReleasedPower, + tractorBeamParticleSystemCount, + initialPickupEntityCount, + finalPickupEntityCount, + pickupCollectionEventCount, + pickupRejectionEventCount, + destructionPickupObserved, + pickupCollection, + pickupRejection, + camera.transform.position, + camera.transform.forward, + playerMarker.transform.position, + aimRenderer.ViewDotPosition, + Convert.ToSingle(fieldViewport.GetType().GetProperty("MinX")?.GetValue(fieldViewport)), + Convert.ToSingle(fieldViewport.GetType().GetProperty("MinY")?.GetValue(fieldViewport)), + Convert.ToSingle(fieldViewport.GetType().GetProperty("MaxX")?.GetValue(fieldViewport)), + Convert.ToSingle(fieldViewport.GetType().GetProperty("MaxY")?.GetValue(fieldViewport)), + Convert.ToInt32(fieldSplats.GetType().GetProperty("Count")?.GetValue(fieldSplats)), + playerRendererFacts, + root.GetComponentsInChildren(includeInactive: true) + .Select(marker => PresentedEntityFact.From(camera, marker)) + .ToArray()); } finally { RenderTexture.active = null; var camera = cameraObject.GetComponent(); if (camera != null) camera.targetTexture = null; + var mapCamera = mapCameraObject.GetComponent(); + if (mapCamera != null) mapCamera.targetTexture = null; if (target != null) UnityEngine.Object.DestroyImmediate(target); if (pixels != null) UnityEngine.Object.DestroyImmediate(pixels); - runtime?.Dispose(); - UnityEngine.Object.DestroyImmediate(lightObject); + host?.Disconnect(); + UnityEngine.Object.DestroyImmediate(mapCameraObject); UnityEngine.Object.DestroyImmediate(cameraObject); UnityEngine.Object.DestroyImmediate(root); } } + private static async Task ReceiveAdvertisedRealtimeFrameAsync( + string rendezvousEndpoint, + string verseId, + string authorityRuntimeId) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using var client = new CultMeshClient(new CultMeshClientOptions + { + RendezvousEndpoints = new[] { rendezvousEndpoint }, + RealtimeConnectors = new ICultMeshRealtimeTransportConnector[] + { + new CultMeshNativeQuicRealtimeTransportConnector() + } + }); + using var session = await client.ConnectRealtimeAsync( + new CultMeshSessionTarget(verseId, authorityRuntimeId), + timeout.Token); + return await session.ReceiveAsync(timeout.Token); + } + private static void WriteWitnessFacts( + string witnessProfile, + string gameplayScenario, + string assetBodyTransport, long initialVersion, long finalVersion, float movementDistance, + int playerRendererCount, + int pilotChangedPixels, + int mapChannelRendererCount, + int mapChangedPixels, + bool pilotCameraExcludesMapChannel, + bool mapCameraIncludesMapChannel, + bool environmentPresentation, + long fieldVolumeFrameId, + int fieldVolumeLayerCount, + long fieldVolumeCompositeCount, + long fieldParticleFrameId, + int fieldParticleCount, + int fieldParticleDispatchCount, + int fieldParticleDrawCount, + bool fieldParticleMapCameraIsolated, + Vector2 fieldParticleGridCenter, WitnessReceipt movement, + WitnessReceipt look, WitnessReceipt targeting, - WitnessReceipt action) + WitnessReceipt tractor, + WitnessReceipt tractorRelease, + WitnessReceipt action, + WitnessReceipt tradePurchase, + WitnessReceipt tradeSale, + bool tradeRoundTrip, + string tradeItemKey, + EveUnityShotReceipt shot, + float shieldRatioBefore, + float shieldRatioAfter, + float hullRatioBefore, + float hullRatioAfter, + float lockProgress, + float aimDotDistance, + float playerViewportWidth, + float playerViewportHeight, + int texturedPlayerMaterialCount, + float directionalLightIntensity, + float pilotAverageLuminance, + int pilotBrightPixelCount, + int cockpitProgressCount, + float tractorBeamPower, + float tractorReleasedPower, + int tractorBeamParticleSystemCount, + int initialPickupEntityCount, + int finalPickupEntityCount, + int pickupCollectionEventCount, + int pickupRejectionEventCount, + bool destructionPickupObserved, + EveUnityFeedbackEvent pickupCollection, + EveUnityFeedbackEvent pickupRejection, + Vector3 cameraPosition, + Vector3 cameraForward, + Vector3 playerPosition, + Vector3 aimPosition, + float fieldViewportMinX, + float fieldViewportMinY, + float fieldViewportMaxX, + float fieldViewportMaxY, + int fieldSplatCount, + RendererFact[] playerRendererFacts, + PresentedEntityFact[] presentedEntityFacts) { var path = Environment.GetEnvironmentVariable("EVEUNITY_WITNESS_FACTS_PATH"); if (string.IsNullOrWhiteSpace(path)) return; + var pickupEvent = pickupCollection ?? pickupRejection; var document = new WitnessFacts { + witnessProfile = witnessProfile, + gameplayScenario = gameplayScenario, providerAdvertisement = true, providerAssets = true, + assetBodyTransport = assetBodyTransport, + environmentPresentation = environmentPresentation, + ambientProbeEnergy = AmbientProbeEnergy(), + ambientMode = RenderSettings.ambientMode.ToString(), + ambientLight = RenderSettings.ambientLight, + reflectionTexture = RenderSettings.customReflectionTexture != null + ? RenderSettings.customReflectionTexture.name + : "", + fieldVolumeFrameId = fieldVolumeFrameId, + fieldVolumeLayerCount = fieldVolumeLayerCount, + fieldVolumeCompositeCount = fieldVolumeCompositeCount, + fieldParticleFrameId = fieldParticleFrameId, + fieldParticleCount = fieldParticleCount, + fieldParticleDispatchCount = fieldParticleDispatchCount, + fieldParticleDrawCount = fieldParticleDrawCount, + fieldParticleMapCameraIsolated = fieldParticleMapCameraIsolated, + fieldParticleGridCenter = fieldParticleGridCenter, movement = movement != null, + aimPresentation = look != null && aimDotDistance >= 49.9f, targeting = targeting != null, + beamPresentation = tractor != null && tractorBeamPower > 0.01f && tractorBeamParticleSystemCount > 0, + beamRelease = tractorRelease != null, action = action != null, + tradeRoundTrip = tradeRoundTrip && tradePurchase != null && tradeSale != null, + tradeItemKey = tradeItemKey ?? "", + combatPresentation = shot != null, + shotId = shot?.ShotId ?? "", + shotHit = shot?.Hit ?? false, + impactKind = shot?.ImpactKind ?? "", + appliedDamage = (float)(shot?.AppliedDamage ?? 0), + shieldAbsorbedDamage = (float)(shot?.ShieldAbsorbedDamage ?? 0), + shieldRatioBefore = shieldRatioBefore, + shieldRatioAfter = shieldRatioAfter, + hullRatioBefore = hullRatioBefore, + hullRatioAfter = hullRatioAfter, + lockProgress = lockProgress, initialVersion = initialVersion, finalVersion = finalVersion, movementDistance = movementDistance, - receipts = new[] { movement, targeting, action } + aimDotDistance = aimDotDistance, + playerRendererCount = playerRendererCount, + playerViewportWidth = playerViewportWidth, + playerViewportHeight = playerViewportHeight, + texturedPlayerMaterialCount = texturedPlayerMaterialCount, + directionalLightIntensity = directionalLightIntensity, + pilotChangedPixels = pilotChangedPixels, + pilotAverageLuminance = pilotAverageLuminance, + pilotBrightPixelCount = pilotBrightPixelCount, + cockpitProgressCount = cockpitProgressCount, + tractorBeamPower = tractorBeamPower, + tractorReleasedPower = tractorReleasedPower, + tractorBeamParticleSystemCount = tractorBeamParticleSystemCount, + pickupCollection = pickupCollection != null && pickupCollectionEventCount == 1 && + pickupCollection.CargoQuantityAfter - pickupCollection.CargoQuantityBefore == pickupCollection.ScalarValue, + pickupRejection = pickupRejection != null && pickupRejectionEventCount >= 1 && + pickupCollectionEventCount == 0 && + string.Equals(pickupRejection.Reason, "cargo-capacity", StringComparison.Ordinal) && + pickupRejection.CargoQuantityAfter == pickupRejection.CargoQuantityBefore, + destructionLoot = pickupEvent != null && !string.IsNullOrWhiteSpace(pickupEvent.EventId) && + !string.IsNullOrWhiteSpace(pickupEvent.ItemKey) && pickupEvent.ScalarValue > 0, + initialPickupEntityCount = initialPickupEntityCount, + finalPickupEntityCount = finalPickupEntityCount, + pickupCollectionEventCount = pickupCollectionEventCount, + pickupRejectionEventCount = pickupRejectionEventCount, + pickupRejectionEventId = pickupRejection?.EventId ?? "", + pickupRejectionReason = pickupRejection?.Reason ?? "", + cargoQuantityBeforeRejection = (float)(pickupRejection?.CargoQuantityBefore ?? 0), + cargoQuantityAfterRejection = (float)(pickupRejection?.CargoQuantityAfter ?? 0), + pickupEventId = pickupEvent?.EventId ?? "", + pickupItemKey = pickupEvent?.ItemKey ?? "", + pickupQuantity = (float)(pickupEvent?.ScalarValue ?? 0), + cargoQuantityBeforePickup = (float)(pickupEvent?.CargoQuantityBefore ?? 0), + cargoQuantityAfterPickup = (float)(pickupEvent?.CargoQuantityAfter ?? 0), + cameraPosition = cameraPosition, + cameraForward = cameraForward, + playerPosition = playerPosition, + aimPosition = aimPosition, + fieldViewportMinX = fieldViewportMinX, + fieldViewportMinY = fieldViewportMinY, + fieldViewportMaxX = fieldViewportMaxX, + fieldViewportMaxY = fieldViewportMaxY, + fieldSplatCount = fieldSplatCount, + playerRenderers = playerRendererFacts, + presentedEntities = presentedEntityFacts, + mapChannelRendererCount = mapChannelRendererCount, + mapChangedPixels = mapChangedPixels, + pilotCameraExcludesMapChannel = pilotCameraExcludesMapChannel, + mapCameraIncludesMapChannel = mapCameraIncludesMapChannel, + receipts = new[] { movement, look, targeting, tractor, tractorRelease, action, tradePurchase, tradeSale } }; Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))); File.WriteAllText(path, JsonUtility.ToJson(document, true)); @@ -387,23 +1394,116 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests [Serializable] private sealed class WitnessFacts { + public string witnessProfile; + public string gameplayScenario; public bool providerAdvertisement; public bool providerAssets; + public string assetBodyTransport; + public bool environmentPresentation; + public float ambientProbeEnergy; + public string ambientMode; + public Color ambientLight; + public string reflectionTexture; + public long fieldVolumeFrameId; + public int fieldVolumeLayerCount; + public long fieldVolumeCompositeCount; + public long fieldParticleFrameId; + public int fieldParticleCount; + public int fieldParticleDispatchCount; + public int fieldParticleDrawCount; + public bool fieldParticleMapCameraIsolated; + public Vector2 fieldParticleGridCenter; public bool movement; + public bool aimPresentation; public bool targeting; + public bool beamPresentation; + public bool beamRelease; public bool action; + public bool tradeRoundTrip; + public string tradeItemKey; + public bool combatPresentation; + public string shotId; + public bool shotHit; + public string impactKind; + public float appliedDamage; + public float shieldAbsorbedDamage; + public float shieldRatioBefore; + public float shieldRatioAfter; + public float hullRatioBefore; + public float hullRatioAfter; + public float lockProgress; public long initialVersion; public long finalVersion; public float movementDistance; + public float aimDotDistance; + public int playerRendererCount; + public float playerViewportWidth; + public float playerViewportHeight; + public int texturedPlayerMaterialCount; + public float directionalLightIntensity; + public int pilotChangedPixels; + public float pilotAverageLuminance; + public int pilotBrightPixelCount; + public int cockpitProgressCount; + public float tractorBeamPower; + public float tractorReleasedPower; + public int tractorBeamParticleSystemCount; + public bool pickupCollection; + public bool pickupRejection; + public bool destructionLoot; + public int initialPickupEntityCount; + public int finalPickupEntityCount; + public int pickupCollectionEventCount; + public int pickupRejectionEventCount; + public string pickupRejectionEventId; + public string pickupRejectionReason; + public float cargoQuantityBeforeRejection; + public float cargoQuantityAfterRejection; + public string pickupEventId; + public string pickupItemKey; + public float pickupQuantity; + public float cargoQuantityBeforePickup; + public float cargoQuantityAfterPickup; + public Vector3 cameraPosition; + public Vector3 cameraForward; + public Vector3 playerPosition; + public Vector3 aimPosition; + public float fieldViewportMinX; + public float fieldViewportMinY; + public float fieldViewportMaxX; + public float fieldViewportMaxY; + public int fieldSplatCount; + public RendererFact[] playerRenderers; + public PresentedEntityFact[] presentedEntities; + public int mapChannelRendererCount; + public int mapChangedPixels; + public bool pilotCameraExcludesMapChannel; + public bool mapCameraIncludesMapChannel; public WitnessReceipt[] receipts; } + private static float AmbientProbeEnergy() + { + var probe = RenderSettings.ambientProbe; + var energy = 0f; + for (var channel = 0; channel < 3; channel++) + for (var coefficient = 0; coefficient < 9; coefficient++) + energy += Mathf.Abs(probe[channel, coefficient]); + return energy; + } + [Serializable] private sealed class WitnessReceipt { public string commandKind; public string commandId; public string state; + public string receiptId; + public string ownerRepo; + public string authority; + public string providerId; + public string surfaceId; + public bool isProviderOwned; public long sourceVersion; public static WitnessReceipt From(string commandKind, EveUnitySceneCommandReceipt receipt) @@ -413,11 +1513,137 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests commandKind = commandKind, commandId = receipt.CommandId, state = receipt.State, + receiptId = receipt.ReceiptId, + ownerRepo = receipt.OwnerRepo, + authority = receipt.Authority, + providerId = receipt.ProviderId, + surfaceId = receipt.SurfaceId, + isProviderOwned = receipt.IsProviderOwned, sourceVersion = receipt.SourceVersion }; } } + [Serializable] + private sealed class RendererFact + { + public string name; + public string rendererType; + public bool enabled; + public bool activeInHierarchy; + public int layer; + public string boundsSize; + public float viewportWidth; + public float viewportHeight; + public int renderedPixelCount; + public float renderedAverageLuminance; + public int renderedBrightPixelCount; + public MaterialFact[] materials; + + public static RendererFact From(Camera camera, Renderer renderer) + { + MeasureViewportCoverage(camera, new[] { renderer }, out var width, out var height); + return new RendererFact + { + name = renderer.name, + rendererType = renderer.GetType().Name, + enabled = renderer.enabled, + activeInHierarchy = renderer.gameObject.activeInHierarchy, + layer = renderer.gameObject.layer, + boundsSize = FormatVector(renderer.bounds.size), + viewportWidth = width, + viewportHeight = height, + materials = renderer.sharedMaterials + .Where(material => material != null) + .Select(MaterialFact.From) + .ToArray() + }; + } + } + + [Serializable] + private sealed class PresentedEntityFact + { + public string entityId; + public string entityKind; + public string label; + public string assetRef; + public string worldPosition; + public string worldScale; + public int rendererCount; + public int enabledRendererCount; + public bool intersectsPilotFrustum; + public float viewportWidth; + public float viewportHeight; + + public static PresentedEntityFact From(Camera camera, EveUnityPlayableWorldEntityMarker marker) + { + var renderers = marker.GetComponentsInChildren(includeInactive: true); + MeasureViewportCoverage(camera, renderers, out var width, out var height); + var planes = GeometryUtility.CalculateFrustumPlanes(camera); + return new PresentedEntityFact + { + entityId = marker.EntityId, + entityKind = marker.EntityKind, + label = marker.Label, + assetRef = marker.AssetRef, + worldPosition = FormatVector(marker.transform.position), + worldScale = FormatVector(marker.transform.lossyScale), + rendererCount = renderers.Length, + enabledRendererCount = renderers.Count(renderer => renderer.enabled && renderer.gameObject.activeInHierarchy), + intersectsPilotFrustum = renderers.Any(renderer => + renderer.enabled && renderer.gameObject.activeInHierarchy && + GeometryUtility.TestPlanesAABB(planes, renderer.bounds)), + viewportWidth = width, + viewportHeight = height + }; + } + } + + [Serializable] + private sealed class MaterialFact + { + public string name; + public string shader; + public bool shaderSupported; + public string baseColor; + public float surface; + public int textureCount; + public int renderQueue; + + public static MaterialFact From(Material material) => new MaterialFact + { + name = material.name, + shader = material.shader?.name ?? "", + shaderSupported = material.shader != null && material.shader.isSupported, + baseColor = material.HasProperty("_BaseColor") + ? FormatColor(material.GetColor("_BaseColor")) + : "", + surface = material.HasProperty("_Surface") ? material.GetFloat("_Surface") : -1f, + textureCount = material.GetTexturePropertyNames().Count(name => material.GetTexture(name) != null), + renderQueue = material.renderQueue + }; + } + + private static void AssertReconciledProviderReceipt( + EveUnitySceneCommandReceipt receipt, + string commandId, + string providerId, + string surfaceId, + long minimumSourceVersion) + { + Assert.That(receipt, Is.Not.Null); + Assert.That(receipt.CommandId, Is.EqualTo(commandId)); + Assert.That(receipt.State, Is.EqualTo("reconciled"), receipt.Message); + Assert.That(receipt.IsProviderOwned, Is.True); + Assert.That(receipt.ProviderId, Is.EqualTo(providerId)); + Assert.That(receipt.SurfaceId, Is.EqualTo(surfaceId)); + Assert.That(receipt.ReceiptId, Is.Not.Empty); + Assert.That(receipt.OwnerRepo, Is.Not.Empty); + Assert.That(receipt.Authority, Is.Not.Empty); + Assert.That(receipt.SourceVersion, Is.GreaterThan(minimumSourceVersion)); + } + private static EveUnityPlayableWorldEntityMarker FindMarker(GameObject root, string entityId) { foreach (var marker in root.GetComponentsInChildren()) @@ -428,6 +1654,270 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests throw new InvalidOperationException($"Projected world has no entity marker '{entityId}'."); } + private static bool HasAnyTexture(Material material) + { + foreach (var propertyName in material.GetTexturePropertyNames()) + { + if (material.GetTexture(propertyName) != null) + return true; + } + return false; + } + + private static string FormatVector(Vector3 value) => + $"{value.x:R},{value.y:R},{value.z:R}"; + + private static string FormatColor(Color value) => + $"{value.r:R},{value.g:R},{value.b:R},{value.a:R}"; + + private static void MeasureViewportCoverage( + Camera camera, + IReadOnlyList renderers, + out float width, + out float height) + { + var min = new Vector2(float.PositiveInfinity, float.PositiveInfinity); + var max = new Vector2(float.NegativeInfinity, float.NegativeInfinity); + foreach (var renderer in renderers) + { + var bounds = renderer.bounds; + for (var x = -1; x <= 1; x += 2) + for (var y = -1; y <= 1; y += 2) + for (var z = -1; z <= 1; z += 2) + { + var point = bounds.center + Vector3.Scale(bounds.extents, new Vector3(x, y, z)); + var viewport = camera.WorldToViewportPoint(point); + if (viewport.z <= 0f) continue; + min = Vector2.Min(min, viewport); + max = Vector2.Max(max, viewport); + } + } + width = float.IsInfinity(min.x) ? 0f : Mathf.Max(0f, max.x - min.x); + height = float.IsInfinity(min.y) ? 0f : Mathf.Max(0f, max.y - min.y); + } + + private static void MeasureLuminance( + Texture2D texture, + out float average, + out int brightPixelCount) + { + double total = 0; + brightPixelCount = 0; + var pixels = texture.GetPixels32(); + foreach (var pixel in pixels) + { + var luminance = (0.2126 * pixel.r + 0.7152 * pixel.g + 0.0722 * pixel.b) / 255.0; + total += luminance; + if (luminance >= 0.2) + brightPixelCount++; + } + average = pixels.Length == 0 ? 0f : (float)(total / pixels.Length); + } + + private static void MeasureRendererOutputs( + Camera camera, + RenderTexture target, + Texture2D pixels, + IReadOnlyList allRenderers, + IReadOnlyList measuredRenderers, + IReadOnlyList facts) + { + var enabled = allRenderers.Select(renderer => renderer.enabled).ToArray(); + var clearFlags = camera.clearFlags; + var background = camera.backgroundColor; + try + { + foreach (var renderer in allRenderers) + renderer.enabled = false; + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = Color.black; + camera.Render(); + RenderTexture.active = target; + pixels.ReadPixels(new Rect(0, 0, target.width, target.height), 0, 0); + pixels.Apply(); + var baseline = pixels.GetPixels(); + for (var index = 0; index < measuredRenderers.Count; index++) + { + var fact = facts[index]; + var rendererLayer = measuredRenderers[index].gameObject.layer; + if (rendererLayer >= 0 && rendererLayer < 32 && + (camera.cullingMask & (1 << rendererLayer)) == 0) + { + fact.renderedPixelCount = 0; + fact.renderedAverageLuminance = 0; + fact.renderedBrightPixelCount = 0; + continue; + } + measuredRenderers[index].enabled = true; + camera.Render(); + RenderTexture.active = target; + pixels.ReadPixels(new Rect(0, 0, target.width, target.height), 0, 0); + pixels.Apply(); + MeasureDifference( + pixels.GetPixels(), + baseline, + out fact.renderedPixelCount, + out fact.renderedAverageLuminance, + out fact.renderedBrightPixelCount); + measuredRenderers[index].enabled = false; + } + } + finally + { + for (var index = 0; index < allRenderers.Count; index++) + allRenderers[index].enabled = enabled[index]; + camera.clearFlags = clearFlags; + camera.backgroundColor = background; + } + } + + private static void MeasureDifference( + IReadOnlyList actual, + IReadOnlyList baseline, + out int changedPixelCount, + out float averageLuminance, + out int brightPixelCount) + { + changedPixelCount = 0; + brightPixelCount = 0; + double total = 0; + var count = Math.Min(actual.Count, baseline.Count); + for (var index = 0; index < count; index++) + { + var delta = new Color( + Mathf.Abs(actual[index].r - baseline[index].r), + Mathf.Abs(actual[index].g - baseline[index].g), + Mathf.Abs(actual[index].b - baseline[index].b)); + var magnitude = delta.r + delta.g + delta.b; + if (magnitude > 0.08f) changedPixelCount++; + var luminance = delta.r * 0.2126f + delta.g * 0.7152f + delta.b * 0.0722f; + total += luminance; + if (luminance >= 0.2f) brightPixelCount++; + } + averageLuminance = count == 0 ? 0f : (float)(total / count); + } + + private static int CountChangedPixels(Texture2D texture, Color background) + { + var changed = 0; + foreach (var pixel in texture.GetPixels()) + { + if (Mathf.Abs(pixel.r - background.r) + + Mathf.Abs(pixel.g - background.g) + + Mathf.Abs(pixel.b - background.b) > 0.08f) + changed++; + } + return changed; + } + + private static void WriteFieldLayerCaptures(GameObject root, EveUnityPlayableWorldProjection world, string capturePath) + { + var renderer = root.GetComponent(); + if (renderer == null || world == null || string.IsNullOrWhiteSpace(capturePath)) return; + var directory = Path.GetDirectoryName(Path.GetFullPath(capturePath)); + foreach (var field in world.FieldVolumes) + { + if (!field.Props.TryGetValue("layerBindings", out var bindings)) continue; + foreach (var pair in (bindings ?? "").Split(';')) + { + var separator = pair.IndexOf('='); + if (separator <= 0) continue; + var layerKey = pair.Substring(0, separator).Trim(); + if (!renderer.TryGetTexture(layerKey, out var texture) || texture == null) continue; + var previous = RenderTexture.active; + RenderTexture.active = texture; + var copy = new Texture2D(texture.width, texture.height, TextureFormat.RGBA32, false, true); + copy.ReadPixels(new Rect(0, 0, texture.width, texture.height), 0, 0); + copy.Apply(); + RenderTexture.active = previous; + File.WriteAllBytes(Path.Combine(directory, $"field-{layerKey}.png"), copy.EncodeToPNG()); + UnityEngine.Object.DestroyImmediate(copy); + + RenderTexture.active = texture; + var floatCopy = new Texture2D(texture.width, texture.height, TextureFormat.RGBAFloat, false, true); + floatCopy.ReadPixels(new Rect(0, 0, texture.width, texture.height), 0, 0); + floatCopy.Apply(); + RenderTexture.active = previous; + File.WriteAllBytes( + Path.Combine(directory, $"field-{layerKey}.exr"), + floatCopy.EncodeToEXR(Texture2D.EXRFlags.OutputAsFloat)); + UnityEngine.Object.DestroyImmediate(floatCopy); + } + } + } + + private static void WriteFieldCloudCapture(GameObject root, string capturePath) + { + var renderer = root.GetComponent(); + if (renderer == null || string.IsNullOrWhiteSpace(capturePath)) return; + var raymarchField = typeof(EveUnityFieldsVolumeRenderer).GetField( + "_raymarchTexture", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var historyTexturesField = typeof(EveUnityFieldsVolumeRenderer).GetField( + "_historyTextures", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var historyIndexField = typeof(EveUnityFieldsVolumeRenderer).GetField( + "_historyTextureIndex", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var materialField = typeof(EveUnityFieldsVolumeRenderer).GetField( + "_material", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var metadataField = typeof(EveUnityFieldsVolumeRenderer).GetField( + "_programMetadata", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var raymarchTexture = raymarchField?.GetValue(renderer) as RenderTexture; + var historyTextures = historyTexturesField?.GetValue(renderer) as RenderTexture[]; + var historyIndex = historyIndexField?.GetValue(renderer) is int value ? value : -1; + var previous = RenderTexture.active; + var directory = Path.GetDirectoryName(Path.GetFullPath(capturePath)); + WriteFloatTexture(raymarchTexture, Path.Combine(directory, "field-volume-raymarch.exr")); + var historyTexture = historyIndex >= 0 && historyTextures != null && historyIndex < historyTextures.Length + ? historyTextures[historyIndex] + : null; + WriteFloatTexture(historyTexture, Path.Combine(directory, "field-volume-history.exr")); + WriteFloatTexture(historyTexture ?? raymarchTexture, Path.Combine(directory, "field-volume-cloud.exr")); + WriteVolumeMaterialFacts( + materialField?.GetValue(renderer) as Material, + metadataField?.GetValue(renderer) as IReadOnlyDictionary, + Path.Combine(directory, "field-volume-material.txt")); + RenderTexture.active = previous; + } + + private static void WriteVolumeMaterialFacts( + Material material, + IReadOnlyDictionary metadata, + string path) + { + if (material == null || metadata == null) return; + var facts = new List(); + foreach (var binding in metadata.OrderBy(value => value.Key, StringComparer.Ordinal)) + { + if (binding.Key.Contains(".floatPort.", StringComparison.Ordinal)) + facts.Add($"{binding.Key}={binding.Value}:{material.GetFloat(binding.Value):R}"); + else if (binding.Key.Contains(".vectorPort.", StringComparison.Ordinal)) + facts.Add($"{binding.Key}={binding.Value}:{material.GetVector(binding.Value)}"); + else if (binding.Key.Contains(".texturePort.", StringComparison.Ordinal)) + { + var texture = material.HasProperty(binding.Value) ? material.GetTexture(binding.Value) : null; + facts.Add($"{binding.Key}={binding.Value}:{texture?.name ?? ""}:{texture?.width ?? 0}x{texture?.height ?? 0}"); + } + } + File.WriteAllLines(path, facts); + } + + private static void WriteFloatTexture(RenderTexture texture, string path) + { + if (texture == null || string.IsNullOrWhiteSpace(path)) return; + var previous = RenderTexture.active; + RenderTexture.active = texture; + var copy = new Texture2D(texture.width, texture.height, TextureFormat.RGBAFloat, false, true); + copy.ReadPixels(new Rect(0, 0, texture.width, texture.height), 0, 0); + copy.Apply(); + RenderTexture.active = previous; + File.WriteAllBytes(path, copy.EncodeToEXR(Texture2D.EXRFlags.OutputAsFloat)); + UnityEngine.Object.DestroyImmediate(copy); + } + private static EveUnitySceneProviderSurfaceDocument WorldDocument() { var entities = new[] diff --git a/TestProject/Packages/manifest.json b/TestProject/Packages/manifest.json index b08495a..157057e 100644 --- a/TestProject/Packages/manifest.json +++ b/TestProject/Packages/manifest.json @@ -5,9 +5,9 @@ "com.unity.modules.imageconversion": "1.0.0", "com.unity.modules.physics": "1.0.0", "com.unity.modules.uielements": "1.0.0", - "org.gamecult.cultlib": "file:../../../CultLib/artifacts/unity/org.gamecult.cultlib", + "org.gamecult.cultlib": "file:../../artifacts/test-dependencies/org.gamecult.cultlib", "org.gamecult.eve.plugin-fields": "file:../../../EvePlugins/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields", - "org.gamecult.eve.surface": "file:../../packages/org.gamecult.eve.surface", + "org.gamecult.eve.surface": "file:../../../Eve/packages/org.gamecult.eve.surface", "org.gamecult.eve.unity-scene": "file:../../packages/org.gamecult.eve.unity-scene", "org.gamecult.eve.unity-uitoolkit": "file:../../packages/org.gamecult.eve.unity-uitoolkit" }, diff --git a/TestProject/Packages/packages-lock.json b/TestProject/Packages/packages-lock.json index 6b032e8..d631673 100644 --- a/TestProject/Packages/packages-lock.json +++ b/TestProject/Packages/packages-lock.json @@ -1,11 +1,94 @@ { "dependencies": { + "com.unity.burst": { + "version": "1.8.29", + "depth": 3, + "source": "registry", + "dependencies": { + "com.unity.mathematics": "1.2.1", + "com.unity.modules.jsonserialize": "1.0.0" + }, + "url": "https://packages.unity.com" + }, + "com.unity.collections": { + "version": "6.4.0", + "depth": 3, + "source": "builtin", + "dependencies": { + "com.unity.burst": "1.8.23", + "com.unity.mathematics": "1.3.2", + "com.unity.nuget.mono-cecil": "1.11.5", + "com.unity.test-framework": "1.4.6", + "com.unity.test-framework.performance": "3.0.3" + } + }, "com.unity.ext.nunit": { "version": "2.0.5", "depth": 1, "source": "builtin", "dependencies": {} }, + "com.unity.mathematics": { + "version": "1.3.3", + "depth": 1, + "source": "registry", + "dependencies": {}, + "url": "https://packages.unity.com" + }, + "com.unity.nuget.mono-cecil": { + "version": "1.11.6", + "depth": 4, + "source": "registry", + "dependencies": {}, + "url": "https://packages.unity.com" + }, + "com.unity.render-pipelines.core": { + "version": "17.4.0", + "depth": 2, + "source": "builtin", + "dependencies": { + "com.unity.burst": "1.8.14", + "com.unity.mathematics": "1.3.2", + "com.unity.ugui": "2.0.0", + "com.unity.collections": "2.4.3", + "com.unity.modules.terrain": "1.0.0", + "com.unity.modules.jsonserialize": "1.0.0" + } + }, + "com.unity.render-pipelines.universal": { + "version": "17.4.0", + "depth": 1, + "source": "builtin", + "dependencies": { + "com.unity.render-pipelines.core": "17.4.0", + "com.unity.shadergraph": "17.4.0", + "com.unity.render-pipelines.universal-config": "17.4.0" + } + }, + "com.unity.render-pipelines.universal-config": { + "version": "17.4.0", + "depth": 2, + "source": "builtin", + "dependencies": { + "com.unity.render-pipelines.core": "17.4.0" + } + }, + "com.unity.searcher": { + "version": "4.9.4", + "depth": 3, + "source": "registry", + "dependencies": {}, + "url": "https://packages.unity.com" + }, + "com.unity.shadergraph": { + "version": "17.4.0", + "depth": 2, + "source": "builtin", + "dependencies": { + "com.unity.render-pipelines.core": "17.4.0", + "com.unity.searcher": "4.9.3" + } + }, "com.unity.test-framework": { "version": "1.6.0", "depth": 0, @@ -16,8 +99,27 @@ "com.unity.modules.jsonserialize": "1.0.0" } }, + "com.unity.test-framework.performance": { + "version": "3.4.0", + "depth": 4, + "source": "registry", + "dependencies": { + "com.unity.test-framework": "1.1.33", + "com.unity.modules.jsonserialize": "1.0.0" + }, + "url": "https://packages.unity.com" + }, + "com.unity.ugui": { + "version": "2.0.0", + "depth": 3, + "source": "builtin", + "dependencies": { + "com.unity.modules.ui": "1.0.0", + "com.unity.modules.imgui": "1.0.0" + } + }, "org.gamecult.cultlib": { - "version": "file:../../../CultLib/artifacts/unity/org.gamecult.cultlib", + "version": "file:../../artifacts/test-dependencies/org.gamecult.cultlib", "depth": 0, "source": "local", "dependencies": {} @@ -26,14 +128,16 @@ "version": "file:../../../EvePlugins/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields", "depth": 0, "source": "local", - "dependencies": {} + "dependencies": { + "org.gamecult.cultlib": "1.0.13" + } }, "org.gamecult.eve.surface": { - "version": "file:../../packages/org.gamecult.eve.surface", + "version": "file:../../../Eve/packages/org.gamecult.eve.surface", "depth": 0, "source": "local", "dependencies": { - "org.gamecult.cultlib": "1.0.3" + "org.gamecult.cultlib": "1.0.56" } }, "org.gamecult.eve.unity-scene": { @@ -41,9 +145,13 @@ "depth": 0, "source": "local", "dependencies": { - "org.gamecult.cultlib": "1.0.3", - "org.gamecult.eve.plugin-fields": "0.1.1", - "org.gamecult.eve.surface": "0.1.0" + "org.gamecult.cultlib": "1.0.56", + "org.gamecult.eve.plugin-fields": "0.2.3", + "org.gamecult.eve.surface": "0.3.6", + "org.gamecult.eve.unity-uitoolkit": "0.1.9", + "com.unity.mathematics": "1.3.2", + "com.unity.render-pipelines.universal": "17.4.0", + "com.unity.modules.particlesystem": "1.0.0" } }, "org.gamecult.eve.unity-uitoolkit": { @@ -51,7 +159,7 @@ "depth": 0, "source": "local", "dependencies": { - "org.gamecult.eve.surface": "0.1.0" + "org.gamecult.eve.surface": "0.3.6" } }, "com.unity.modules.assetbundle": { @@ -84,12 +192,24 @@ "source": "builtin", "dependencies": {} }, + "com.unity.modules.particlesystem": { + "version": "1.0.0", + "depth": 1, + "source": "builtin", + "dependencies": {} + }, "com.unity.modules.physics": { "version": "1.0.0", "depth": 0, "source": "builtin", "dependencies": {} }, + "com.unity.modules.terrain": { + "version": "1.0.0", + "depth": 3, + "source": "builtin", + "dependencies": {} + }, "com.unity.modules.ui": { "version": "1.0.0", "depth": 1, diff --git a/docs/aetheria-released-package-witness.md b/docs/aetheria-released-package-witness.md new file mode 100644 index 0000000..6446cec --- /dev/null +++ b/docs/aetheria-released-package-witness.md @@ -0,0 +1,370 @@ +# Aetheria released-package witness + +## Current integration gate + +The generic `ReleaseConsumerProject` is pinned to released Git packages: + +- `org.gamecult.eve.unity-scene` `0.3.134`, commit + `d1663b36d12b60ff5c7249135eff48edf6227ca9`, and + `org.gamecult.eve.surface` `0.3.6`, commit + `96839ad34c8d464ef622d8bbdd5d277e1ca9d825`; +- `org.gamecult.eve.plugin-fields` `0.2.3`, commit + `c5a4a75c1b727499b16c2dae1895f29e2a9f72f0`; +- `org.gamecult.eve.unity-uitoolkit` `0.1.9`, commit + `d1663b36d12b60ff5c7249135eff48edf6227ca9`; +- `org.gamecult.cultlib` `1.0.56`, commit + `334e60f1928b4212a29dd8b0d19b2c099fe6365e`. + +The hand-run integration gates are the cold lowering proof and the warm +full-session proof: + +```powershell +pwsh -File .\scripts\run-aetheria-daemon-world-witness.ps1 ` + -CultLibRoot E:\Projects\CultLib ` + -CacheState cold ` + -AssetCacheDirectory artifacts\admitted-release-cold-cache ` + -OutputDirectory artifacts\admitted-release-cold ` + -SkipAssetBundleBuild +``` + +Both connect directly to the Aetheria daemon. Control, subscriptions, commands, +and receipts use `cultnet+tcp`; provider files use `cultmesh-content+tcp`; +same-machine entity state uses a mapped SoA body; and the released native QUIC +client receives the advertised latest-only realtime body. No Odin hop or +Aetheria-specific gameplay code participates in the Unity runtime. This +admission pass proves the package graph with the isolated EditMode consumer; it +does not claim a new rendered end-to-end witness. + +## Historical witness ledger + +The retained cold proof is +`artifacts/cultlib-1043-scene-03100-cold5/runtime-witness.cold.json`. It began +with no bodies or partials and passed in 57.8 seconds end to end (14.9 seconds +inside PlayMode). The client materialized nine requested bundles totaling +56,872,521 bytes: shaders, core resources, UI, Longinus, tractor beam, the three +visible celestial types, and the visible asteroid. Every body matches the +current provider file by SHA-256 and length; no partial remained. Unused ships, +stations, shield, mine, turret, pickup, lightning, and explosion bundles stayed +unloaded. This cold profile owns content transfer, world lowering, fog and +Stardust execution, and pilot/map camera isolation. It deliberately issues no +gameplay command into the paused Terminus fixture. + +The retained warm full-session proof is +`artifacts/cultlib-1043-scene-03100-warm/runtime-witness.warm.json`. It passed in +70.7 seconds end to end (27.9 seconds inside PlayMode). The generic client docks, +buys and sells one typed item, resumes and undocks, moves, targets, aims, holds +and releases the tractor beam, destroys the proof hostile, and collects the +resulting loot exactly once through daemon-owned XZ proximity. All eight +retained receipts are provider-owned and reconciled. Eleven on-demand bundles +totaling 74,191,724 bytes were present by the end; gameplay loaded only the +additional content it exercised. The pilot camera excludes all eleven map +renderers and the map camera includes them. + +The pilot capture proves that provider fog, Stardust, celestial meshes, and ship +meshes reach URP, but it is not final visual approval: hulls remain crushed to +black and the cloud frame remains too hot and cyan-grey. Those are provider +visual-lever and shader-calibration issues, not concealed transport failures. +The map capture is clean and contains only map-channel glyphs on its dark map +background. + +An earlier released-package cold world witness passed with: + +- `org.gamecult.eve.unity-scene` `0.3.63`, commit + `3135d24bb84f40719b121b51634811c85af0a7af`; +- `org.gamecult.eve.plugin-fields` `0.2.3`, commit + `c5a4a75c1b727499b16c2dae1895f29e2a9f72f0`; +- `org.gamecult.eve.surface` `0.2.2`, commit + `140e1bd963a0033e66777a3b2c5fe6e9c97dfe32`; +- `org.gamecult.eve.unity-uitoolkit` `0.1.1`, commit + `4d0cbe0185bdc4fc65eb63503a7c5cb578539669`; +- `org.gamecult.cultlib` `1.0.15`, commit + `419053ebe2325848051c4f4d8ba352cd4286c424`; +- the generic `ReleaseConsumerProject` client connected directly to the + Aetheria daemon. + +The cold `0.3.59` witness in +`artifacts/aetheria-daemon-adaptive-exposure-cold` resolves the tagged package, +transfers and lowers the 56,170,916-byte provider bundle +`34dd156038c9c55b96136e10805f35f2bc7f2aa4e8dda8a519e57aa5a83329c8`, and +passes in 53.3 seconds. EveUnity lowers Aetheria's advertised historical +histogram exposure as a provider-agnostic camera semantic; the bundled static +volume profile remains neutral and is no longer a competing exposure owner. +Average pilot-frame luminance fell from `0.8716` in the restored-scattering +camera witness to `0.5096`, recovering visible cloud structure. The capture is +still cyan and visibly posterized, so temporal/color parity remains open. +Map-channel isolation also passes: the pilot camera excludes the 11 map +renderers and the map camera includes them. + +The subsequent released `0.3.61` warm witness in +`artifacts/aetheria-daemon-temporal-bootstrap-warm` restores two temporal +details from the fossil renderer as explicit provider-program semantics. The +fog history projects the previous view through the current projection, and the +first history frame uses the provider-advertised `ultra` quality before +settling to the scene's serialized `high` quality. Average luminance remains +effectively unchanged while mean neighboring-pixel variation falls from +`0.0332` to `0.0174` and Laplacian high-frequency energy falls from `0.1050` +to `0.0531`. This proves the temporal history is accumulating and that its +bootstrap seed mattered. The remaining cyan/tonemap mismatch is still open. + +The cold HD witness in `artifacts/aetheria-daemon-fog-alpha-cold-hd` then +transfers the rebuilt 56,168,970-byte provider bundle +`a829e57731fbe08f4564fa131468148e99f6b4204708ad3e64498885532b5091` +through `SharedFileMapping` and captures after 128 temporal composites at +1280x720. It restores the historical fog history ABI: the temporal pass stores +density directly in alpha, so the composite consumes that density directly +instead of decoding it as the raymarch pass's packed distance/density payload. +The flat grey opacity slabs disappear. The released client test passes in 52.7 +seconds with 238 fog composites and Stardust draws; the pilot camera still +excludes map renderers and the map camera includes them. Remaining visual work +is color/composition parity, not broken fog opacity or an unsettled witness. + +The released `0.3.62` warm HD witness in +`artifacts/aetheria-daemon-hdr-grading-warm-hd` lowers the provider's +`hdr-before-tonemap.v1` grading-space contract. This restores the fossil +profile's HDR ordering—grading before ACES—rather than inheriting the released +consumer project's default LDR grading after tonemapping. EveUnity restores the +consumer's prior pipeline mode when the camera is released and fails closed +when the requested grading space cannot be lowered. + +The asserted rerun in `artifacts/aetheria-daemon-hdr-grading-asserted-warm-hd` +also completes the live destruction-loot path on the same released client. One +daemon destruction produces the canonical pickup; tractor power reaches `1` +and returns to `0`; one Ymir `begin` fact produces exactly one +`pickup.collected` event; cargo changes from `0` to `1`; and no client pickup +command participates in the transaction. The event identity begins +`ymir-fact:` and contains the retained Box3D session, step, contact episode, +and begin-fact identity. + +The released `0.3.63` warm HD witness in +`artifacts/aetheria-daemon-temporal-projection-0363-warm-hd` makes the native +volume history projection convention an explicit provider-program semantic. +Aetheria advertises the non-render-target projection convention used by its +historical `Graphics.Blit` temporal pass; the generic URP lowerer applies that +convention without naming Aetheria or interpreting fog. The witness captures +the naturally rendered camera target after 359 volume composites instead of +issuing a second out-of-band `Camera.Render()`. Package tests pass 105/105 and +the released gameplay witness still proves movement, combat, destruction loot, +one Ymir contact collection, held tractor input, and camera-channel isolation. +The remaining visual controls (fog parameters, stellar tint, ambient intensity, +histogram exposure, grading, and temporal settings) remain provider-owned +levers; this proof does not claim a final art grade. + +The released `0.3.63` warm rejection witness in +`artifacts/aetheria-daemon-cargo-rejection-0363-warm` selects the daemon-owned +`cargo-capacity-rejection-proof` scenario. The controlled ship begins with all +generated cargo bays filled from typed item volume and bay capacity. Combat +creates the salvage pickup normally; player Ymir Begin contacts emit +`pickup.rejected` with `cargo-capacity`, preserve cargo at `5` items, and leave +the pickup live. The final run observed two later player recontacts with two +distinct `ymir-fact:` identities and no player collection event. The generic +witness correlates feedback through advertised SoA `SourceIndex` and feedback +`TargetEntityIndex`; events from other ships are not attributed to the pilot. + +Current released-package evidence is in +`artifacts/aetheria-daemon-mapped-body-cold`; despite that retained directory +name, `runtime-witness.warm.json` is the current passing run. The warm PlayMode test +passed and recorded provider-owned reconciled movement, look, targeting, +tractor press, tractor release, contact-gated cargo collection, and action +receipts. The fingerprinted April 2021 catalog recovery now restores the +distinct common `Longinus`, rare `LonginusX`, and `Djinni` rows plus the missing +turn-thruster equipment needed to outfit ordinary hulls. This run generated the +player as a common directional-thruster `Longinus`; the other six presented +ships were `Djinni`, and no `LonginusX` appeared. The player moved 14.34 world +units and completed its authoritative hardware-driven look before combat. +The daemon-owned look direction reached the SoA body rotation, +and the generic aim presentation rendered at the advertised 50-unit minimum +convergence distance. Aetheria derives ambient color from daemon-owned stellar +`LightColor` values and advertises `studio2` only as the reflection cubemap; the +gravity-fog raymarch owns the visible frame. The released lowerer records flat +ambient mode, color `(1.46,1.2556,0.8468)`, ambient-probe energy `4.6354`, and +custom reflection texture `studio2`. No skybox asset or key light is advertised, +and the generic camera rig records zero live directional intensity. The earlier +invented `0.75` light and false skybox authority were deleted rather than retained +as material-conversion compensators. +Aetheria also advertises the original undocked `ARPG.unity` Third Person Rig: +entity-forward perspective follow, `30` unit distance, `60` degree vertical +field of view, canonical bottom-origin target screen position `0.64,0.19`, zero position damping, and +`1`-`4096` clip planes. Its generic `aim.convergence-point.v1` relation makes +the optical axis reach the daemon-published aim point while preserving that +entity framing. The generic camera rig lowers both constraints without +Aetheria-specific camera code. The distinct `70` unit Docked Rig no longer +incorrectly owns the flight view. +Aetheria's pilot surface also publishes a transparent cockpit overlay. The +released generic UI Toolkit lowerer produced seven native `ProgressBar` elements +for hull, shield, capacitor, weapon cooldown, target lock, and target state. Daemon frame, +player, and command diagnostics remain available in the document but lower +with `display: none` in the pilot presentation. The runnable generic-client +launcher mounts the same advertised document and forwards its typed commands; +it contains no Aetheria gameplay or client-authored world light. + +The same surface publishes a `field.volume3d` node over four canonical Eve +Fields splat layers. Its surface contract contains logical ports such as +`surfaceHeight`, `patch`, and `tint`; it contains no Unity `_Nebula*` property +names or pass indices. The selected `unity-scene` provider asset variant owns +that concrete shader ABI through `unity.volume.*` metadata. The released +generic lowerer resolved the provider shader and dither texture, rasterized all + four layers, and composited `195` pilot-camera frames through daemon frame `491`. +The provider variant advertises the fossil shader's raymarch, temporal-history, +and composite passes plus their logical ports. EveUnity allocates and resets the +history targets generically; a partial temporal ABI fails closed. + +The two PNGs below are deliberate camera-channel captures, not a composited +screen capture, so they prove world/map isolation rather than HUD pixels. The +native cockpit proof is the asserted UI Toolkit visual tree recorded as +`cockpitProgressCount: 7` in the witness facts. The same run records completed +daemon lock progress `1.0`; Unity does not manufacture or smooth that value. + +Camera-channel facts: + +- provider-authored player renderers: `7` active, with no embedded shield renderer; +- pilot changed pixels: `217,323`; +- pilot average luminance: `0.1322155`; +- pilot bright pixels: `101,510`; +- map-channel renderers: `10`; +- map changed pixels: `3,179`; +- native cockpit progress bars: `7`; +- daemon tractor power at capture: `1.0` after the held input completed its + authored ramp; +- daemon tractor power after the advertised release: `0`; +- provider tractor particle systems: `1`; +- pickup entities before/after destruction and collection: `0` / `0`; +- provider `pickup.collected` events: exactly `1`; +- collected item and quantity: one canonical `aetheria.item_definition:*` key, + quantity `1`; +- authoritative cargo quantity before/after: `0` / `1`; +- the pilot camera excludes the advertised map layer; +- the player prefab's embedded layer-14 map icon contributes exactly `0` pilot + pixels; +- the map camera renders exactly the advertised map layer. + +The daemon also publishes the current zone's celestial presentation through the +same generic entity SoA generation as ordinary world entities. The current warm +witness records eleven sun/planet/gas-giant rows and eighteen asteroid rows. +Every row resolved a provider asset with enabled native renderers, and seven +celestial instances intersected the pilot frustum. These identities are +presentation-only and cannot receive gameplay commands. EveUnity contains no +Aetheria or celestial branch; release `0.3.55` includes the generic prefab wrapper +so semantic instance scale composes with, rather than overwrites, provider-local +scale. + +Provider prefab construction now removes the fossil shield renderer embedded in +each ship. That renderer's custom shader performs collision-local dither clipping; +flattening it to ordinary opaque URP Lit had produced the large black ellipsoids +previously mistaken for celestial bodies. Shield state remains daemon truth and +shield impacts use the separately advertised receipt-driven effect. Material +conversion also preserves source lighting models: the map icon's unlit source +now becomes `Universal Render Pipeline/Unlit`, so map glyphs remain readable +without resurrecting the false scene light. + +Visual inspection confirms that map glyphs are absent from the pilot frame and +present in the map-only frame. This is transport, authority, field-production, +and lowering proof, not visual parity: the current pilot capture contains the +blue fog field and the provider's magenta-and-gold Longinus hull with no opaque +shield ellipsoid. The celestial composition still lacks the fossil capture's +readable close body. Release +`0.3.57` includes generic lowering for Eve Fields `0.2.3`'s +`AnimatedRadialCosine` source alongside `PowerPulse`, dither-scale, +temporal-history, and finite-look-at semantics. Aetheria publishes the fossil +global animated simplex/cellular/ambient producer stack, body-owned radial wave +frequency, separate negative `gravity.height` and positive +`fog.surface_height`, and the authored `ARPG.unity` zone-brush extent. The + witness records player Y `-101.5035` and camera Y `-84.7369`; Unity does not +sample terrain to invent either value. The four rasterized field layers and HDR +raymarch/history targets are populated, so the remaining failure is in native +presentation/composition or provider art calibration rather than missing state +delivery. Color, lighting, patch structure, readable world geometry, and +gravity-wave visual parity remain open. EveUnity must not compensate with +Aetheria-specific lighting or fog rules. + +The `0.3.57` run also closes two camera-contract omissions. Aetheria advertises +the fossil pilot camera's neutral temporal-reprojection values; the released +generic lowerer applies URP TAA with `0.99` history blend, `0.1` jitter scale, +high quality, and zero sharpening. Fog and Stardust now consume one generic +camera-relative viewport-frame resolver rather than projecting the same splat +document independently. The live witness fails unless their snapped grid +centers are identical; the latest run resolved both to `(-6,-6)` after movement. +The provider HLSL now consumes Eve Fields' positive world-axis viewport directly; +it no longer retains the two-axis compensation once required by the deleted +fossil gravity camera. An asymmetric GPU fixture fails unless positive-world X +samples positive texture X, while the existing one-cell remap proof still keeps +705 overlapping world particles bit-identical. +The capture is still visibly rough and is not a visual-parity claim. + +The fossil Stardust material used a screen-space coverage mask that changes on +every rendered frame and depends on TAA to reconstruct stable subpixel stars. +The earlier URP port had replaced that with a fixed alpha threshold and +additive blending. Aetheria now advertises the provider dither texture, +viewport scale, and logical render-frame port; generic EveUnity binds them +without knowing their product meaning. The live witness resolves those native +ports from provider metadata and checks the actual material instance. The +provider bundle source gate requires the temporal clip, overwrite blend, and +depth write and rejects `_AlphaClip`. + +Substance is not part of this path; later texture baking belongs in Blender. +Aetheria currently bundles ambientCG's 1K `Metal012` color, normal, and +metalness maps under CC0. The same pre-generated maps now replace dead +Substance archive sub-assets on the brushed aluminium, tinted car paint, +cockpit, steel, black-metal, and radiator materials. The released witness + resolves textured native URP materials in `8` distinct player-material slots, +up from `3`, without adding another texture payload. This is temporary provider +art with recorded provenance, not a replacement texture pipeline. + +The tractor proof is structural, not yet a native-look victory lap. The daemon +publishes `beam.presentation` and advertises `pilot.scoop` as a +`button-hold.v1` value action. The generic client routes scalar `1` on press and +scalar `0` on release through the advertised `SetTractorPower` operation. Both +commands receive provider-owned reconciled receipts, and the daemon ramps the +published power from `1.0` at capture to exactly `0` after release. The provider +build removes the fossil's embedded tractor object from player/ship prefabs, so +the standalone `beam.presentation` prefab is the only tractor renderer. Its +cyan/yellow dotted band is visible in the pilot capture and absent from the map +capture. +The current `0.3.72` warm gameplay run is retained in +`artifacts/aetheria-daemon-0372-warm5`. It begins with no pickup. A +daemon-authoritative shot destroys the deterministic salvage target and creates +one provider-owned pickup carrying a canonical generated cargo item. Held +tractor input causes Ymir-owned attraction; collection itself is an +authoritative XZ proximity transaction and does not depend on a contact fact. +The daemon removes the pickup once, changes cargo from `0` to `1`, and emits one +stable `zone:0:pickup:0:collected` feedback event. No client pickup command or +Ymir contact receipt owns cargo mutation. + +The same run records 709 URP field-volume composites, 65,536 stateless field +particles, and 11 map-channel renderers. The pilot camera excludes every map +renderer and the map camera includes them. The serialized consumer renderer now +contains `EveUnityRendererFeature`; the configuration utility marks +`UniversalRendererData` dirty before saving so batch and editor cameras use the +same URP scheduling path. The captures are integration evidence, not a final art +grade: the pilot frame remains overexposed and the hull reads nearly black. +Effect tuning remains provider art work; the runtime must not compensate with +an Aetheria-specific beam. + +Primary artifacts: + +- `results.xml`: one passing PlayMode witness; +- `runtime-witness.json`: package, content, receipt, aim, combat, and + camera facts; +- `aetheria-daemon-world.png`: pilot-camera capture; +- `aetheria-daemon-map.png`: map-only capture. + +## Cold delivery status + +Cold CDN transfer is part of the passing readiness gate. The released `0.3.91` +run in `artifacts/cold-release-0391` began with zero cached bodies and partials, +received only the typed manifest through snapshot state, transferred bounded +content chunks through the managed CultMesh content session, and atomically +promoted one `56,204,750`-byte body named by SHA-256 +`f2c8acf938998703799e909cbf0c6e3ed9ad5d6f83b708a0b7b6e76ee33f7160`. +It left zero partials and selected `SharedFileMapping` over the verified file. + +The cold-only failure exposed by `0.3.90` was a bootstrap ownership race, not a +bulk snapshot transfer: live SoA subscriptions began before the blocking cold +asset acquisition, so their retained capability expired before Unity pumped it. +`0.3.91` resolves the surface first, completes provider-asset acquisition, and +only then starts live subscriptions. A fresh current generation therefore owns +the first lowered frame. No retry timer or stale-body reinterpretation exists. + +This proves cold acquisition and local mapped reuse, not provider-to-client +network zero-copy, shared-memory delivery, or GPU zero-copy. Managed remote +chunks are still copied and fragmented before the transfer owner commits the +mapped file; network-body streaming and copy-volume profiling remain open. diff --git a/docs/authority-map.md b/docs/authority-map.md index 15c6148..3be5f6c 100644 --- a/docs/authority-map.md +++ b/docs/authority-map.md @@ -9,6 +9,9 @@ importing provider product code into the generic runtime. EveUnity owns Unity scene projection, `GameObject` lifecycle, camera and input drivers, asset-provider hooks, package release, runtime tests, and captures. +`CultMeshClient` owns physical route selection, reconnection, shared typed +resource sessions, and their lease lifetime. Unity receives stable Verse and +provider identities and cannot preserve a physical route as gameplay authority. ## Inputs @@ -17,7 +20,11 @@ drivers, asset-provider hooks, package release, runtime tests, and captures. - `gamecult.eve.provider_advertisement.v1` - `gamecult.eve.surface.v1` - provider asset-manifest documents +- runtime-selected asset-variant metadata, including native rendering-program bindings +- generic field-volume lifecycle declarations and logical texture/scalar/matrix ports +- generic viewport-to-texture scale bindings between logical vector and texture ports - provider command receipts +- semantic render-channel inclusion and exclusion policy - Unity input and local presentation assets ## Outputs @@ -29,23 +36,83 @@ drivers, asset-provider hooks, package release, runtime tests, and captures. ## Derived State -`ActiveWorld`, scene objects, transforms, markers, camera pose, asset caches, +`ActiveWorld`, scene objects, transforms, markers, camera pose, native +render-channel layer mappings, resolved native shader ports/passes, asset caches, +per-volume raymarch targets, temporal-history targets, previous camera matrices, presentation counters, and receipt display state are projections. They are not -world truth. +world truth. Temporal targets reset when the volume program, node, or render size +changes and can never become provider state. + +The optional Unity cache directory contains verified content-transfer state and +mapped body bytes only. It is disposable derived data, not a CultCache replica +of provider gameplay documents. + +Camera-relative field viewports have one generic resolver. The advertised +viewport frame owns its extent, spatial-cell scale, raster texels per cell, and +snap policy; fog volumes and stateless particle programs consume the identical +projected `EveFieldsViewport`. URP TAA state is likewise derived from the +world's neutral `temporal-reprojection.v1` camera contract and is restored on +release. + +Particle materials may consume advertised presentation-only inputs through +logical native metadata: provider-owned textures, viewport-to-texture scale, +and the current render-frame index. The runtime frame index owns temporal +coverage only; it cannot drive particle identity, phase, position, or any +daemon-visible state. ## Forbidden Writers Unity transforms, input drivers, camera rigs, scene sinks, asset caches, and pending receipts may not mutate or simulate provider world state. Plugin -projection shells may not implement Sai, Norn, or TeX semantics. +projection shells may not implement Sai, Norn, or TeX semantics. Portable +surfaces may not carry Unity layer numbers, camera culling masks, shader property +names, shader keywords, or native pass indices. Those bindings belong to the +selected runtime asset variant. A native volume program that advertises a +temporal pass must also advertise its current-sample, history, +previous-view-projection, and reset-history ports; partial temporal programs fail +closed. + +Unity code may not open provider snapshot sessions, schema clients, write +forwarders, subscriptions, or replica shards against a discovered physical +route. Reads, watches, content/body transfer, realtime frames, and command +submissions share the same stable-identity `CultMeshClient` owner. +Remote bootstrap and asset materialization complete through `PrepareAsync` +before the scene is mounted. `Connect`, `Mount`, input, refresh, and disposal +cannot synchronously wait on CultMesh. A bounded command outbox owns delivery; +continuous movement/look values may be replaced by a newer value, but transport +send completion cannot retire any command. Only the provider's canonical +receipt retires its idempotency key. Unacknowledged work is retried fairly; it +cannot become client-authored success or starve unrelated commands. + +Viewport-sized shader inputs are likewise presentation state. A provider may +relate a logical vector port to a logical native texture port through +`viewportTextureScaleBindings`; EveUnity derives the vector from the active +camera viewport and resolved texture dimensions on every render. A provider +literal is not allowed to impersonate those runtime dimensions, and an +unresolvable relation fails closed. + +The provider selects camera semantics and framing from its authoritative mode. +`planar.top-down-follow.v1` derives a downward presentation from the advertised +target and framing values. `perspective.entity-forward-follow.v1` derives its +view direction from the presented entity rotation and applies the same generic +distance, screen-position, damping, and lens contract. When the provider also +advertises `aim.convergence-point.v1`, the camera and aim marker consume one +derived convergence point from the `aim.presentation` node. The native camera +rotation and position are solved together so the optical axis reaches that +point while the followed entity remains at the advertised screen coordinate. +EveUnity owns only this native projection. It cannot collapse distinct provider +modes into one camera opinion or infer a product camera from entity kind. ## Shared Paths -Initial connect discovers a Verse endpoint and selects a provider surface by -semantic kind. Refresh, reconnect, and terminal-receipt reconciliation consume -that provider's surface documents. Movement, focus, target, and action input all -become `gamecult.eve.command_invocation.v1` through the provider-advertised -command boundary. +Initial preparation asks one rendezvous for Verses, selects an advertised +authority runtime and provider surface by semantic kind, then leases it through +the explicit Verse/authority-runtime target. The Eve provider ID remains a +separate surface-owner identity. +Refresh, route rotation, reconnect, and terminal-receipt reconciliation remain +inside the same identity-owned session. Movement, focus, target, and action +input all become `gamecult.eve.command_invocation.v1` through the +provider-advertised command boundary. ## Cut Line diff --git a/packages/org.gamecult.eve.surface/Directory.Build.props b/packages/org.gamecult.eve.surface/Directory.Build.props deleted file mode 100644 index 994fcb6..0000000 --- a/packages/org.gamecult.eve.surface/Directory.Build.props +++ /dev/null @@ -1,7 +0,0 @@ - - - $(MSBuildThisFileDirectory)bin~\ - $(MSBuildThisFileDirectory)obj~\ - $(MSBuildThisFileDirectory)obj~\ - - diff --git a/packages/org.gamecult.eve.surface/GameCult.Eve.Contracts.csproj b/packages/org.gamecult.eve.surface/GameCult.Eve.Contracts.csproj deleted file mode 100644 index 6b453f0..0000000 --- a/packages/org.gamecult.eve.surface/GameCult.Eve.Contracts.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - netstandard2.1 - enable - latest - enable - GameCult.Eve.Surface - Portable Eve surface, provider, input, plugin, and entity-view contracts. - $(MSBuildThisFileDirectory)..\..\..\CultLib - - - - - - - - diff --git a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj deleted file mode 100644 index f07f7e8..0000000 --- a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - netstandard2.1 - enable - latest - enable - Portable Eve surface and provider contracts. - $(MSBuildThisFileDirectory)..\..\..\CultLib - - - - - - - diff --git a/packages/org.gamecult.eve.surface/README.md b/packages/org.gamecult.eve.surface/README.md deleted file mode 100644 index eb65c17..0000000 --- a/packages/org.gamecult.eve.surface/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# Eve Surface Contract - -This package defines the retained CultUI surface document that every Eve -runtime lowers. - -Components own normal child layout through `Children`, live CultMesh value -bindings through `StateBindings`, and nested synced document regions through -`EmbeddedDocuments`. An embedded document slot names the child document id, -schema, presentation kind, and route hint; renderers resolve that child through -CultMesh instead of rebuilding it with runtime-local projection code. - -See `../../docs/surface-contract-v1.md` for the full contract and -`../../web/fixtures/cultui-embedded-surface.json` for the parity fixture. -Runtime discovery and required feature coverage live in -`../../tools/parity/parity-manifest.json`; active GUI runtimes must list -`embeddedDocuments` as a supported feature and require the `embedded-surface` -fixture. - -Verification entrypoints: - -- Browser/TypeScript: `node --test web\eve-dsl.test.mjs` -- Semantic runtime matrix: `powershell -ExecutionPolicy Bypass -File .\scripts\run-parity-harness.ps1` -- Flutter GUI contract, when Flutter is installed: `flutter test --plain-name embedded_surface_fixture_contract` -- Rust/CultMesh document sync: run `cargo test rust_preserves_cultui_embedded_surface_slots_through_typed_document_sync` from `E:/Projects/CultLib/packages/cultnet-rs` - -The parity manifest is the discovery source for web, Flutter desktop/Linux/Android, -iOS/UIKit, Android/Kotlin, Unity UI Toolkit, and Rust contract coverage. Do not -add a new renderer claim without adding its nested-surface fixture requirement -there. diff --git a/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs deleted file mode 100644 index eba3b44..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs +++ /dev/null @@ -1,144 +0,0 @@ -using System; -using System.Collections.Generic; -using GameCult.Caching; -using MessagePack; - -#nullable enable - -namespace GameCult.Eve.Surface -{ - [CultDocument("gamecult.eve.asset_catalog", SchemaId)] - [MessagePackObject] - public sealed class EveAssetCatalogDocument - { - public const string SchemaId = "gamecult.eve.asset_catalog.v1"; - - [SerializationConstructor] - public EveAssetCatalogDocument( - string schema, - string providerId, - string catalogId, - long version, - string updatedAtUtc, - EveAssetCatalogEntry[] assets) - { - Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; - ProviderId = providerId ?? ""; - CatalogId = catalogId ?? ""; - Version = version; - UpdatedAtUtc = updatedAtUtc ?? ""; - Assets = assets ?? Array.Empty(); - } - - public EveAssetCatalogDocument( - string providerId, - string catalogId, - long version, - string updatedAtUtc, - EveAssetCatalogEntry[] assets) - : this(SchemaId, providerId, catalogId, version, updatedAtUtc, assets) - { - } - - [Key(0)] public string Schema { get; } - [Key(1)] public string ProviderId { get; } - [Key(2)] public string CatalogId { get; } - [Key(3)] public long Version { get; } - [Key(4)] public string UpdatedAtUtc { get; } - [Key(5)] public EveAssetCatalogEntry[] Assets { get; } - } - - [MessagePackObject] - public sealed class EveAssetCatalogEntry - { - [SerializationConstructor] - public EveAssetCatalogEntry( - string assetRef, - string assetKind, - EveAssetVariant[] variants, - Dictionary metadata) - { - AssetRef = assetRef ?? ""; - AssetKind = assetKind ?? ""; - Variants = variants ?? Array.Empty(); - Metadata = metadata == null - ? new Dictionary(StringComparer.Ordinal) - : new Dictionary(metadata, StringComparer.Ordinal); - } - - public EveAssetCatalogEntry( - string assetRef, - string assetKind, - EveAssetVariant[] variants, - IReadOnlyDictionary? metadata = null) - : this( - assetRef, - assetKind, - variants, - metadata == null ? new Dictionary() : new Dictionary(metadata)) - { - } - - [Key(0)] public string AssetRef { get; } - [Key(1)] public string AssetKind { get; } - [Key(2)] public EveAssetVariant[] Variants { get; } - [Key(3)] public Dictionary Metadata { get; } - } - - [MessagePackObject] - public sealed class EveAssetVariant - { - [SerializationConstructor] - public EveAssetVariant( - string runtimeId, - string platform, - string format, - string uri, - string contentHash, - long sizeBytes, - string assetKey, - Dictionary metadata) - { - RuntimeId = runtimeId ?? ""; - Platform = platform ?? ""; - Format = format ?? ""; - Uri = uri ?? ""; - ContentHash = contentHash ?? ""; - SizeBytes = sizeBytes; - AssetKey = assetKey ?? ""; - Metadata = metadata == null - ? new Dictionary(StringComparer.Ordinal) - : new Dictionary(metadata, StringComparer.Ordinal); - } - - public EveAssetVariant( - string runtimeId, - string platform, - string format, - string uri, - string contentHash, - long sizeBytes, - string assetKey, - IReadOnlyDictionary? metadata = null) - : this( - runtimeId, - platform, - format, - uri, - contentHash, - sizeBytes, - assetKey, - metadata == null ? new Dictionary() : new Dictionary(metadata)) - { - } - - [Key(0)] public string RuntimeId { get; } - [Key(1)] public string Platform { get; } - [Key(2)] public string Format { get; } - [Key(3)] public string Uri { get; } - [Key(4)] public string ContentHash { get; } - [Key(5)] public long SizeBytes { get; } - [Key(6)] public string AssetKey { get; } - [Key(7)] public Dictionary Metadata { get; } - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs.meta deleted file mode 100644 index 0af6487..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 8f53654f7c98d894588127ba001f11b4 \ No newline at end of file diff --git a/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs b/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs deleted file mode 100644 index 3af0c86..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs +++ /dev/null @@ -1,167 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using GameCult.Mesh; - -#nullable enable - -namespace GameCult.Eve.Surface -{ - public sealed class EveSurfaceRequest - { - public string ProviderId { get; set; } = ""; - public string SurfaceId { get; set; } = ""; - public string SurfaceKind { get; set; } = ""; - public string LoweringTarget { get; set; } = ""; - public bool RequireActive { get; set; } = true; - } - - public sealed class EveSurfaceSelection - { - internal EveSurfaceSelection( - EveProviderAdvertisementDocument provider, - EveAdvertisedSurface advertisement, - CultMeshDocumentHandle surface, - IReadOnlyList plugins) - { - Provider = provider; - Advertisement = advertisement; - Surface = surface; - Plugins = plugins; - } - - public EveProviderAdvertisementDocument Provider { get; } - public EveAdvertisedSurface Advertisement { get; } - public CultMeshDocumentHandle Surface { get; } - public IReadOnlyList Plugins { get; } - } - - public static class EveCultMeshSurfaceSelection - { - public static async Task SurfaceAsync( - this CultMeshClient mesh, - string endpointId, - EveSurfaceRequest? request = null, - CancellationToken cancellationToken = default, - Action? observeStage = null) - { - if (mesh == null) throw new ArgumentNullException(nameof(mesh)); - var resolved = request ?? new EveSurfaceRequest(); - IReadOnlyList providerDocuments; - if (string.IsNullOrWhiteSpace(resolved.ProviderId)) - { - observeStage?.Invoke("provider-collection-opening"); - var providers = await mesh.CollectionAsync(endpointId, cancellationToken) - .ConfigureAwait(false); - observeStage?.Invoke("provider-collection-ready"); - providerDocuments = await providers.LatestAsync().ConfigureAwait(false); - } - else - { - observeStage?.Invoke("provider-document-opening"); - var provider = await mesh.DocumentAsync( - endpointId, - ProviderAdvertisementRecordRef(resolved.ProviderId), - cancellationToken) - .ConfigureAwait(false); - observeStage?.Invoke("provider-document-ready"); - providerDocuments = new[] { await provider.LatestAsync().ConfigureAwait(false) }; - } - observeStage?.Invoke("provider-snapshot-ready"); - var match = Select(providerDocuments, resolved); - IReadOnlyList plugins = Array.Empty(); - if (match.Surface.RequiresPlugins.Count > 0) - { - var advertisements = await mesh.CollectionAsync(endpointId, cancellationToken) - .ConfigureAwait(false); - plugins = ResolvePlugins(match.Surface, await advertisements.LatestAsync().ConfigureAwait(false)); - observeStage?.Invoke("plugin-snapshot-ready"); - } - observeStage?.Invoke("surface-document-opening"); - var surface = await mesh.DocumentAsync( - endpointId, - match.Surface.RecordRef, - cancellationToken) - .ConfigureAwait(false); - observeStage?.Invoke("surface-document-ready"); - return new EveSurfaceSelection(match.Provider, match.Surface, surface, plugins); - } - - public static IReadOnlyList ResolvePlugins( - EveAdvertisedSurface surface, - IEnumerable advertisements) - { - if (surface == null) throw new ArgumentNullException(nameof(surface)); - if (advertisements == null) throw new ArgumentNullException(nameof(advertisements)); - var available = advertisements - .GroupBy(plugin => plugin.PluginId, StringComparer.Ordinal) - .ToDictionary(group => group.Key, group => group.OrderByDescending(plugin => plugin.Version, StringComparer.Ordinal).First(), StringComparer.Ordinal); - var selected = new List(); - foreach (var requirement in surface.RequiresPlugins) - { - if (!available.TryGetValue(requirement.PluginId, out var plugin)) - { - if (string.Equals(requirement.Availability, "required", StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException( - $"Eve surface '{surface.SurfaceId}' requires unavailable plugin '{requirement.PluginId}'."); - continue; - } - - var claims = plugin.Schemas.Concat(plugin.ComponentKinds).Concat(plugin.Commands) - .ToHashSet(StringComparer.Ordinal); - var missing = requirement.RequiredCapabilities.Where(capability => !claims.Contains(capability)).ToArray(); - if (missing.Length > 0) - { - if (string.Equals(requirement.Availability, "required", StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException( - $"Eve plugin '{plugin.PluginId}' does not advertise required capabilities: {string.Join(", ", missing)}."); - continue; - } - selected.Add(plugin); - } - return selected; - } - - public static (EveProviderAdvertisementDocument Provider, EveAdvertisedSurface Surface) Select( - IEnumerable providers, - EveSurfaceRequest request) - { - if (providers == null) throw new ArgumentNullException(nameof(providers)); - if (request == null) throw new ArgumentNullException(nameof(request)); - var match = providers - .Where(provider => Matches(request.ProviderId, provider.ProviderId)) - .SelectMany(provider => provider.Surfaces.Select(surface => new { Provider = provider, Surface = surface })) - .Where(candidate => Matches(request.SurfaceId, candidate.Surface.SurfaceId)) - .Where(candidate => Matches(request.SurfaceKind, candidate.Surface.SurfaceKind)) - .Where(candidate => !request.RequireActive || string.Equals(candidate.Surface.Status, "active", StringComparison.OrdinalIgnoreCase)) - .Where(candidate => string.IsNullOrWhiteSpace(request.LoweringTarget) || - candidate.Surface.WorldInteraction?.LoweringTargets.Any(target => - string.Equals(target, request.LoweringTarget, StringComparison.Ordinal)) == true) - .OrderBy(candidate => candidate.Provider.ProviderId, StringComparer.Ordinal) - .ThenBy(candidate => candidate.Surface.SurfaceId, StringComparer.Ordinal) - .FirstOrDefault(); - if (match == null) - throw new InvalidOperationException( - $"No advertised Eve surface matched provider='{request.ProviderId}', surface='{request.SurfaceId}', " + - $"kind='{request.SurfaceKind}', loweringTarget='{request.LoweringTarget}'."); - if (!string.Equals(match.Surface.Transport, "cultmesh-record", StringComparison.Ordinal)) - throw new InvalidOperationException( - $"Eve surface '{match.Surface.SurfaceId}' uses unsupported transport '{match.Surface.Transport}'."); - if (string.IsNullOrWhiteSpace(match.Surface.RecordRef)) - throw new InvalidOperationException($"Eve surface '{match.Surface.SurfaceId}' does not advertise a record reference."); - return (match.Provider, match.Surface); - } - - private static bool Matches(string expected, string actual) => - string.IsNullOrWhiteSpace(expected) || string.Equals(expected, actual, StringComparison.Ordinal); - - public static string ProviderAdvertisementRecordRef(string providerId) - { - if (string.IsNullOrWhiteSpace(providerId)) - throw new ArgumentException("Provider id must be non-empty.", nameof(providerId)); - return $"eve:provider:{providerId.Trim()}"; - } - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs.meta deleted file mode 100644 index 5385db9..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveCultMeshSurfaceSelection.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: b3c40df8a2a14d7db2f45f7018591fd3 diff --git a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs deleted file mode 100644 index 22d55aa..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System; -using GameCult.Caching; -using MessagePack; -using GameCult.Mesh; - -#nullable enable - -namespace GameCult.Eve.Surface -{ - [CultDocument("gamecult.eve.entity_soa_view", SchemaId)] - [MessagePackObject] - public sealed class EveEntitySoaViewDocument - { - public const string SchemaId = "gamecult.eve.entity_soa_view.v1"; - - [Key(0)] public string Schema { get; set; } = SchemaId; - [Key(1)] public string ProviderId { get; set; } = ""; - [Key(2)] public string ViewId { get; set; } = ""; - [Key(3)] public long FrameId { get; set; } - [Key(4)] public long Generation { get; set; } - [Key(5)] public string PublishedAtUtc { get; set; } = ""; - [Key(6)] public string Backend { get; set; } = "cultcache"; - [Key(7)] public string SynchronizationMode { get; set; } = "immutable_frame"; - [Key(8)] public EveEntitySoaBuffer[] Buffers { get; set; } = Array.Empty(); - [Key(9)] public EveEntitySoaColumn[] Columns { get; set; } = Array.Empty(); - [Key(10)] public EveEntitySoaDirtyRange[] DirtyRanges { get; set; } = Array.Empty(); - [Key(11)] public EveEntityRenderGroup[] RenderGroups { get; set; } = Array.Empty(); - [Key(12)] public EveEntitySoaIdentity[] Identities { get; set; } = Array.Empty(); - [Key(13)] public CultMeshBodyDescriptor? Body { get; set; } - } - - [MessagePackObject] - public sealed class EveEntitySoaIdentity - { - [Key(0)] public int EntityIndex { get; set; } = -1; - [Key(1)] public string EntityId { get; set; } = ""; - [Key(2)] public string Kind { get; set; } = ""; - [Key(3)] public string Label { get; set; } = ""; - [Key(4)] public string Faction { get; set; } = ""; - [Key(5)] public bool Selectable { get; set; } = true; - [Key(6)] public bool Controllable { get; set; } - [Key(7)] public string AssetRef { get; set; } = ""; - } - - [MessagePackObject] - public sealed class EveEntitySoaBuffer - { - [Key(0)] public string BufferId { get; set; } = ""; - [Key(1)] public string Backend { get; set; } = "cultcache"; - [Key(2)] public string Location { get; set; } = ""; - [Key(3)] public long ByteOffset { get; set; } - [Key(4)] public long ByteLength { get; set; } - [Key(5)] public long Generation { get; set; } - } - - [MessagePackObject] - public sealed class EveEntitySoaColumn - { - [Key(0)] public string ColumnId { get; set; } = ""; - [Key(1)] public string Semantic { get; set; } = ""; - [Key(2)] public string BufferId { get; set; } = ""; - [Key(3)] public string ScalarType { get; set; } = ""; - [Key(4)] public long ByteOffset { get; set; } - [Key(5)] public int ElementStride { get; set; } - [Key(6)] public int ElementCount { get; set; } - [Key(7)] public string Unit { get; set; } = ""; - [Key(8)] public string CoordinateSpace { get; set; } = ""; - } - - [MessagePackObject] - public sealed class EveEntitySoaDirtyRange - { - [Key(0)] public string ColumnId { get; set; } = ""; - [Key(1)] public int StartIndex { get; set; } - [Key(2)] public int Count { get; set; } - [Key(3)] public long Generation { get; set; } - } - - [MessagePackObject] - public sealed class EveEntityRenderGroup - { - [Key(0)] public int GroupId { get; set; } - [Key(1)] public string MeshAssetRef { get; set; } = ""; - [Key(2)] public string MaterialAssetRef { get; set; } = ""; - [Key(3)] public int SubMeshIndex { get; set; } - [Key(4)] public int Layer { get; set; } - [Key(5)] public int InstanceCount { get; set; } - [Key(6)] public float DefaultScale { get; set; } = 1f; - [Key(7)] public float BoundsCenterX { get; set; } - [Key(8)] public float BoundsCenterY { get; set; } - [Key(9)] public float BoundsCenterZ { get; set; } - [Key(10)] public float BoundsSizeX { get; set; } - [Key(11)] public float BoundsSizeY { get; set; } - [Key(12)] public float BoundsSizeZ { get; set; } - [Key(13)] public string ShadowMode { get; set; } = "on"; - [Key(14)] public bool ReceiveShadows { get; set; } = true; - [Key(15)] public int Lod { get; set; } = -1; - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs deleted file mode 100644 index 356edee..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; -using System.Collections.Generic; -using GameCult.Caching; -using MessagePack; - -namespace GameCult.Eve.Surface -{ - [CultDocument("gamecult.eve.input_capability", SchemaId)] - [MessagePackObject] - public sealed class EveInputCapabilityDocument - { - public const string SchemaId = "gamecult.eve.input_capability.v1"; - [Key(0)] public string Schema { get; set; } = SchemaId; - [Key(1)] public string ProviderId { get; set; } = ""; - [Key(2)] public string CapabilityId { get; set; } = ""; - [Key(3)] public long Version { get; set; } - [Key(4)] public EveInputActionDocument[] Actions { get; set; } = Array.Empty(); - [Key(5)] public EveInputProfileDocument[] DefaultProfiles { get; set; } = Array.Empty(); - } - - [MessagePackObject] - public sealed class EveInputActionDocument - { - [Key(0)] public string ActionId { get; set; } = ""; - [Key(1)] public string Label { get; set; } = ""; - [Key(2)] public string Operation { get; set; } = ""; - [Key(3)] public string Context { get; set; } = "pilot"; - [Key(4)] public string Category { get; set; } = "ship"; - [Key(5)] public string Availability { get; set; } = "available"; - [Key(6)] public string SourceRef { get; set; } = ""; - [Key(7)] public Dictionary Payload { get; set; } = new Dictionary(StringComparer.Ordinal); - } - - [MessagePackObject] - public sealed class EveInputProfileDocument - { - [Key(0)] public string ProfileId { get; set; } = ""; - [Key(1)] public string DeviceClass { get; set; } = ""; - [Key(2)] public EveInputBindingDocument[] Bindings { get; set; } = Array.Empty(); - } - - [MessagePackObject] - public sealed class EveInputBindingDocument - { - [Key(0)] public string BindingId { get; set; } = ""; - [Key(1)] public string ActionId { get; set; } = ""; - [Key(2)] public EveInputGestureDocument Gesture { get; set; } = new EveInputGestureDocument(); - [Key(3)] public bool ActionBar { get; set; } - } - - [MessagePackObject] - public sealed class EveInputGestureDocument - { - [Key(0)] public string Kind { get; set; } = "direct"; - [Key(1)] public string[] Controls { get; set; } = Array.Empty(); - [Key(2)] public int MaxStepIntervalMs { get; set; } = 650; - [Key(3)] public string CompletionControl { get; set; } = ""; - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs.meta deleted file mode 100644 index 9a01871..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 4c297c7dbb64a0f49a7f805e770c5e55 \ No newline at end of file diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs b/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs deleted file mode 100644 index 35425a3..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace GameCult.Eve.Surface -{ - public sealed class EveInputDeviceLayout - { - public string LayoutId { get; set; } = ""; - public string DeviceClass { get; set; } = ""; - public IReadOnlyList Controls { get; set; } = Array.Empty(); - public bool SupportsDirectionalStrings { get; set; } - - public static EveInputDeviceLayout AnsiKeyboardMouse() => new EveInputDeviceLayout - { - LayoutId = "keyboard.ansi-104+mouse.standard", - DeviceClass = "keyboard-mouse", - Controls = new[] - { - Key("keyboard.escape", "Esc", 0, 0, 1.25f), Key("keyboard.f1", "F1", 2, 0), Key("keyboard.f2", "F2", 3, 0), - Key("keyboard.1", "1", 1, 1), Key("keyboard.2", "2", 2, 1), Key("keyboard.3", "3", 3, 1), Key("keyboard.4", "4", 4, 1), Key("keyboard.5", "5", 5, 1), - Key("keyboard.tab", "Tab", 0, 2, 1.5f), Key("keyboard.q", "Q", 1.5f, 2), Key("keyboard.w", "W", 2.5f, 2), Key("keyboard.e", "E", 3.5f, 2), Key("keyboard.r", "R", 4.5f, 2), - Key("keyboard.a", "A", 1.75f, 3), Key("keyboard.s", "S", 2.75f, 3), Key("keyboard.d", "D", 3.75f, 3), Key("keyboard.f", "F", 4.75f, 3), - Key("keyboard.leftShift", "Shift", 0, 4, 2.25f), Key("keyboard.space", "Space", 3.75f, 5, 6.25f), - Key("mouse.primary", "Mouse 1", 18, 1), Key("mouse.secondary", "Mouse 2", 19, 1), Key("mouse.middle", "Mouse 3", 18.5f, 2) - } - }; - - public static EveInputDeviceLayout StandardGamepad() => new EveInputDeviceLayout - { - LayoutId = "gamepad.standard", - DeviceClass = "gamepad", - SupportsDirectionalStrings = true, - Controls = new[] - { - Key("gamepad.leftStick", "Left Stick", 2, 3, 2), Key("gamepad.rightStick", "Right Stick", 8, 4, 2), - Key("gamepad.dpad.up", "Up", 1, 1), Key("gamepad.dpad.left", "Left", 0, 2), Key("gamepad.dpad.right", "Right", 2, 2), Key("gamepad.dpad.down", "Down", 1, 3), - Key("gamepad.buttonSouth", "A / Cross", 10, 3), Key("gamepad.buttonEast", "B / Circle", 11, 2), Key("gamepad.buttonWest", "X / Square", 9, 2), Key("gamepad.buttonNorth", "Y / Triangle", 10, 1), - Key("gamepad.leftTrigger", "LT / L2", 2, 0, 2), Key("gamepad.rightTrigger", "RT / R2", 9, 0, 2) - } - }; - - private static EveInputLayoutControl Key(string id, string label, float x, float y, float width = 1) => - new EveInputLayoutControl { Control = id, Label = label, X = x, Y = y, Width = width, Height = 1 }; - } - - public sealed class EveInputLayoutControl - { - public string Control { get; set; } = ""; - public string Label { get; set; } = ""; - public float X { get; set; } - public float Y { get; set; } - public float Width { get; set; } = 1; - public float Height { get; set; } = 1; - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs.meta deleted file mode 100644 index 0d7a9a2..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 8a2a1258551e42a9831a3e4f9483d928 diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs b/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs deleted file mode 100644 index a318066..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace GameCult.Eve.Surface -{ - public sealed class EveInputGesture - { - public string Kind { get; set; } = "direct"; - public IReadOnlyList Controls { get; set; } = Array.Empty(); - public int MaxStepIntervalMs { get; set; } = 650; - public string CompletionControl { get; set; } = ""; - } - - public sealed class EveInputBinding - { - public string BindingId { get; set; } = ""; - public string ActionId { get; set; } = ""; - public EveInputGesture Gesture { get; set; } = new EveInputGesture(); - public bool ActionBar { get; set; } - } - - public sealed class EveInputGestureResolver - { - private readonly IReadOnlyList _bindings; - private readonly HashSet _pressed = new HashSet(StringComparer.Ordinal); - private readonly Dictionary _sequences = new Dictionary(StringComparer.Ordinal); - - public EveInputGestureResolver(IEnumerable bindings) - { - _bindings = (bindings ?? Array.Empty()).Where(binding => binding != null).ToArray(); - } - - public IReadOnlyList Press(string control, long timestampMs) - { - _pressed.Add(control ?? ""); - var actions = new HashSet(StringComparer.Ordinal); - foreach (var binding in _bindings) - { - var gesture = binding.Gesture ?? new EveInputGesture(); - if (gesture.Kind == "direct" && gesture.Controls.FirstOrDefault() == control) - actions.Add(binding.ActionId); - else if (gesture.Kind == "chord" && gesture.Controls.Count > 0 && gesture.Controls.All(_pressed.Contains)) - actions.Add(binding.ActionId); - else if (gesture.Kind == "sequence" && Advance(binding, control ?? "", timestampMs)) - actions.Add(binding.ActionId); - } - return actions.ToArray(); - } - - public void Release(string control) => _pressed.Remove(control ?? ""); - - public void Reset() - { - _pressed.Clear(); - _sequences.Clear(); - } - - private bool Advance(EveInputBinding binding, string control, long timestampMs) - { - var controls = binding.Gesture.Controls; - if (controls.Count == 0) return false; - _sequences.TryGetValue(binding.BindingId, out var previous); - var index = previous.Index > 0 && timestampMs - previous.TimestampMs <= Math.Max(50, binding.Gesture.MaxStepIntervalMs) ? previous.Index : 0; - var next = controls[index] == control ? index + 1 : controls[0] == control ? 1 : 0; - if (next == controls.Count) - { - _sequences.Remove(binding.BindingId); - return string.IsNullOrWhiteSpace(binding.Gesture.CompletionControl); - } - _sequences[binding.BindingId] = new SequenceState(next, timestampMs); - return false; - } - - private readonly struct SequenceState - { - public SequenceState(int index, long timestampMs) { Index = index; TimestampMs = timestampMs; } - public int Index { get; } - public long TimestampMs { get; } - } - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs.meta deleted file mode 100644 index 3abaaab..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: e3c2941be243a754092ac2543d1e6bf7 \ No newline at end of file diff --git a/packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs deleted file mode 100644 index d213976..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs +++ /dev/null @@ -1,125 +0,0 @@ -using System; -using System.Collections.Generic; -using GameCult.Caching; -using MessagePack; - -#nullable enable - -namespace GameCult.Eve.Surface -{ - [CultDocument("gamecult.eve.plugin_advertisement", SchemaId)] - [MessagePackObject] - public sealed class EvePluginAdvertisementDocument - { - public const string SchemaId = "gamecult.eve.plugin_advertisement.v1"; - - public EvePluginAdvertisementDocument( - string pluginId, - string ownerService, - string version, - string manifestAddress, - EvePluginRuntimeAdvertisement runtime, - IReadOnlyList schemas, - IReadOnlyList componentKinds, - IReadOnlyList commands, - IReadOnlyList fixtures) - : this(SchemaId, pluginId, ownerService, version, manifestAddress, runtime, schemas, componentKinds, commands, fixtures) - { - } - - [SerializationConstructor] - public EvePluginAdvertisementDocument( - string schema, - string pluginId, - string ownerService, - string version, - string manifestAddress, - EvePluginRuntimeAdvertisement runtime, - IReadOnlyList schemas, - IReadOnlyList componentKinds, - IReadOnlyList commands, - IReadOnlyList fixtures) - { - Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; - PluginId = pluginId ?? ""; - OwnerService = ownerService ?? ""; - Version = version ?? ""; - ManifestAddress = manifestAddress ?? ""; - Runtime = runtime ?? new EvePluginRuntimeAdvertisement("", "", Array.Empty(), Array.Empty(), null); - Schemas = schemas ?? Array.Empty(); - ComponentKinds = componentKinds ?? Array.Empty(); - Commands = commands ?? Array.Empty(); - Fixtures = fixtures ?? Array.Empty(); - } - - [Key(0)] public string Schema { get; } - [Key(1), CultName] public string PluginId { get; } - [Key(2)] public string OwnerService { get; } - [Key(3)] public string Version { get; } - [Key(4)] public string ManifestAddress { get; } - [Key(5)] public EvePluginRuntimeAdvertisement Runtime { get; } - [Key(6)] public IReadOnlyList Schemas { get; } - [Key(7)] public IReadOnlyList ComponentKinds { get; } - [Key(8)] public IReadOnlyList Commands { get; } - [Key(9)] public IReadOnlyList Fixtures { get; } - } - - [MessagePackObject] - public sealed class EvePluginRuntimeAdvertisement - { - [SerializationConstructor] - public EvePluginRuntimeAdvertisement( - string invocationModel, - string contract, - IReadOnlyList transports, - IReadOnlyList authority, - EvePluginSidecarAdvertisement? sidecar) - { - InvocationModel = invocationModel ?? ""; - Contract = contract ?? ""; - Transports = transports ?? Array.Empty(); - Authority = authority ?? Array.Empty(); - Sidecar = sidecar; - } - - [Key(0)] public string InvocationModel { get; } - [Key(1)] public string Contract { get; } - [Key(2)] public IReadOnlyList Transports { get; } - [Key(3)] public IReadOnlyList Authority { get; } - [Key(4)] public EvePluginSidecarAdvertisement? Sidecar { get; } - } - - [MessagePackObject] - public sealed class EvePluginSidecarAdvertisement - { - [SerializationConstructor] - public EvePluginSidecarAdvertisement( - string processKind, - string protocol, - string requestSchema, - string responseSchema, - IReadOnlyList operations, - string commandEnvelope, - string receiptSchema, - string stateAuthority) - { - ProcessKind = processKind ?? ""; - Protocol = protocol ?? ""; - RequestSchema = requestSchema ?? ""; - ResponseSchema = responseSchema ?? ""; - Operations = operations ?? Array.Empty(); - CommandEnvelope = commandEnvelope ?? ""; - ReceiptSchema = receiptSchema ?? ""; - StateAuthority = stateAuthority ?? ""; - } - - [Key(0)] public string ProcessKind { get; } - [Key(1)] public string Protocol { get; } - [Key(2)] public string RequestSchema { get; } - [Key(3)] public string ResponseSchema { get; } - [Key(4)] public IReadOnlyList Operations { get; } - [Key(5)] public string CommandEnvelope { get; } - [Key(6)] public string ReceiptSchema { get; } - [Key(7)] public string StateAuthority { get; } - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs.meta deleted file mode 100644 index 69b1c21..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EvePluginAdvertisementDocument.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 7a5b91c7defe47d2a38940a460aa59c5 diff --git a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs deleted file mode 100644 index ec8e37f..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs +++ /dev/null @@ -1,289 +0,0 @@ -using System; -using System.Collections.Generic; -using GameCult.Caching; -using MessagePack; - -#nullable enable - -namespace GameCult.Eve.Surface -{ - [CultDocument("gamecult.eve.provider_advertisement", SchemaId)] - [MessagePackObject] - public sealed class EveProviderAdvertisementDocument - { - public const string SchemaId = "gamecult.eve.provider_advertisement.v1"; - - public EveProviderAdvertisementDocument( - string providerId, - string serviceId, - string verseId, - string title, - string kind, - string cultMeshAddress, - string updatedAtUtc, - EveProviderFreshness freshness, - IReadOnlyList schemas, - IReadOnlyList witnesses, - IReadOnlyList surfaces, - IReadOnlyList commands) - : this(SchemaId, providerId, serviceId, verseId, title, kind, cultMeshAddress, updatedAtUtc, freshness, schemas, witnesses, surfaces, commands) - { - } - - [SerializationConstructor] - public EveProviderAdvertisementDocument( - string schema, - string providerId, - string serviceId, - string verseId, - string title, - string kind, - string cultMeshAddress, - string updatedAtUtc, - EveProviderFreshness freshness, - IReadOnlyList schemas, - IReadOnlyList witnesses, - IReadOnlyList surfaces, - IReadOnlyList commands) - { - Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; - ProviderId = providerId ?? ""; - ServiceId = serviceId ?? ""; - VerseId = verseId ?? ""; - Title = title ?? ""; - Kind = kind ?? ""; - CultMeshAddress = cultMeshAddress ?? ""; - UpdatedAtUtc = updatedAtUtc ?? ""; - Freshness = freshness ?? new EveProviderFreshness("unknown", "", 0); - Schemas = schemas ?? Array.Empty(); - Witnesses = witnesses ?? Array.Empty(); - Surfaces = surfaces ?? Array.Empty(); - Commands = commands ?? Array.Empty(); - } - - [Key(0)] public string Schema { get; } - [Key(1)] public string ProviderId { get; } - [Key(2)] public string ServiceId { get; } - [Key(3)] public string VerseId { get; } - [Key(4)] public string Title { get; } - [Key(5)] public string Kind { get; } - [Key(6)] public string CultMeshAddress { get; } - [Key(7)] public string UpdatedAtUtc { get; } - [Key(8)] public EveProviderFreshness Freshness { get; } - [Key(9)] public IReadOnlyList Schemas { get; } - [Key(10)] public IReadOnlyList Witnesses { get; } - [Key(11)] public IReadOnlyList Surfaces { get; } - [Key(12)] public IReadOnlyList Commands { get; } - } - - [MessagePackObject] - public sealed class EveProviderFreshness - { - [SerializationConstructor] - public EveProviderFreshness(string state, string lastSeenAtUtc, long maxAgeMs) - { - State = state ?? ""; - LastSeenAtUtc = lastSeenAtUtc ?? ""; - MaxAgeMs = maxAgeMs; - } - - [Key(0)] public string State { get; } - [Key(1)] public string LastSeenAtUtc { get; } - [Key(2)] public long MaxAgeMs { get; } - } - - [MessagePackObject] - public sealed class EveProviderWitness - { - [SerializationConstructor] - public EveProviderWitness(string kind, string reference, string summary) - { - Kind = kind ?? ""; - Reference = reference ?? ""; - Summary = summary ?? ""; - } - - [Key(0)] public string Kind { get; } - [Key(1)] public string Reference { get; } - [Key(2)] public string Summary { get; } - } - - [MessagePackObject] - public sealed class EveAdvertisedSurface - { - [SerializationConstructor] - public EveAdvertisedSurface( - string surfaceId, - string schema, - string recordRef, - string transport, - string status, - string surfaceKind, - EveWorldInteractionAdvertisement? worldInteraction = null, - IReadOnlyList? requiresPlugins = null) - { - SurfaceId = surfaceId ?? ""; - Schema = schema ?? ""; - RecordRef = recordRef ?? ""; - Transport = transport ?? ""; - Status = status ?? ""; - SurfaceKind = surfaceKind ?? ""; - WorldInteraction = worldInteraction; - RequiresPlugins = requiresPlugins ?? Array.Empty(); - } - - [Key(0)] public string SurfaceId { get; } - [Key(1)] public string Schema { get; } - [Key(2)] public string RecordRef { get; } - [Key(3)] public string Transport { get; } - [Key(4)] public string Status { get; } - [Key(5)] public string SurfaceKind { get; } - [Key(6)] public EveWorldInteractionAdvertisement? WorldInteraction { get; } - [Key(7)] public IReadOnlyList RequiresPlugins { get; } - } - - [MessagePackObject] - public sealed class EvePluginRequirement - { - [SerializationConstructor] - public EvePluginRequirement( - string pluginId, - string versionRange, - string availability, - IReadOnlyList requiredCapabilities, - IReadOnlyList optionalCapabilities) - { - PluginId = pluginId ?? ""; - VersionRange = versionRange ?? ""; - Availability = string.IsNullOrWhiteSpace(availability) ? "required" : availability; - RequiredCapabilities = requiredCapabilities ?? Array.Empty(); - OptionalCapabilities = optionalCapabilities ?? Array.Empty(); - } - - [Key(0)] public string PluginId { get; } - [Key(1)] public string VersionRange { get; } - [Key(2)] public string Availability { get; } - [Key(3)] public IReadOnlyList RequiredCapabilities { get; } - [Key(4)] public IReadOnlyList OptionalCapabilities { get; } - } - - [MessagePackObject] - public sealed class EveWorldInteractionAdvertisement - { - [SerializationConstructor] - public EveWorldInteractionAdvertisement( - string projectionKind, - IReadOnlyList stateSchemas, - string commandBoundary, - string commandRecordRef, - string receiptSchema, - string receiptRecordRef, - string assetManifestRecordRef, - IReadOnlyList loweringTargets, - string ownership) - { - ProjectionKind = projectionKind ?? ""; - StateSchemas = stateSchemas ?? Array.Empty(); - CommandBoundary = commandBoundary ?? ""; - CommandRecordRef = commandRecordRef ?? ""; - ReceiptSchema = receiptSchema ?? ""; - ReceiptRecordRef = receiptRecordRef ?? ""; - AssetManifestRecordRef = assetManifestRecordRef ?? ""; - LoweringTargets = loweringTargets ?? Array.Empty(); - Ownership = ownership ?? ""; - } - - [Key(0)] public string ProjectionKind { get; } - [Key(1)] public IReadOnlyList StateSchemas { get; } - [Key(2)] public string CommandBoundary { get; } - [Key(3)] public string CommandRecordRef { get; } - [Key(4)] public string ReceiptSchema { get; } - [Key(5)] public string ReceiptRecordRef { get; } - [Key(6)] public string AssetManifestRecordRef { get; } - [Key(7)] public IReadOnlyList LoweringTargets { get; } - [Key(8)] public string Ownership { get; } - } - - [MessagePackObject] - public sealed class EveAdvertisedCommand - { - [SerializationConstructor] - public EveAdvertisedCommand(string command, string surfaceId, string transport, string summary) - { - Command = command ?? ""; - SurfaceId = surfaceId ?? ""; - Transport = transport ?? ""; - Summary = summary ?? ""; - } - - [Key(0)] public string Command { get; } - [Key(1)] public string SurfaceId { get; } - [Key(2)] public string Transport { get; } - [Key(3)] public string Summary { get; } - } - - [CultDocument("gamecult.eve.command_receipt", SchemaId)] - [MessagePackObject] - public sealed class EveCommandReceiptDocument - { - public const string SchemaId = "gamecult.eve.command_receipt.v1"; - - public EveCommandReceiptDocument( - string receiptId, - string commandId, - string command, - string state, - string ownerRepo, - string authority, - string providerId, - string surfaceId, - string message, - string issuedAtUtc, - long sourceVersion) - : this(SchemaId, receiptId, commandId, command, state, ownerRepo, authority, providerId, surfaceId, message, issuedAtUtc, sourceVersion) - { - } - - [SerializationConstructor] - public EveCommandReceiptDocument( - string schema, - string receiptId, - string commandId, - string command, - string state, - string ownerRepo, - string authority, - string providerId, - string surfaceId, - string message, - string issuedAtUtc, - long sourceVersion) - { - Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; - ReceiptId = receiptId ?? ""; - CommandId = commandId ?? ""; - Command = command ?? ""; - State = state ?? ""; - OwnerRepo = ownerRepo ?? ""; - Authority = authority ?? ""; - ProviderId = providerId ?? ""; - SurfaceId = surfaceId ?? ""; - Message = message ?? ""; - IssuedAtUtc = issuedAtUtc ?? ""; - SourceVersion = sourceVersion; - } - - [Key(0)] public string Schema { get; } - [Key(1)] public string ReceiptId { get; } - [Key(2)] public string CommandId { get; } - [Key(3)] public string Command { get; } - [Key(4)] public string State { get; } - [Key(5)] public string OwnerRepo { get; } - [Key(6)] public string Authority { get; } - [Key(7)] public string ProviderId { get; } - [Key(8)] public string SurfaceId { get; } - [Key(9)] public string Message { get; } - [Key(10)] public string IssuedAtUtc { get; } - [Key(11)] public long SourceVersion { get; } - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs.meta deleted file mode 100644 index f29c875..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: f7ea0cd07c4d5da4099b5acf8d44ca2e \ No newline at end of file diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs deleted file mode 100644 index 2d88eb5..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs +++ /dev/null @@ -1,874 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using GameCult.Mesh; - -#nullable enable - -namespace GameCult.Eve.Surface -{ - /// - /// Fluent CultUI/Eve surface builder. Eve owns the surface document; CultMesh only supplies typed binding descriptors. - /// - public sealed class EveSurfaceBuilder - { - private readonly string _surfaceId; - private readonly List _children = new(); - private readonly List _styles = new(); - private readonly List _commands = new(); - private EveSurfaceComponent? _root; - private long _version = 1; - private string _providerId = "gamecult"; - private string _providerKind = "cultui.surface"; - private string _title = ""; - private string _updatedAtUtc = ""; - - public EveSurfaceBuilder(string surfaceId) - { - _surfaceId = RequireNonEmpty(surfaceId, nameof(surfaceId)); - } - - public EveSurfaceBuilder Provider(string providerId, string providerKind) - { - _providerId = providerId ?? ""; - _providerKind = providerKind ?? ""; - return this; - } - - public EveSurfaceBuilder Title(string title) - { - _title = title ?? ""; - _children.Add(Text($"{_surfaceId}.title", _title, "text.title")); - return this; - } - - public EveSurfaceBuilder TitleSubtitle(string title, string subtitle) - { - _title = string.IsNullOrWhiteSpace(subtitle) ? title ?? "" : $"{title} {subtitle}"; - _children.Add(Text($"{_surfaceId}.title", title ?? "", "text.title")); - _children.Add(Text($"{_surfaceId}.subtitle", subtitle ?? "", "text.subtitle")); - return this; - } - - public EveSurfaceBuilder Text(string text, string? id = null) - { - _children.Add(Text(id ?? $"{_surfaceId}.text.{_children.Count}", text, "text")); - return this; - } - - public EveSurfaceBuilder Button(string label, string command) - { - var operation = CultMesh.OperationBinding(command, label); - _commands.Add(new EveCommandTemplate(operation)); - _children.Add(ButtonComponent($"{_surfaceId}.button.{Slug(label)}", label, operation)); - return this; - } - - public EveSurfaceBuilder Button(string label, CultMeshOperationBindingDescriptor operation) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _commands.Add(new EveCommandTemplate(operation)); - _children.Add(ButtonComponent($"{_surfaceId}.button.{Slug(label)}", label, operation)); - return this; - } - - public EveSurfaceBuilder ButtonColumn(string id, Action build) - { - return Group(id, "column", build); - } - - public EveSurfaceBuilder ButtonRow(string id, Action build) - { - return Group(id, "row", build); - } - - public EveSurfaceBuilder Form(string id, Action build) - { - if (build == null) throw new ArgumentNullException(nameof(build)); - var form = new EveSurfaceFormBuilder(id, AddCommand); - build(form); - _children.Add(form.Build()); - return this; - } - - public EveSurfaceBuilder RootColumn(string id, Action build) - { - _root = BuildContainer(id, "partition", Props(("split", "y")), build); - return this; - } - - public EveSurfaceBuilder RootRow(string id, Action build) - { - _root = BuildContainer(id, "partition", Props(("split", "x")), build); - return this; - } - - public EveSurfaceBuilder RootGrid( - string id, - int columns, - int rows, - Action build) - { - _root = BuildContainer( - id, - "grid", - Props( - ("columns", columns.ToString(System.Globalization.CultureInfo.InvariantCulture)), - ("rows", rows.ToString(System.Globalization.CultureInfo.InvariantCulture))), - build); - return this; - } - - public EveSurfaceBuilder Row(string id, Action build) - { - _children.Add(BuildContainer(id, "partition", Props(("split", "x")), build)); - return this; - } - - public EveSurfaceBuilder Column(string id, Action build) - { - _children.Add(BuildContainer(id, "partition", Props(("split", "y")), build)); - return this; - } - - public EveSurfaceBuilder Grid(string id, int columns, int rows, Action build) - { - _children.Add(BuildContainer( - id, - "grid", - Props( - ("columns", columns.ToString(System.Globalization.CultureInfo.InvariantCulture)), - ("rows", rows.ToString(System.Globalization.CultureInfo.InvariantCulture))), - build)); - return this; - } - - public EveSurfaceBuilder Modal(string id, string title, Action build) - { - _children.Add(BuildContainer(id, "modal", Props(("title", title ?? "")), build)); - return this; - } - - public EveSurfaceBuilder EmbeddedDocument( - string slotId, - string documentId, - string schemaId, - string presentationKind, - CultMeshRouteHint? routeHint = null) - { - _children.Add(new EveSurfaceComponent( - $"{_surfaceId}.slot.{Slug(slotId)}", - "surface.slot", - new Dictionary(StringComparer.Ordinal) - { - ["slotId"] = slotId ?? "", - ["documentId"] = documentId ?? "", - ["schemaId"] = schemaId ?? "", - ["presentationKind"] = presentationKind ?? "" - }, - Array.Empty(), - Array.Empty(), - new[] - { - new EveEmbeddedDocumentSlot( - slotId ?? "", - documentId ?? "", - schemaId ?? "", - presentationKind ?? "", - routeHint) - })); - return this; - } - - public EveSurfaceBuilder Style(string name, string value) - { - _styles.Add(new EveStyleToken(name, value)); - return this; - } - - public EveSurfaceBuilder Version(long version) - { - _version = version; - return this; - } - - public EveSurfaceBuilder UpdatedAtUtc(string updatedAtUtc) - { - _updatedAtUtc = updatedAtUtc ?? ""; - return this; - } - - public EveSurfaceDocument Build() - { - return new EveSurfaceDocument( - _providerId, - _providerKind, - _title, - _version, - string.IsNullOrWhiteSpace(_updatedAtUtc) ? DateTime.UtcNow.ToString("O") : _updatedAtUtc, - new EveSurfaceTree( - _surfaceId, - _root ?? new EveSurfaceComponent( - $"{_surfaceId}.root", - "surface", - EmptyProps(), - _children.ToArray()), - _styles.ToArray()), - _commands.ToArray()); - } - - private EveSurfaceBuilder Group(string id, string kind, Action build) - { - if (build == null) throw new ArgumentNullException(nameof(build)); - var group = new EveSurfaceGroupBuilder(id, kind, AddCommand); - build(group); - _children.Add(group.Build()); - return this; - } - - private EveSurfaceComponent BuildContainer( - string id, - string kind, - IReadOnlyDictionary props, - Action build) - { - if (build == null) throw new ArgumentNullException(nameof(build)); - var container = new EveSurfaceContainerBuilder(id, kind, props, AddCommand); - build(container); - return container.Build(); - } - - private void AddCommand(EveCommandTemplate command) - { - if (command != null) - _commands.Add(command); - } - - internal static EveSurfaceComponent Text(string id, string value, string kind) - { - return new EveSurfaceComponent( - id, - kind, - new Dictionary(StringComparer.Ordinal) - { - ["value"] = value ?? "" - }, - Array.Empty()); - } - - internal static EveSurfaceComponent ButtonComponent( - string id, - string label, - CultMeshOperationBindingDescriptor operation) - { - return new EveSurfaceComponent( - id, - "control.button", - new Dictionary(StringComparer.Ordinal) - { - ["label"] = label ?? operation.Label, - ["command"] = operation.OperationId, - ["operationId"] = operation.OperationId, - ["schemaId"] = operation.SchemaId - }, - Array.Empty()); - } - - internal static Dictionary EmptyProps() - { - return new Dictionary(StringComparer.Ordinal); - } - - internal static Dictionary Props(params (string Key, string Value)[] values) - { - return (values ?? Array.Empty<(string Key, string Value)>()) - .ToDictionary(value => value.Key, value => value.Value ?? "", StringComparer.Ordinal); - } - - internal static string Slug(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - return "unnamed"; - - var chars = value - .Select(character => char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : '.') - .ToArray(); - var slug = new string(chars).Trim('.'); - while (slug.Contains("..", StringComparison.Ordinal)) - slug = slug.Replace("..", ".", StringComparison.Ordinal); - return string.IsNullOrWhiteSpace(slug) ? "unnamed" : slug; - } - - private static string RequireNonEmpty(string value, string paramName) - { - return string.IsNullOrWhiteSpace(value) - ? throw new ArgumentException("Value must be non-empty.", paramName) - : value; - } - } - - public sealed class EveSurfaceContainerBuilder - { - private readonly string _id; - private readonly string _kind; - private readonly Dictionary _props; - private readonly Action _addCommand; - private readonly List _children = new(); - private readonly List _stateBindings = new(); - private readonly List _embeddedDocuments = new(); - - internal EveSurfaceContainerBuilder( - string id, - string kind, - IReadOnlyDictionary props, - Action addCommand) - { - _id = id ?? ""; - _kind = kind ?? "partition"; - _props = new Dictionary(props ?? EveSurfaceBuilder.EmptyProps(), StringComparer.Ordinal); - _addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand)); - } - - public EveSurfaceContainerBuilder Gap(int value) => Prop("gap", value); - - public EveSurfaceContainerBuilder Padding(int value) => Prop("padding", value); - - public EveSurfaceContainerBuilder Size(string value) => Prop("size", value); - - public EveSurfaceContainerBuilder Min(string value) => Prop("min", value); - - public EveSurfaceContainerBuilder Max(string value) => Prop("max", value); - - public EveSurfaceContainerBuilder Align(string value) => Prop("align", value); - - public EveSurfaceContainerBuilder Clip(string value = "hidden") => Prop("clip", value); - - public EveSurfaceContainerBuilder Scroll(string value = "y") => Prop("scroll", value); - - public EveSurfaceContainerBuilder Region(string value) => Prop("region", value); - - public EveSurfaceContainerBuilder Bind(CultMeshStateBindingDescriptor binding) - { - if (binding != null) - _stateBindings.Add(binding); - return this; - } - - public EveSurfaceContainerBuilder Row(string id, Action build) - { - _children.Add(BuildContainer(id, "partition", EveSurfaceBuilder.Props(("split", "x")), build)); - return this; - } - - public EveSurfaceContainerBuilder Column(string id, Action build) - { - _children.Add(BuildContainer(id, "partition", EveSurfaceBuilder.Props(("split", "y")), build)); - return this; - } - - public EveSurfaceContainerBuilder Grid( - string id, - int columns, - int rows, - Action build) - { - _children.Add(BuildContainer( - id, - "grid", - EveSurfaceBuilder.Props( - ("columns", columns.ToString(System.Globalization.CultureInfo.InvariantCulture)), - ("rows", rows.ToString(System.Globalization.CultureInfo.InvariantCulture))), - build)); - return this; - } - - public EveSurfaceContainerBuilder Pane(string id, string title, Action build) - { - _children.Add(BuildContainer(id, "pane", EveSurfaceBuilder.Props(("title", title ?? "")), build)); - return this; - } - - public EveSurfaceContainerBuilder Card(string id, string title, Action build) - { - _children.Add(BuildContainer(id, "card", EveSurfaceBuilder.Props(("title", title ?? "")), build)); - return this; - } - - public EveSurfaceContainerBuilder Modal(string id, string title, Action build) - { - _children.Add(BuildContainer(id, "modal", EveSurfaceBuilder.Props(("title", title ?? "")), build)); - return this; - } - - public EveSurfaceContainerBuilder FieldRow( - string id, - string label, - Action build) - { - return Row(id, row => row - .Gap(8) - .Label($"{id}.label", label, labelPart => labelPart.Size("12rem")) - .Column($"{id}.field", field => - { - field.Size("1fr"); - build(field); - })); - } - - public EveSurfaceContainerBuilder Label( - string id, - string text, - Action? configure = null) - { - return Leaf(id, "label", EveSurfaceBuilder.Props(("value", text ?? "")), null, null, configure); - } - - public EveSurfaceContainerBuilder Text(string id, string text) - { - return Leaf(id, "text", EveSurfaceBuilder.Props(("value", text ?? ""))); - } - - public EveSurfaceContainerBuilder Title(string id, string text) - { - return Leaf(id, "text.title", EveSurfaceBuilder.Props(("value", text ?? ""))); - } - - public EveSurfaceContainerBuilder Metric( - string id, - string label, - string value, - CultMeshStateBindingDescriptor? binding = null) - { - return Leaf( - id, - "metric", - EveSurfaceBuilder.Props(("label", label ?? ""), ("value", value ?? "")), - binding); - } - - public EveSurfaceContainerBuilder Progress( - string id, - string label, - string value, - string min, - string max, - CultMeshStateBindingDescriptor? binding = null) - { - return Leaf( - id, - "progress", - EveSurfaceBuilder.Props( - ("label", label ?? ""), - ("value", value ?? ""), - ("min", min ?? ""), - ("max", max ?? "")), - binding); - } - - public EveSurfaceContainerBuilder Button(string id, string label, string command) - { - return Button(id, label, CultMesh.OperationBinding(command, label)); - } - - public EveSurfaceContainerBuilder Button( - string id, - string label, - CultMeshOperationBindingDescriptor operation) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _addCommand(new EveCommandTemplate(operation)); - _children.Add(EveSurfaceBuilder.ButtonComponent(id, label, operation)); - return this; - } - - public EveSurfaceContainerBuilder PopupButton( - string id, - string label, - string title, - Action build, - string anchor = "viewport", - string placement = "center", - int offsetX = 0, - int offsetY = 0) - { - if (build == null) throw new ArgumentNullException(nameof(build)); - var popup = BuildContainer( - $"{id}.popup", - "modal", - EveSurfaceBuilder.Props( - ("title", title ?? label ?? ""), - ("anchor", anchor ?? "viewport"), - ("placement", placement ?? "center"), - ("offsetX", offsetX.ToString(System.Globalization.CultureInfo.InvariantCulture)), - ("offsetY", offsetY.ToString(System.Globalization.CultureInfo.InvariantCulture)), - ("presentation", "popover")), - build); - _children.Add(new EveSurfaceComponent( - id ?? "", - "control.popup", - EveSurfaceBuilder.Props( - ("label", label ?? ""), - ("title", title ?? label ?? ""), - ("anchor", anchor ?? "viewport"), - ("placement", placement ?? "center"), - ("offsetX", offsetX.ToString(System.Globalization.CultureInfo.InvariantCulture)), - ("offsetY", offsetY.ToString(System.Globalization.CultureInfo.InvariantCulture))), - new[] { popup })); - return this; - } - - public EveSurfaceContainerBuilder EnumField( - string id, - string label, - string value, - CultMeshStateBindingDescriptor optionsBinding, - CultMeshOperationBindingDescriptor setValue, - string anchor = "trigger", - string placement = "right-down") - { - return FieldRow(id, label, field => field.PopupButton( - $"{id}.dropdown", - value, - label, - popup => popup.OptionList($"{id}.options", label, optionsBinding, setValue), - anchor, - placement)); - } - - public EveSurfaceContainerBuilder OptionList( - string id, - string label, - CultMeshStateBindingDescriptor itemsBinding, - CultMeshOperationBindingDescriptor operation) - { - if (itemsBinding == null) throw new ArgumentNullException(nameof(itemsBinding)); - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _addCommand(new EveCommandTemplate(operation)); - _children.Add(new EveSurfaceComponent( - id ?? "", - "options", - EveSurfaceBuilder.Props( - ("label", label ?? ""), - ("command", operation.OperationId), - ("operationId", operation.OperationId), - ("schemaId", operation.SchemaId)), - Array.Empty(), - new[] { itemsBinding })); - return this; - } - - public EveSurfaceContainerBuilder TextInput( - string id, - string label, - string value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding = null) - { - return Control("control.input.text", id, label, value, operation, binding); - } - - public EveSurfaceContainerBuilder Toggle( - string id, - string label, - bool value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding = null) - { - return Control("control.toggle", id, label, value ? "true" : "false", operation, binding); - } - - public EveSurfaceContainerBuilder Slider( - string id, - string label, - string value, - string min, - string max, - string step, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding = null) - { - return Control( - "control.slider", - id, - label, - value, - operation, - binding, - ("min", min ?? ""), - ("max", max ?? ""), - ("step", step ?? "")); - } - - public EveSurfaceContainerBuilder Select( - string id, - string label, - string value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding = null) - { - return Control("control.select", id, label, value, operation, binding); - } - - public EveSurfaceContainerBuilder EmbeddedDocument( - string id, - string slotId, - string documentId, - string schemaId, - string presentationKind, - CultMeshRouteHint? routeHint = null) - { - _children.Add(new EveSurfaceComponent( - id ?? "", - "surface.slot", - EveSurfaceBuilder.Props( - ("slotId", slotId ?? ""), - ("documentId", documentId ?? ""), - ("schemaId", schemaId ?? ""), - ("presentationKind", presentationKind ?? "")), - Array.Empty(), - Array.Empty(), - new[] - { - new EveEmbeddedDocumentSlot( - slotId ?? "", - documentId ?? "", - schemaId ?? "", - presentationKind ?? "", - routeHint) - })); - return this; - } - - internal EveSurfaceComponent Build() - { - return new EveSurfaceComponent( - _id, - _kind, - _props, - _children.ToArray(), - _stateBindings.ToArray(), - _embeddedDocuments.ToArray()); - } - - private EveSurfaceContainerBuilder Prop(string key, int value) - { - _props[key] = value.ToString(System.Globalization.CultureInfo.InvariantCulture); - return this; - } - - private EveSurfaceContainerBuilder Prop(string key, string value) - { - _props[key] = value ?? ""; - return this; - } - - private EveSurfaceContainerBuilder Leaf( - string id, - string kind, - IReadOnlyDictionary props, - CultMeshStateBindingDescriptor? binding = null, - IReadOnlyList? children = null, - Action? configure = null) - { - var builder = new EveSurfaceContainerBuilder(id, kind, props, _addCommand); - configure?.Invoke(builder); - var component = new EveSurfaceComponent( - id ?? "", - kind ?? "", - new Dictionary(props ?? EveSurfaceBuilder.EmptyProps(), StringComparer.Ordinal), - children ?? Array.Empty(), - binding == null ? Array.Empty() : new[] { binding }); - if (builder._props.Count != (props?.Count ?? 0)) - { - component = new EveSurfaceComponent( - id ?? "", - kind ?? "", - new Dictionary(builder._props, StringComparer.Ordinal), - children ?? Array.Empty(), - binding == null ? Array.Empty() : new[] { binding }); - } - _children.Add(component); - return this; - } - - private EveSurfaceContainerBuilder Control( - string kind, - string id, - string label, - string value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding, - params (string Key, string Value)[] extraProps) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _addCommand(new EveCommandTemplate(operation)); - var props = EveSurfaceBuilder.Props( - new[] - { - ("label", label ?? ""), - ("value", value ?? ""), - ("command", operation.OperationId), - ("operationId", operation.OperationId), - ("schemaId", operation.SchemaId) - }.Concat(extraProps ?? Array.Empty<(string Key, string Value)>()).ToArray()); - return Leaf(kind: kind, id: id, props: props, binding: binding); - } - - private EveSurfaceComponent BuildContainer( - string id, - string kind, - IReadOnlyDictionary props, - Action build) - { - if (build == null) throw new ArgumentNullException(nameof(build)); - var container = new EveSurfaceContainerBuilder(id, kind, props, _addCommand); - build(container); - return container.Build(); - } - } - - public sealed class EveSurfaceGroupBuilder - { - private readonly string _id; - private readonly string _kind; - private readonly Action _addCommand; - private readonly List _children = new(); - - internal EveSurfaceGroupBuilder( - string id, - string kind, - Action addCommand) - { - _id = id ?? ""; - _kind = kind ?? "column"; - _addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand)); - } - - public EveSurfaceGroupBuilder Button(string label, string command) - { - var operation = CultMesh.OperationBinding(command, label); - _addCommand(new EveCommandTemplate(operation)); - _children.Add(EveSurfaceBuilder.ButtonComponent($"{_id}.button.{EveSurfaceBuilder.Slug(label)}", label, operation)); - return this; - } - - public EveSurfaceGroupBuilder Button(string label, CultMeshOperationBindingDescriptor operation) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _addCommand(new EveCommandTemplate(operation)); - _children.Add(EveSurfaceBuilder.ButtonComponent($"{_id}.button.{EveSurfaceBuilder.Slug(label)}", label, operation)); - return this; - } - - internal EveSurfaceComponent Build() - { - return new EveSurfaceComponent(_id, _kind, EveSurfaceBuilder.EmptyProps(), _children.ToArray()); - } - } - - public sealed class EveSurfaceFormBuilder - { - private readonly string _id; - private readonly Action _addCommand; - private readonly List _children = new(); - - internal EveSurfaceFormBuilder(string id, Action addCommand) - { - _id = id ?? ""; - _addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand)); - } - - public EveSurfaceFormBuilder Text( - string label, - string value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding = null) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _addCommand(new EveCommandTemplate(operation)); - _children.Add(Control( - "control.text", - label, - value, - operation, - binding)); - return this; - } - - public EveSurfaceFormBuilder Toggle( - string label, - bool value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding = null) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - _addCommand(new EveCommandTemplate(operation)); - _children.Add(Control( - "control.toggle", - label, - value ? "true" : "false", - operation, - binding)); - return this; - } - - public EveSurfaceFormBuilder Metric( - string label, - string value, - CultMeshStateBindingDescriptor? binding = null) - { - var bindings = binding == null - ? Array.Empty() - : new[] { binding }; - _children.Add(new EveSurfaceComponent( - $"{_id}.metric.{EveSurfaceBuilder.Slug(label)}", - "metric", - new Dictionary(StringComparer.Ordinal) - { - ["label"] = label ?? "", - ["value"] = value ?? "" - }, - Array.Empty(), - bindings)); - return this; - } - - internal EveSurfaceComponent Build() - { - return new EveSurfaceComponent(_id, "form", EveSurfaceBuilder.EmptyProps(), _children.ToArray()); - } - - private EveSurfaceComponent Control( - string kind, - string label, - string value, - CultMeshOperationBindingDescriptor operation, - CultMeshStateBindingDescriptor? binding) - { - var bindings = binding == null - ? Array.Empty() - : new[] { binding }; - return new EveSurfaceComponent( - $"{_id}.{EveSurfaceBuilder.Slug(label)}", - kind, - new Dictionary(StringComparer.Ordinal) - { - ["label"] = label ?? "", - ["value"] = value ?? "", - ["command"] = operation.OperationId, - ["operationId"] = operation.OperationId, - ["schemaId"] = operation.SchemaId - }, - Array.Empty(), - bindings); - } - } - - public static class EveSurface - { - public static EveSurfaceBuilder Create(string surfaceId) - { - return new EveSurfaceBuilder(surfaceId); - } - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs.meta deleted file mode 100644 index adc41da..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 97ac7f757692aef4db64d75d0df6fe51 \ No newline at end of file diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs deleted file mode 100644 index c4eb051..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs +++ /dev/null @@ -1,414 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using GameCult.Caching; -using GameCult.Mesh; -using MessagePack; - -#nullable enable - -namespace GameCult.Eve.Surface -{ - [CultDocument("gamecult.eve.surface", "gamecult.eve.surface.v1")] - [MessagePackObject] - public sealed class EveSurfaceDocument - { - public const string DefaultType = "surface-state"; - public const string SchemaId = "gamecult.eve.surface.v1"; - - public EveSurfaceDocument( - string providerId, - string providerKind, - string title, - long version, - string updatedAtUtc, - EveSurfaceTree surface, - IReadOnlyList commands) - : this(DefaultType, SchemaId, providerId, providerKind, title, version, updatedAtUtc, surface, commands) - { - } - - [SerializationConstructor] - public EveSurfaceDocument( - string type, - string schema, - string providerId, - string providerKind, - string title, - long version, - string updatedAtUtc, - EveSurfaceTree surface, - IReadOnlyList commands) - { - Type = string.IsNullOrWhiteSpace(type) ? DefaultType : type; - Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; - ProviderId = providerId ?? ""; - ProviderKind = providerKind ?? ""; - Title = title ?? ""; - Version = version; - UpdatedAtUtc = updatedAtUtc ?? ""; - Surface = surface ?? throw new ArgumentNullException(nameof(surface)); - Commands = commands ?? Array.Empty(); - } - - [Key(0)] - public string Type { get; } - - [Key(1)] - public string Schema { get; } - - [Key(2)] - public string ProviderId { get; } - - [Key(3)] - public string ProviderKind { get; } - - [Key(4)] - public string Title { get; } - - [Key(5)] - public long Version { get; } - - [Key(6)] - public string UpdatedAtUtc { get; } - - [Key(7)] - public EveSurfaceTree Surface { get; } - - [Key(8)] - public IReadOnlyList Commands { get; } - } - - [MessagePackObject] - public sealed class EveSurfaceTree - { - [SerializationConstructor] - public EveSurfaceTree(string id, EveSurfaceComponent root, IReadOnlyList styles) - { - Id = id ?? ""; - Root = root ?? throw new ArgumentNullException(nameof(root)); - Styles = styles ?? Array.Empty(); - } - - [Key(0)] - public string Id { get; } - - [Key(1)] - public EveSurfaceComponent Root { get; } - - [Key(2)] - public IReadOnlyList Styles { get; } - } - - [MessagePackObject] - public sealed class EveSurfaceComponent - { - public EveSurfaceComponent( - string id, - string kind, - IReadOnlyDictionary props, - IReadOnlyList children) - : this(id, kind, props, children, StateBindingsFromProps(props)) - { - } - - public EveSurfaceComponent( - string id, - string kind, - IReadOnlyDictionary props, - IReadOnlyList children, - IReadOnlyList stateBindings) - : this(id, kind, props, children, stateBindings, Array.Empty()) - { - } - - public EveSurfaceComponent( - string id, - string kind, - IReadOnlyDictionary props, - IReadOnlyList children, - IReadOnlyList stateBindings, - IReadOnlyList embeddedDocuments, - IReadOnlyDictionary? layout = null, - IReadOnlyDictionary? style = null) - : this( - id, - kind, - props, - children, - (stateBindings ?? Array.Empty()) - .Select(CultMeshStateBindingRecord.FromBinding) - .ToArray(), - embeddedDocuments, - layout, - style) - { - } - - [SerializationConstructor] - public EveSurfaceComponent( - string id, - string kind, - IReadOnlyDictionary props, - IReadOnlyList children, - CultMeshStateBindingRecord[] stateBindingRecords, - IReadOnlyList embeddedDocuments, - IReadOnlyDictionary? layout = null, - IReadOnlyDictionary? style = null) - { - Id = id ?? ""; - Kind = kind ?? ""; - Props = props ?? new Dictionary(StringComparer.Ordinal); - Children = children ?? Array.Empty(); - StateBindingRecords = stateBindingRecords ?? Array.Empty(); - EmbeddedDocuments = embeddedDocuments ?? Array.Empty(); - Layout = layout ?? new Dictionary(StringComparer.Ordinal); - Style = style ?? new Dictionary(StringComparer.Ordinal); - } - - [Key(0)] - public string Id { get; } - - [Key(1)] - public string Kind { get; } - - [Key(2)] - public IReadOnlyDictionary Props { get; } - - [Key(3)] - public IReadOnlyList Children { get; } - - [Key(4)] - public CultMeshStateBindingRecord[] StateBindingRecords { get; } - - [IgnoreMember] - public IReadOnlyList StateBindings => - StateBindingRecords.Select(record => record.ToBinding()).ToArray(); - - [Key(5)] - public IReadOnlyList EmbeddedDocuments { get; } - - [Key(6)] - public IReadOnlyDictionary Layout { get; } - - [Key(7)] - public IReadOnlyDictionary Style { get; } - - public string GetProp(string key, string fallback = "") - { - return Props.TryGetValue(key, out var value) ? value : fallback; - } - - private static IReadOnlyList StateBindingsFromProps( - IReadOnlyDictionary? props) - { - if (props == null || props.Count == 0) - return Array.Empty(); - - var bindings = new List(); - foreach (var prop in props) - { - if (string.IsNullOrWhiteSpace(prop.Value) || - !prop.Key.EndsWith("PointerId", StringComparison.Ordinal)) - { - continue; - } - - var targetProp = prop.Key.Substring(0, prop.Key.Length - "PointerId".Length); - if (targetProp.Length == 0) - targetProp = "value"; - bindings.Add(new CultMeshStateBindingDescriptor(targetProp, prop.Value)); - } - - return bindings; - } - } - - [MessagePackObject] - public sealed class EveEmbeddedDocumentSlot - { - public EveEmbeddedDocumentSlot( - string slotId, - string documentId, - string schemaId, - string presentationKind, - CultMeshRouteHint? routeHint = null) - : this(slotId, documentId, schemaId, presentationKind, CultMeshRouteRecord.FromRoute(routeHint)) - { - } - - [SerializationConstructor] - public EveEmbeddedDocumentSlot( - string slotId, - string documentId, - string schemaId, - string presentationKind, - CultMeshRouteRecord route) - { - SlotId = slotId ?? ""; - DocumentId = documentId ?? ""; - SchemaId = schemaId ?? ""; - PresentationKind = presentationKind ?? ""; - Route = route ?? CultMeshRouteRecord.FromRoute(CultMeshRouteHint.Automatic); - } - - [Key(0)] - public string SlotId { get; } - - [Key(1)] - public string DocumentId { get; } - - [Key(2)] - public string SchemaId { get; } - - [Key(3)] - public string PresentationKind { get; } - - [Key(4)] - public CultMeshRouteRecord Route { get; } - - [IgnoreMember] - public CultMeshRouteHint RouteHint => Route.ToRoute(); - } - - [MessagePackObject] - public sealed class EveStyleToken - { - [SerializationConstructor] - public EveStyleToken(string name, string value) - { - Name = name ?? ""; - Value = value ?? ""; - } - - [Key(0)] - public string Name { get; } - - [Key(1)] - public string Value { get; } - } - - [MessagePackObject] - public sealed class EveCommandTemplate - { - public EveCommandTemplate(CultMeshOperationBindingDescriptor operation) - : this(CultMeshOperationBindingRecord.FromBinding(operation)) - { - } - - [SerializationConstructor] - public EveCommandTemplate(CultMeshOperationBindingRecord operationRecord) - { - OperationRecord = operationRecord ?? throw new ArgumentNullException(nameof(operationRecord)); - } - - [Key(0)] - public CultMeshOperationBindingRecord OperationRecord { get; } - - [IgnoreMember] - public CultMeshOperationBindingDescriptor Operation => OperationRecord.ToBinding(); - - [IgnoreMember] - public string Command => Operation.OperationId; - - [IgnoreMember] - public string Label => Operation.Label; - - [IgnoreMember] - public string Transport => Operation.RouteHint.Description ?? ""; - } - - [CultDocument("gamecult.eve.command", SchemaId)] - [MessagePackObject] - public sealed class EveSurfaceCommandRequest - { - public const string SchemaId = "gamecult.eve.command_invocation.v1"; - - public EveSurfaceCommandRequest( - string providerId, - string surfaceId, - CultMeshOperationInvocationDescriptor operation, - CultMeshOperationPayload payload, - DateTimeOffset issuedAt, - string clientId, - string commandBoundary = "", - string receiptSchema = "") - : this( - SchemaId, - providerId, - surfaceId, - CultMeshOperationInvocationRecord.FromInvocation(operation), - payload?.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.Ordinal) - ?? new Dictionary(StringComparer.Ordinal), - issuedAt, - clientId, - commandBoundary, - receiptSchema) - { - } - - [SerializationConstructor] - public EveSurfaceCommandRequest( - string schema, - string providerId, - string surfaceId, - CultMeshOperationInvocationRecord operation, - Dictionary payload, - DateTimeOffset issuedAt, - string clientId, - string commandBoundary, - string receiptSchema) - { - Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; - ProviderId = providerId; - SurfaceId = surfaceId; - OperationRecord = operation ?? throw new ArgumentNullException(nameof(operation)); - PayloadFields = payload == null - ? new Dictionary(StringComparer.Ordinal) - : new Dictionary(payload, StringComparer.Ordinal); - IssuedAt = issuedAt; - ClientId = clientId; - CommandBoundary = commandBoundary ?? ""; - ReceiptSchema = receiptSchema ?? ""; - } - - [Key(0)] - public string Schema { get; } - - [Key(1)] - public string ProviderId { get; } - - [Key(2)] - public string SurfaceId { get; } - - [Key(3)] - public CultMeshOperationInvocationRecord OperationRecord { get; } - - [IgnoreMember] - public CultMeshOperationInvocationDescriptor Operation => OperationRecord.ToInvocation(); - - [IgnoreMember] - public string Command => Operation.OperationId; - - [Key(4)] - public Dictionary PayloadFields { get; } - - [IgnoreMember] - public CultMeshOperationPayload Payload => new CultMeshOperationPayload(PayloadFields); - - [Key(5)] - public DateTimeOffset IssuedAt { get; } - - [Key(6)] - public string ClientId { get; } - - [Key(7)] - public string CommandBoundary { get; } - - [Key(8)] - public string ReceiptSchema { get; } - - [IgnoreMember] - public string CommandId => Operation.IdempotencyKey ?? ""; - } -} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs.meta deleted file mode 100644 index ebd5f02..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 6516c42104b235f42b15efeb4639bb73 \ No newline at end of file diff --git a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef deleted file mode 100644 index 8041c88..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "GameCult.Eve.Surface", - "rootNamespace": "GameCult.Eve.Surface", - "references": [ - "GameCult.Mesh" - ], - "includePlatforms": [], - "excludePlatforms": [], - "allowUnsafeCode": false, - "overrideReferences": true, - "precompiledReferences": [ - "GameCult.Caching.dll", - "GameCult.Mesh.dll", - "MessagePack.Annotations.dll" - ], - "autoReferenced": true, - "defineConstraints": [], - "versionDefines": [], - "noEngineReferences": true -} diff --git a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef.meta b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef.meta deleted file mode 100644 index d8f543e..0000000 --- a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: a9004278c14816345941af2f2ce673ca -AssemblyDefinitionImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/org.gamecult.eve.surface/package.json b/packages/org.gamecult.eve.surface/package.json deleted file mode 100644 index 7c99f0e..0000000 --- a/packages/org.gamecult.eve.surface/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "org.gamecult.eve.surface", - "displayName": "Eve Surface Contract", - "version": "0.1.0", - "unity": "2021.3", - "description": "Unity-compatible DTOs for gamecult.eve.surface.v1 documents.", - "dependencies": { - "org.gamecult.cultlib": "1.0.6" - }, - "keywords": [ - "Eve", - "CultUI", - "CultMesh", - "Surface" - ], - "license": "MPL-2.0", - "category": "Scripting" -} diff --git a/packages/org.gamecult.eve.surface/package.json.meta b/packages/org.gamecult.eve.surface/package.json.meta deleted file mode 100644 index 8e89223..0000000 --- a/packages/org.gamecult.eve.surface/package.json.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 49f1738bd94075e489c82190cf660b1d -PackageManifestImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/README.md b/packages/org.gamecult.eve.unity-scene/README.md index d93183b..52d0a60 100644 --- a/packages/org.gamecult.eve.unity-scene/README.md +++ b/packages/org.gamecult.eve.unity-scene/README.md @@ -5,22 +5,127 @@ interactive worlds. Providers implement the surface-document, asset-manifest, command-sink, and receipt-source ports. `EveUnityPlayableWorldClientBootstrap` wires those ports -to the generic scene host, input driver, camera rig, and `GameObject` sink. +to the generic scene host, input driver, camera rig, `GameObject` sink, and UI +Toolkit surface overlay. The overlay lowers provider-owned panes, metrics, +progress, inventory, and controls from the same Eve document used by the scene. +Stable structures update their native controls in place; only structural +changes rebuild the visual tree. Scene, field, and reactive render subtrees are +owned by their native lowerers and are not duplicated into empty UI elements. `EveUnityCultMeshPlayableWorldProvider` is the runtime composition root for a networked client. Give it one Verse rendezvous endpoint and it discovers an advertised `interactive-world`, constructs the generic CultMesh transport, and provides every bootstrap port. Provider, surface, and Verse IDs are optional -selection filters; they are not product knowledge required by the client. +selection filters; they are not product knowledge required by the client. The +selected Verse ID and its advertised authority-runtime ID form the stable +CultMesh session target. The Eve provider ID separately names the UI owner found +through that target. Physical routes are resolved and re-resolved inside +`CultMeshClient`; they are never published as provider selection state or +cached as Unity authority. The optional local path is only an ephemeral +content/body cache, not a replicated gameplay database. +Await `PrepareAsync()` before mounting the bootstrap or reading provider ports. +It owns discovery, initial document reads, content transfer, asynchronous asset +bundle loading, and live lease installation. Once preparation succeeds, +`Mount()` and `Connect()` perform no remote work. Input submission only enters a +bounded asynchronous outbox: one-shot commands retain their idempotency keys, +while movement and look values coalesce to the latest value per entity. A +successful send is not completion: the command remains pending and retries with +the same idempotency key until its canonical provider receipt arrives. Failed +or unacknowledged commands rotate behind other due work, so one poison command +cannot starve the outbox. +Live entity, field, and receipt polling likewise remains non-blocking. Unity's +main thread applies completed typed documents and assets; it never waits for a +CultMesh task, keeping Play, Pause, input, inspection, and rendering responsive +while a provider is slow or temporarily unreachable. + +An Eve world may qualify its asset catalog with a different provider, Verse, +authority runtime, and Odin rendezvous set than the surface provider. The +transport resolves that exact asset session and commits assets as one generation: +catalog, verified bundles, leases, lookups, render policy, and surface lowering +are prepared off to the side, then swapped together. A failed or superseded +candidate is disposed without unloading or rewriting the mounted generation. +The mounted provider's trust policy authenticates only that provider. A +provider-qualified asset source in another Verse/runtime uses the configured +cross-target Odin trust for its catalog, subscription, manifests, content, and +bundles; local-development trust does not cross that boundary. +The candidate opens its catalog lease before preload, observes updates in a +candidate-owned buffer, and verifies the leased version again before commit. +An update that lands during preload therefore rebuilds the candidate instead +of disappearing between a one-shot read and a later subscription. + +Navigation keeps the outgoing presentation quiesced until the candidate route +is ready, then commits the provider route before activating the candidate. +Presentation connections are provider-owned leases: disposing the outgoing +host releases only its lease and cannot disconnect the newly committed Verse. The package never imports provider product assemblies. +Add `EveUnityRendererFeature` to the Universal Renderer used by Eve cameras. +The feature is the single URP-owned scheduling point for provider-advertised +volume, particle, and exposure programs. The lowerers use Unity 6 RenderGraph +resource declarations; effects that sample camera color request an intermediate +color texture and never read the back buffer. Do not schedule these effects from +`RenderPipelineManager` callbacks. The provider owns programs, assets, and +parameters; the Universal Renderer owns when and where they execute. +Provider volume programs render through both Game and SceneView cameras. Each +camera owns separate raymarch and temporal-history targets, so editor preview +cannot resize or contaminate the playable camera's history. Presentation-only +exposure remains bound to the leased Game camera. + +Advertised input bindings are performed from the provider's semantic action +catalog. Direct and chord gestures submit once per press edge; held values +submit their advertised press/release scalar. `view-direction.v1` samples the +active world camera's normalized world-space forward direction into the three +advertised payload keys. Providers own what that ray means; EveUnity never +selects targets or imports provider gameplay policy. + +`EveUnityInputActionBar` lowers provider suggestions and profile-visible actions +from that same catalog. It resolves `iconRef` through the provider asset +manifest, disables unavailable actions, preserves press/release behavior for +held controls, samples the active camera for view-direction controls, and +submits `scalar.v1` values from an explicit numeric field. Its named UI Toolkit +elements and classes are presentation levers; the component never imports a +provider assembly or mutates provider-owned current values optimistically. + +`IEveUnityPresentedEntityRegistry` exposes each committed SoA generation as +immutable entity facts plus presentation transforms, addressable by Eve entity +id or source index. Camera, selection, and provider UI adapters consume that +registry instead of reconstructing provider gameplay objects. It owns +presentation lookup only; simulation state and commands remain provider-owned. + +World surfaces may exclude semantic render channels through the +`excludedRenderChannels` world-root property. Runtime asset variants map those +channel names to Unity layers with `renderChannel..unityLayer` +metadata. Surfaces never publish Unity layer numbers or camera masks: providers +own which semantic content belongs in a view, while EveUnity owns the native +layer mapping and subtracts only those layers from the camera's configured +culling mask. + +World surfaces may also advertise a presentation-only directional key light +through `keyLightDirection`, `keyLightColor`, and `keyLightIntensity`. +EveUnity lowers those generic values into a transient Unity light and removes +it when the world camera lease ends. The provider owns the lighting design; +the light never becomes physics, visibility, or gameplay authority. + Retained provider feedback is lowered without becoming Unity authority. `EveUnityFeedbackPresenter` emits each new `feedback.event` identity once, and `EveUnityShotReceiptPresenter` does the same for `shot.receipt` trajectories. The generic `EveUnityShotTrajectoryRenderer` renders provider-authored origin, endpoint, duration, and hit outcome, then expires the visual locally. It never uses Unity collision to infer damage or report a hit back to the provider. +Providers may publish a non-spatial `combat.presentation` component beside the +SoA body view. `EveUnityCombatPresentationRenderer` lowers its selected target, +contact visibility, lock progress, meter ratios, semantic roles, and timing +into reticle, lock, shield/hull, and hit-marker visuals. Hit markers require a +new deduplicated shot receipt from the advertised controlled source to the +advertised selection with authoritative applied or shield-absorbed damage. +SoA remains the only transform authority; the presentation component never +duplicates bodies or computes combat outcomes. +Continuous `beam.presentation` nodes bind provider-authored effect assets to +the current presented-entity generation. The generic beam renderer follows the +advertised source transform, applies provider-owned power to particle emission, +and reconciles asset lifecycles by semantic role. It never raycasts, applies +force, infers contact, collects cargo, or emits gameplay receipts. The reusable `LightningCompute` renderer and its shader/material bundle live in this runtime package with their original Unity GUIDs. Provider asset bundles may therefore reference the effect without importing Aetheria gameplay code. @@ -29,3 +134,55 @@ therefore reference the effect without importing Aetheria gameplay code. `org.gamecult.eve.plugin-fields` contracts. `EveFieldsSplatRasterizer` owns the Unity structured-buffer and RenderTexture lowering without importing provider document classes. + +`field.volume3d` surfaces name logical texture ports, feature names, quality, +and authored scalar/vector parameters. Concrete Unity shader properties, +keywords, and pass indices belong to the selected `unity-scene` asset variant +under `unity.volume.*` metadata. The lowerer fails closed when that native +program descriptor is absent or incomplete. This keeps provider shader ABIs +out of Eve's semantic surface while allowing a provider-owned shader to consume +portable Eve Fields documents. Volume composition runs as a generic URP render +pass before post-processing so the provider-advertised profile grades both the +native scene and the provider volume through the same camera pipeline. + +Camera-relative field consumers share `viewportAnchor`, `span`, +`cellWorldSize`, `viewportSnapLayer`, and `viewportSnapTexels` as one viewport +frame contract. Volume and particle lowerers resolve that frame through the +same generic path, so a snapped stateless particle lattice cannot drift from +the field textures it samples. A `world.scene3d` may also request +`temporal-reprojection.v1`; EveUnity lowers its neutral history blend, jitter, +quality, and sharpening values to URP TAA and restores the previous camera +state when the world releases the camera. + +Native volume programs also advertise the projection convention consumed by +their history matrix. `non-render-target-projection.previous-view.v1` preserves +the projection convention used by legacy `Graphics.Blit` temporal shaders even +when EveUnity executes the program through a render-target-backed URP pass; +providers must not rely on a runtime guessing that Y-axis convention. + +`field.particles3d` may advertise provider material textures, viewport-relative +texture scales, and a logical render-frame index port. These are presentation +inputs: EveUnity binds the selected provider assets and current display frame +without assigning product meaning to the shader. Stateless simulation time and +particle positions remain provider-document inputs. + +Providers may advertise per-layer resolution scales, mipmap use, and filtering +through `layerTargetDescriptors`; the Unity lowerer applies those native target +requirements without assigning gameplay or product meaning to the layer keys. +`documentFloatBindings` maps standard document scalars to logical float ports as +`source=port,scale,offset`. This lets animated native programs consume +provider-authoritative simulation time without making the client clock an +authority. +Native volume programs may advertise a `cameraToWorld` matrix port when their +procedural passes require an explicit camera transform instead of relying on +render-pipeline globals. + +Command finality is presentation-causal. When a terminal receipt names a +`sourceVersion` newer than the mounted surface, EveUnity keeps the command and +its exact receipt lease alive, makes the obsolete surface read-only, and emits +the terminal receipt only after that surface generation and its qualified asset +catalog have committed together. Asset preparation failure therefore cannot +leave an accepted Verse switch paired with actionable controls from the prior +Verse. Embedded surfaces may recompose the visible tree, but their versions are +derived rendering inputs and cannot satisfy or clear the advertised base +surface's finality barrier. diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/AssemblyInfo.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/AssemblyInfo.cs new file mode 100644 index 0000000..61285ea --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("GameCult.Eve.UnityScene.Tests")] diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/AssemblyInfo.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/AssemblyInfo.cs.meta new file mode 100644 index 0000000..db8b4c5 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/AssemblyInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 68e2f1a625b94fc39dcc44d33e0cf318 diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs new file mode 100644 index 0000000..f8c89fa --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs @@ -0,0 +1,296 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GameCult.Eve.Surface; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + /// + /// Owns receipt-backed asynchronous command delivery for the CultMesh Unity + /// transport. A successful transport send only moves a command into + /// AwaitingCanonicalReceipt. The provider receipt is the sole completion + /// signal. Retries retain the original idempotency key, and one failed + /// command cannot monopolize delivery of the rest of the bounded outbox. + /// + internal sealed class EveUnityCultMeshCommandOutbox : IDisposable + { + internal enum DeliveryState + { + Queued, + Sending, + AwaitingCanonicalReceipt + } + + private readonly object _gate = new object(); + private readonly List _items = new List(); + private readonly Dictionary _byCommandId = + new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _continuous = + new Dictionary(StringComparer.Ordinal); + private readonly Func _send; + private readonly Func _continuousKey; + private readonly Action _discard; + private readonly int _commandCapacity; + private readonly int _continuousCapacity; + private readonly TimeSpan _retryDelay; + private readonly TimeSpan _maximumRetryDelay; + private readonly TimeSpan _sendAttemptTimeout; + private readonly SemaphoreSlim _changed = new SemaphoreSlim(0); + private readonly CancellationTokenSource _lifetime = new CancellationTokenSource(); + private readonly Task _worker; + private bool _disposed; + private long _sequence; + + public EveUnityCultMeshCommandOutbox( + Func send, + Func continuousKey, + Action? discard = null, + int commandCapacity = 256, + int continuousCapacity = 32, + TimeSpan? retryDelay = null, + TimeSpan? maximumRetryDelay = null, + TimeSpan? sendAttemptTimeout = null) + { + _send = send ?? throw new ArgumentNullException(nameof(send)); + _continuousKey = continuousKey ?? throw new ArgumentNullException(nameof(continuousKey)); + _discard = discard ?? (_ => { }); + if (commandCapacity <= 0) throw new ArgumentOutOfRangeException(nameof(commandCapacity)); + if (continuousCapacity <= 0) throw new ArgumentOutOfRangeException(nameof(continuousCapacity)); + _commandCapacity = commandCapacity; + _continuousCapacity = continuousCapacity; + _retryDelay = retryDelay ?? TimeSpan.FromMilliseconds(100); + _maximumRetryDelay = maximumRetryDelay ?? TimeSpan.FromSeconds(2); + _sendAttemptTimeout = sendAttemptTimeout ?? TimeSpan.FromSeconds(2); + if (_retryDelay <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(retryDelay)); + if (_maximumRetryDelay < _retryDelay) throw new ArgumentOutOfRangeException(nameof(maximumRetryDelay)); + if (_sendAttemptTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(sendAttemptTimeout)); + _worker = Task.Run(DeliverAsync); + } + + internal int PendingCount + { + get { lock (_gate) return _items.Count; } + } + + internal DeliveryState? StateOf(string commandId) + { + lock (_gate) + return _byCommandId.TryGetValue(commandId, out var item) ? item.State : null; + } + + public void Enqueue(EveSurfaceCommandRequest request) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + if (string.IsNullOrWhiteSpace(request.CommandId)) + throw new InvalidOperationException("Eve command invocations require an idempotency key."); + + string? discarded = null; + lock (_gate) + { + if (_disposed) throw new ObjectDisposedException(nameof(EveUnityCultMeshCommandOutbox)); + if (_byCommandId.ContainsKey(request.CommandId)) + throw new InvalidOperationException($"Eve command '{request.CommandId}' is already pending."); + + var key = _continuousKey(request); + key = string.IsNullOrWhiteSpace(key) ? null : key; + if (key == null) + { + if (_items.Count(item => item.ContinuousKey == null) >= _commandCapacity) + throw new InvalidOperationException( + $"The EveUnity command outbox reached its {_commandCapacity}-command bound."); + } + else if (_continuous.TryGetValue(key, out var previous)) + { + RemoveLocked(previous); + discarded = previous.Request.CommandId; + } + else if (_continuous.Count >= _continuousCapacity) + { + throw new InvalidOperationException( + $"The EveUnity continuous-control outbox reached its {_continuousCapacity}-binding bound."); + } + + var item = new OutboxItem(request, key, ++_sequence); + _items.Add(item); + _byCommandId.Add(request.CommandId, item); + if (key != null) _continuous[key] = item; + } + + if (discarded != null) _discard(discarded); + _changed.Release(); + } + + /// Completes a command only when its canonical provider receipt arrives. + public bool Acknowledge(string commandId) + { + if (string.IsNullOrWhiteSpace(commandId)) return false; + lock (_gate) + { + if (!_byCommandId.TryGetValue(commandId, out var item)) return false; + RemoveLocked(item); + } + _changed.Release(); + return true; + } + + public void Dispose() + { + OutboxItem[] discarded; + lock (_gate) + { + if (_disposed) return; + _disposed = true; + discarded = _items.ToArray(); + _items.Clear(); + _byCommandId.Clear(); + _continuous.Clear(); + } + foreach (var item in discarded) _discard(item.Request.CommandId); + _lifetime.Cancel(); + _changed.Release(); + } + + private async Task DeliverAsync() + { + var cancellationToken = _lifetime.Token; + try + { + while (true) + { + var selection = TakeNext(); + if (selection.Item == null) + { + await WaitForChangeOrDueAsync(selection.Delay, cancellationToken).ConfigureAwait(false); + continue; + } + await DeliverOnceAsync(selection.Item, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + private async Task DeliverOnceAsync(OutboxItem item, CancellationToken cancellationToken) + { + var sent = false; + using (var attempt = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + attempt.CancelAfter(_sendAttemptTimeout); + try + { + await _send(item.Request, attempt.Token).ConfigureAwait(false); + sent = true; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + // The same idempotency key is retried after other due items. + } + } + + lock (_gate) + { + if (!_byCommandId.TryGetValue(item.Request.CommandId, out var current) || + !ReferenceEquals(item, current)) + return; + item.Attempts++; + item.State = sent ? DeliveryState.AwaitingCanonicalReceipt : DeliveryState.Queued; + item.NextAttemptAt = DateTimeOffset.UtcNow + RetryDelay(item.Attempts); + item.Sequence = ++_sequence; + } + _changed.Release(); + } + + private Selection TakeNext() + { + lock (_gate) + { + var now = DateTimeOffset.UtcNow; + var item = _items + .Where(candidate => candidate.State != DeliveryState.Sending && candidate.NextAttemptAt <= now) + .OrderBy(candidate => candidate.Sequence) + .FirstOrDefault(); + if (item != null) + { + item.State = DeliveryState.Sending; + return new Selection(item, null); + } + var next = _items + .Where(candidate => candidate.State != DeliveryState.Sending) + .Select(candidate => (DateTimeOffset?)candidate.NextAttemptAt) + .OrderBy(value => value) + .FirstOrDefault(); + return new Selection(null, next.HasValue ? Max(TimeSpan.Zero, next.Value - now) : null); + } + } + + private async Task WaitForChangeOrDueAsync(TimeSpan? delay, CancellationToken cancellationToken) + { + if (!delay.HasValue) + { + await _changed.WaitAsync(cancellationToken).ConfigureAwait(false); + return; + } + var milliseconds = (int)Math.Min(int.MaxValue, Math.Max(1, delay.Value.TotalMilliseconds)); + await _changed.WaitAsync(milliseconds, cancellationToken).ConfigureAwait(false); + } + + private TimeSpan RetryDelay(int attempts) + { + var multiplier = Math.Pow(2, Math.Min(attempts - 1, 10)); + return TimeSpan.FromMilliseconds(Math.Min( + _maximumRetryDelay.TotalMilliseconds, + _retryDelay.TotalMilliseconds * multiplier)); + } + + private void RemoveLocked(OutboxItem item) + { + _items.Remove(item); + _byCommandId.Remove(item.Request.CommandId); + if (item.ContinuousKey != null && + _continuous.TryGetValue(item.ContinuousKey, out var current) && + ReferenceEquals(item, current)) + _continuous.Remove(item.ContinuousKey); + } + + private static TimeSpan Max(TimeSpan left, TimeSpan right) => left >= right ? left : right; + + private sealed class OutboxItem + { + public OutboxItem(EveSurfaceCommandRequest request, string? continuousKey, long sequence) + { + Request = request; + ContinuousKey = continuousKey; + Sequence = sequence; + NextAttemptAt = DateTimeOffset.MinValue; + } + + public EveSurfaceCommandRequest Request { get; } + public string? ContinuousKey { get; } + public DeliveryState State { get; set; } + public DateTimeOffset NextAttemptAt { get; set; } + public int Attempts { get; set; } + public long Sequence { get; set; } + } + + private readonly struct Selection + { + public Selection(OutboxItem? item, TimeSpan? delay) + { + Item = item; + Delay = delay; + } + + public OutboxItem? Item { get; } + public TimeSpan? Delay { get; } + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs.meta new file mode 100644 index 0000000..a6c7e7a --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshCommandOutbox.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 61d76e98f2054d86bf8e7480d4411c39 +timeCreated: 1787039650 diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs new file mode 100644 index 0000000..5e163c8 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs @@ -0,0 +1,49 @@ +using System; +using GameCult.Mesh; +using GameCult.Networking; +using GameCult.Networking.WebSockets; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + /// + /// Owns the transport set used by EveUnity for both rendezvous discovery and + /// the selected provider. Trust remains caller-owned; this class only makes + /// every supported secure plane reachable through the same configuration. + /// + internal static class EveUnityCultMeshConnectivity + { + public static CultMeshVerseDiscoveryClientOptions Discovery() => new() + { + CreateClientForEndpoint = CreateSchemaClient + }; + + public static ICultMeshTransportConnector[] SchemaConnectors() => new ICultMeshTransportConnector[] + { + new CultMeshTcpSchemaTransportConnector(), + new CultMeshUriSchemaTransportConnector( + "cultnet-websocket", + new[] { "ws", "wss" }, + _ => new CultNetWebSocketSchemaClient()) + }; + + public static ICultMeshContentTransportConnector[] ContentConnectors() => + new ICultMeshContentTransportConnector[] + { + new CultMeshHttpsContentTransportConnector(), + new CultMeshTcpContentTransportConnector() + }; + + private static ICultNetSchemaClient CreateSchemaClient(string endpoint) + { + if (Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) && + (string.Equals(uri.Scheme, "ws", StringComparison.OrdinalIgnoreCase) || + string.Equals(uri.Scheme, "wss", StringComparison.OrdinalIgnoreCase))) + { + return new CultNetWebSocketSchemaClient(); + } + return CultNetSchemaClients.CreateForEndpoint(endpoint); + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs.meta new file mode 100644 index 0000000..a701492 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshConnectivity.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7f7cb06716fd4f319ade147a69acb74a 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 04cbeb1..5a403f4 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -2,16 +2,18 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Security.Cryptography; using System.Collections.Concurrent; +using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using GameCult.Caching; -using GameCult.Caching.MessagePack; +using GameCult.Eve.PluginFields; using GameCult.Eve.Surface; +using GameCult.Eve.UnityScene.Fields; using GameCult.Mesh; -using GameCult.Networking; +using GameCult.Mesh.Quic.Native; using UnityEngine; +using Stopwatch = System.Diagnostics.Stopwatch; #nullable enable @@ -22,62 +24,110 @@ namespace GameCult.Eve.UnityScene IEveUnityGameObjectAssetProvider, IDisposable { - private const string RemoteShardId = "provider"; + private const int AssetPresentationAttemptLimit = 3; - private static readonly Type[] WireDocumentTypes = - { - typeof(EveProviderAdvertisementDocument), - typeof(EveSurfaceDocument), - typeof(EveSurfaceCommandRequest), - typeof(EveCommandReceiptDocument), - typeof(EveAssetCatalogDocument), - typeof(EveEntitySoaViewDocument), - typeof(EveInputCapabilityDocument), - typeof(CultMeshCdnArtifactManifest), - typeof(CultMeshCdnArtifactChunk) - }; - - private readonly EveUnityCultMeshProviderSelection _selection; - private readonly CultMeshClient _mesh; - private readonly string _endpointId; + private readonly string _cachePath; + private readonly string _rendezvousEndpoint; + private readonly CultMeshSessionTarget _target; private readonly string _providerId; private readonly string _surfaceId; private readonly string _runtimeId; - private CultMeshSession? _documentSession; - 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 Dictionary _nativeAssets = new Dictionary(StringComparer.Ordinal); - private readonly List _assetBundles = new List(); - private readonly Dictionary _cameraCullingMasks = new Dictionary(StringComparer.Ordinal); + private readonly CultMeshAuthorityTrustPolicy _authorityTrust; + private readonly CultMeshAuthorityTrustPolicy _crossTargetAuthorityTrust; + private readonly Dictionary _pendingCommands = + new Dictionary(StringComparer.Ordinal); + private readonly Dictionary> _receiptSubscriptions = + new Dictionary>(StringComparer.Ordinal); + private readonly object _presentationFinalityGate = new object(); + private readonly Dictionary _deferredTerminalReceipts = + new Dictionary(StringComparer.Ordinal); + private readonly HashSet _continuousCommandIds = new HashSet(StringComparer.Ordinal); private readonly ConcurrentQueue _liveDocuments = new ConcurrentQueue(); - private readonly CancellationTokenSource _backgroundCancellation = new CancellationTokenSource(); - private CultNetDocumentRegistry? _networkRegistry; - private readonly List _liveWatches = new List(); - private bool _liveSubscriptionsOpen; + private readonly object _realtimeFrameGate = new object(); + private readonly Dictionary _latestRealtimeFrames = + new Dictionary(StringComparer.Ordinal); + private readonly HashSet _queuedRealtimeFrameBodies = + new HashSet(StringComparer.Ordinal); + private readonly Dictionary _embeddedSurfaces = + new Dictionary(StringComparer.Ordinal); + private readonly List _documentLeases = new List(); + private readonly List _documentWatches = new List(); + private readonly CancellationTokenSource _lifetime = new CancellationTokenSource(); + private readonly SemaphoreSlim _assetGenerationGate = new SemaphoreSlim(1, 1); + private CultMeshClient? _meshClient; + private bool _liveDocumentsReady; + private CultCache? _contentState; + private AssetGeneration _assetGeneration; private EveProviderAdvertisementDocument? _advertisement; private EveAdvertisedSurface? _advertisedSurface; - private string _assetCatalogPointer = ""; - private Task? _assetCatalogTask; - private EveEntitySoaViewDocument? _deferredEntityView; + private EveSurfaceDocument? _baseSurface; + private CultMeshBodyPublicationResolver? _bodyResolver; + private EveEntitySoaViewDocument? _latestEntityLayout; + private CultMeshBodyPublicationDocument? _latestEntityPublication; + private CultMeshMappedFrameBodyCursor? _mappedEntityFrameCursor; + private CultMeshBodyDescriptor? _mappedEntityFrameContract; + private CancellationTokenSource? _realtimeLifetime; + private CancellationTokenSource? _surfaceAssetCandidateLifetime; + private Task? _realtimePump; + private CultMeshRealtimeSession? _realtimeSession; + private EveUnityCultMeshCommandOutbox? _commandOutbox; + private PendingAssetCatalogUpdate? _pendingAssetCatalog; + private bool _assetCatalogUpdateRunning; + private long _surfaceAssetGeneration; + private long _highestObservedBaseSurfaceVersion = -1; + private long _activeBaseSurfaceCandidateVersion = -1; + private long _activeBaseSurfaceCandidateGeneration = -1; + private int _lastAssetPresentationAttemptCount; + private long _mountedBaseSurfaceVersion; + private long _presentationBarrierVersion = -1; + private long _lastQueuedEntityViewEpoch = -1; + private long _lastQueuedEntityViewSequence = -1; + private long _lastPresentedEntityViewEpoch = -1; + private long _lastPresentedEntityViewSequence = -1; + private bool _bootstrapped; + private bool _disposed; public EveUnityCultMeshLiveProviderTransport( - string replicaPath, - EveUnityCultMeshProviderSelection selection, - string runtimeId = "eve-unity") + string cachePath, + string rendezvousEndpoint, + string verseId, + string authorityRuntimeId, + string providerId, + string surfaceId, + string runtimeId = "eve-unity", + CultMeshBodyPublicationResolver? bodyResolver = null, + CultMeshAuthorityTrustPolicy? authorityTrust = null, + CultMeshAuthorityTrustPolicy? crossTargetAuthorityTrust = null) { - _ = replicaPath; - _selection = selection ?? throw new ArgumentNullException(nameof(selection)); - _mesh = selection.Mesh; - _endpointId = selection.EndpointId; - _providerId = selection.ProviderId; - _surfaceId = selection.SurfaceId; + _cachePath = string.IsNullOrWhiteSpace(cachePath) + ? throw new ArgumentException("Cache path must be non-empty.", nameof(cachePath)) + : Path.GetFullPath(cachePath); + _rendezvousEndpoint = string.IsNullOrWhiteSpace(rendezvousEndpoint) + ? throw new ArgumentException("CultMesh rendezvous endpoint must be non-empty.", nameof(rendezvousEndpoint)) + : rendezvousEndpoint.Trim(); + _target = new CultMeshSessionTarget(verseId, authorityRuntimeId); + _providerId = string.IsNullOrWhiteSpace(providerId) + ? throw new ArgumentException("Provider id must be non-empty.", nameof(providerId)) + : providerId.Trim(); + _assetGeneration = new AssetGeneration( + new AssetSource(_target, _providerId, "", Array.Empty()), + sourceIdentity: "", + catalogVersion: -1, + meshClient: null); + _surfaceId = string.IsNullOrWhiteSpace(surfaceId) + ? throw new ArgumentException("Surface id must be non-empty.", nameof(surfaceId)) + : surfaceId.Trim(); _runtimeId = string.IsNullOrWhiteSpace(runtimeId) ? "eve-unity" : runtimeId.Trim(); + _bodyResolver = bodyResolver; + _authorityTrust = authorityTrust ?? new CultMeshAuthorityTrustPolicy( + CultMeshAuthorityTrustMode.AuthenticatedRemote); + _crossTargetAuthorityTrust = crossTargetAuthorityTrust ?? _authorityTrust; CurrentSurfaceDocument = EmptySurfaceDocument(); CurrentAssetManifestDocument = EmptyAssetManifest(); + CurrentInputCapability = new EveInputCapabilityDocument(); } - public string TransportKind => "eve-cultmesh-remote-replica"; + public string TransportKind => "eve-cultmesh-identity-session"; public string SurfacePointer => CurrentSurfaceDocument.SourcePointer; @@ -87,7 +137,9 @@ namespace GameCult.Eve.UnityScene public EveUnityPlayableWorldAssetManifestDocument CurrentAssetManifestDocument { get; private set; } - public EveInputCapabilityDocument? CurrentInputCapability { get; private set; } + public EveInputCapabilityDocument CurrentInputCapability { get; private set; } + + public CultMeshBodyTransportKind? CurrentAssetBodyTransportKind => _assetGeneration.BodyTransportKind; public event Action? SurfaceDocumentAvailable; @@ -95,12 +147,24 @@ namespace GameCult.Eve.UnityScene public event Action? CommandReceiptAvailable; - public event Action? EntityViewAvailable; + public event Action? EntityViewAvailable; + + public event Action? FieldsSplatsAvailable; + + private static CultMeshBodyPublicationHandle BodyPublicationHandle(EveEntitySoaViewDocument document) + { + if (document == null) throw new ArgumentNullException(nameof(document)); + if (document.Buffers == null || document.Buffers.Length == 0) + throw new InvalidOperationException("Entity layout does not name a primary logical buffer."); + return new CultMeshBodyPublicationHandle( + document.Buffers[0].BufferId, + document.ProducerEpoch, + document.Sequence); + } public void Connect() { - EnsureOpen(); - Refresh(); + EnsurePrepared(); } public void Disconnect() @@ -109,43 +173,197 @@ namespace GameCult.Eve.UnityScene public void Refresh() { - EnsureOpen(); - ResolveAdvertisement(); - RefreshSurface(); - RefreshAssetCatalog(); + EnsurePrepared(); + PumpLiveEvents(); + } + + public async Task PrepareAsync(CancellationToken cancellationToken = default) + { + if (_bootstrapped) + return; + + try + { + EnsureOpen(); + var elapsed = Stopwatch.StartNew(); + await ResolveAdvertisementAsync(forceRefresh: true, cancellationToken); + TraceStartup("advertisement", elapsed); + await RefreshSurfaceAsync(cancellationToken); + TraceStartup("surface", elapsed); + await RefreshAssetCatalogAsync(cancellationToken); + TraceStartup("asset-catalog-and-bundles", elapsed); + await EnsureLiveDocumentsAsync(cancellationToken); + TraceStartup("subscriptions", elapsed); + _commandOutbox = new EveUnityCultMeshCommandOutbox( + SendCommandAsync, + ContinuousCommandKey, + ForgetPendingCommand); + _bootstrapped = true; + } + catch (Exception error) when (error is IOException || error is SocketException || error is TimeoutException) + { + throw new InvalidOperationException( + $"CultMesh provider '{_providerId}' bootstrap through target '{_target}' failed.", + error); + } + } + + private void EnsurePrepared() + { + if (!_bootstrapped) + throw new InvalidOperationException( + "The EveUnity CultMesh transport is not prepared. Await PrepareAsync before mounting or connecting it."); + } + + private static void TraceStartup(string phase, Stopwatch elapsed) + { + if (string.Equals(Environment.GetEnvironmentVariable("EVEUNITY_TRACE_STARTUP_PHASES"), "1", StringComparison.Ordinal)) + Debug.Log($"EveUnity startup phase {phase} took {elapsed.Elapsed.TotalMilliseconds:0.###}ms."); + elapsed.Restart(); + } + + private static string FindComponentProp(EveSurfaceComponent component, string key) + { + var value = component.GetProp(key); + if (!string.IsNullOrWhiteSpace(value)) + return value; + foreach (var child in component.Children) + { + value = FindComponentProp(child, key); + if (!string.IsNullOrWhiteSpace(value)) + return value; + } + return ""; } public void PumpLiveEvents() { while (_liveDocuments.TryDequeue(out var document)) { - if (document is EveEntitySoaViewDocument entityView) + if (document is PendingEntityGeneration entityGeneration) + PublishEntityView(entityGeneration.View, entityGeneration.Publication); + else if (document is PendingRealtimeEntityFrame realtimeFrame) + PublishPendingRealtimeEntityFrame(realtimeFrame.BodyId); + else if (document is EveFieldsSplatsDocument fields) + FieldsSplatsAvailable?.Invoke(fields); + else if (document is PendingSurfaceDocument surface) { - if (!string.IsNullOrWhiteSpace(_assetCatalogPointer) && CurrentAssetCatalogVersion < 0) - _deferredEntityView = entityView; + if (string.Equals(surface.RecordKey, _advertisedSurface?.RecordRef, StringComparison.Ordinal)) + PublishBaseSurface(surface.Document); else - EntityViewAvailable?.Invoke(entityView); + PublishEmbeddedSurface(surface.RecordKey, surface.Document); } - else if (document is EveSurfaceDocument surface) - PublishSurface(surface); - else if (document is EveCommandReceiptDocument receipt) - PublishReceipt(receipt); else if (document is EveInputCapabilityDocument inputCapability) CurrentInputCapability = inputCapability; else if (document is EveAssetCatalogDocument assetCatalog) - ApplyAssetCatalog(assetCatalog); - else if (document is PreparedAssetCatalog preparedAssetCatalog) - ApplyPreparedAssetCatalog(preparedAssetCatalog); - else if (document is BackgroundFailure failure) - throw new InvalidOperationException(failure.Operation + " failed.", failure.Error); + QueueAssetCatalogUpdate(CurrentAssetSourceIdentity, assetCatalog); + else if (document is EveCommandReceiptDocument receipt) + PublishReceipt(receipt); + else if (document is Exception error) + throw error; } + PumpMappedEntityFrame(); + } + + private void QueueEntityView(EveEntitySoaViewDocument document) + { + TraceHotState($"layout epoch={document.ProducerEpoch} sequence={document.Sequence} identities={document.Identities?.Length ?? 0}"); + _latestEntityLayout = document; + var publication = _latestEntityPublication; + if (publication != null && PublicationMatchesLayout(publication, document)) + QueueEntityGeneration(document, publication); + } + + private void QueueBodyPublication(CultMeshBodyPublicationDocument publication) + { + _latestEntityPublication = publication; + var layout = _latestEntityLayout; + if (layout == null || layout.Buffers == null || layout.Buffers.Length == 0 || + !string.Equals(layout.Buffers[0].BufferId, publication.BodyId, StringComparison.Ordinal)) + { + TraceHotState($"publication skipped body={publication.BodyId} sequence={publication.Sequence} layout={(layout == null ? "missing" : "incompatible")}"); + return; + } + + TraceHotState($"publication body={publication.BodyId} epoch={publication.ProducerEpoch} sequence={publication.Sequence} plane={string.Join(",", publication.Representations.Select(value => value.TransportKind))}"); + + var generation = PublicationMatchesLayout(publication, layout) + ? layout + : MaterializeEntityGeneration(layout, publication.ProducerEpoch, publication.Sequence); + QueueEntityGeneration(generation, publication); + } + + private void QueueEntityGeneration( + EveEntitySoaViewDocument document, + CultMeshBodyPublicationDocument publication) + { + if (document.ProducerEpoch < _lastQueuedEntityViewEpoch || + (document.ProducerEpoch == _lastQueuedEntityViewEpoch && + document.Sequence <= _lastQueuedEntityViewSequence)) + return; + _lastQueuedEntityViewEpoch = document.ProducerEpoch; + _lastQueuedEntityViewSequence = document.Sequence; + _liveDocuments.Enqueue(new PendingEntityGeneration(document, publication)); + } + + private static bool PublicationMatchesLayout( + CultMeshBodyPublicationDocument publication, + EveEntitySoaViewDocument layout) => + publication.ProducerEpoch == layout.ProducerEpoch && + publication.Sequence == layout.Sequence; + + private sealed class PendingEntityGeneration + { + public PendingEntityGeneration( + EveEntitySoaViewDocument view, + CultMeshBodyPublicationDocument publication) + { + View = view; + Publication = publication; + } + + public EveEntitySoaViewDocument View { get; } + public CultMeshBodyPublicationDocument Publication { get; } + } + + private static EveEntitySoaViewDocument MaterializeEntityGeneration( + EveEntitySoaViewDocument layout, + long producerEpoch, + long sequence) + { + return new EveEntitySoaViewDocument + { + Schema = layout.Schema, + ProviderId = layout.ProviderId, + ViewId = layout.ViewId, + PublishedAtUtc = DateTimeOffset.UtcNow.ToString("O"), + BodySchemaId = layout.BodySchemaId, + LayoutVersion = layout.LayoutVersion, + ProducerEpoch = producerEpoch, + Sequence = sequence, + Capacity = layout.Capacity, + Buffers = layout.Buffers, + Columns = layout.Columns, + DirtyRanges = (layout.DirtyRanges ?? Array.Empty()) + .Select(range => new EveEntitySoaDirtyRange + { + ColumnId = range.ColumnId, + StartIndex = range.StartIndex, + Count = range.Count, + Sequence = sequence + }) + .ToArray(), + RenderGroups = layout.RenderGroups, + Identities = layout.Identities, + FrameId = sequence + }; } public void SubmitCommand(EveSurfaceCommandRequest request) { if (request == null) throw new ArgumentNullException(nameof(request)); - EnsureOpen(); - ResolveAdvertisement(); + EnsurePrepared(); + EnsurePresentationWritable(); var interaction = RequireWorldInteraction(); if (string.IsNullOrWhiteSpace(interaction.CommandRecordRef)) throw new InvalidOperationException("The provider advertisement does not publish a command record reference."); @@ -159,124 +377,629 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(commandId)) throw new InvalidOperationException("Eve command invocations require an idempotency key."); - var recordKey = ChildRecordKey(interaction.CommandRecordRef, commandId); - if (string.IsNullOrWhiteSpace(interaction.ReceiptRecordRef)) - throw new InvalidOperationException("The provider advertisement does not publish a receipt record reference."); - var receiptKey = ChildRecordKey(interaction.ReceiptRecordRef, commandId); - var receipt = _mesh.DocumentAsync(_endpointId, receiptKey) - .GetAwaiter().GetResult(); - _liveWatches.Add(receipt.Watch(value => _liveDocuments.Enqueue(value))); - var message = _networkRegistry!.CreateRawDocumentPutMessage( - $"eve-unity-{commandId}", - new CultRecordHandle(new CultRecordKey(recordKey)), - request, - new CultNetDocumentMessageOptions - { - SourceRuntimeId = _runtimeId, - SourceRole = "eve-unity" - }); - message.ShardId = RemoteShardId; - message.ShardEpoch = 1; - _documentSession!.SendCultNet(message); - _pendingCommandIds.Add(commandId); - _ = AwaitReceiptAsync(receiptKey, commandId, _backgroundCancellation.Token); + lock (_pendingCommands) + { + if (_pendingCommands.ContainsKey(commandId)) + throw new InvalidOperationException($"Eve command '{commandId}' is already pending."); + _pendingCommands.Add(commandId, request); + } + try { _commandOutbox!.Enqueue(request); } + catch + { + ForgetPendingCommand(commandId); + throw; + } } - private async Task AwaitReceiptAsync(string receiptKey, string commandId, CancellationToken cancellationToken) + private Task SendCommandAsync(EveSurfaceCommandRequest request, CancellationToken cancellationToken) { - for (var attempt = 0; attempt < 60 && !cancellationToken.IsCancellationRequested; attempt++) + return SendCommandWithReceiptLeaseAsync(request, cancellationToken); + } + + private async Task SendCommandWithReceiptLeaseAsync( + EveSurfaceCommandRequest request, + CancellationToken cancellationToken) + { + await EnsureReceiptSubscriptionAsync(request.CommandId, cancellationToken).ConfigureAwait(false); + var interaction = RequireWorldInteraction(); + var recordKey = ChildRecordKey(interaction.CommandRecordRef, request.CommandId); + await _meshClient!.SubmitDocumentAsync( + _target, + recordKey, + request, + _runtimeId, + "eve-unity", + cancellationToken) + .ConfigureAwait(false); + } + + private string? ContinuousCommandKey(EveSurfaceCommandRequest request) + { + if (!request.PayloadFields.TryGetValue("commandId", out var commandId) || + !_continuousCommandIds.Contains(commandId)) + return null; + request.PayloadFields.TryGetValue("entityId", out var entityId); + return commandId + "\u001f" + (entityId ?? ""); + } + + private void ForgetPendingCommand(string commandId) + { + Task? subscription = null; + lock (_pendingCommands) { - try - { - var receipt = await _mesh.ReadAsync( - _endpointId, - receiptKey, - TimeSpan.FromSeconds(2), - cancellationToken) - .ConfigureAwait(false); - if (string.Equals(receipt.CommandId, commandId, StringComparison.Ordinal)) - { - _liveDocuments.Enqueue(receipt); - return; - } - } - catch (InvalidOperationException) - { - } - try - { - await Task.Delay(100, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - return; - } + _pendingCommands.Remove(commandId); + if (_receiptSubscriptions.TryGetValue(commandId, out subscription)) + _receiptSubscriptions.Remove(commandId); } + if (subscription != null) DisposeReceiptSubscription(subscription); } public void Dispose() { - _backgroundCancellation.Cancel(); - foreach (var bundle in _assetBundles) bundle.Unload(unloadAllLoadedObjects: false); - _assetBundles.Clear(); - _prefabs.Clear(); - _nativeAssets.Clear(); - foreach (var watch in _liveWatches) watch.Dispose(); - _liveWatches.Clear(); - _mesh.Dispose(); - _documentSession = null; - _networkRegistry = null; - _pendingCommandIds.Clear(); - _publishedReceiptIds.Clear(); - _backgroundCancellation.Dispose(); + _disposed = true; + _lifetime.Cancel(); + _commandOutbox?.Dispose(); + _commandOutbox = null; + _assetGeneration.Dispose(); + _mappedEntityFrameCursor?.Dispose(); + _mappedEntityFrameCursor = null; + _mappedEntityFrameContract = null; + _realtimeLifetime?.Cancel(); + _surfaceAssetCandidateLifetime?.Cancel(); + _surfaceAssetCandidateLifetime?.Dispose(); + _surfaceAssetCandidateLifetime = null; + _realtimeSession?.Dispose(); + _realtimeSession = null; + _realtimePump = null; + _realtimeLifetime?.Dispose(); + _realtimeLifetime = null; + DisposeLiveDocuments(); + _contentState?.Dispose(); + _contentState = null; + _meshClient?.Dispose(); + _meshClient = null; + _baseSurface = null; + _embeddedSurfaces.Clear(); + lock (_pendingCommands) + { + _pendingCommands.Clear(); + foreach (var subscription in _receiptSubscriptions.Values) + DisposeReceiptSubscription(subscription); + _receiptSubscriptions.Clear(); + } + lock (_presentationFinalityGate) + { + _deferredTerminalReceipts.Clear(); + _presentationBarrierVersion = -1; + } + _lifetime.Dispose(); } public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) { if (asset == null) throw new ArgumentNullException(nameof(asset)); - return _prefabs.TryGetValue(asset.AssetRef, out var prefab) ? prefab : null; + EnsureAssetPrepared(asset.AssetRef); + return _assetGeneration.Prefabs.TryGetValue(asset.AssetRef, out var prefab) ? prefab : null; } public UnityEngine.Object? ResolveAsset(EveUnityPlayableWorldAssetBinding asset, Type assetType) { if (asset == null) throw new ArgumentNullException(nameof(asset)); if (assetType == null) throw new ArgumentNullException(nameof(assetType)); - return _nativeAssets.TryGetValue(asset.AssetRef, out var value) && assetType.IsInstanceOfType(value) + EnsureAssetPrepared(asset.AssetRef); + return _assetGeneration.NativeAssets.TryGetValue(asset.AssetRef, out var value) && assetType.IsInstanceOfType(value) ? value : null; } - public bool TryGetCameraCullingMask(string viewId, out int cullingMask) + private void EnsureAssetPrepared(string assetRef) { - return _cameraCullingMasks.TryGetValue(viewId ?? "", out cullingMask); + EnsurePrepared(); + if (_assetGeneration.AssetSelections.ContainsKey(assetRef) && + !_assetGeneration.NativeAssets.ContainsKey(assetRef)) + throw new InvalidOperationException( + $"Provider asset '{assetRef}' was not materialized during asynchronous preparation."); + } + + public bool TryResolveAssetMetadata( + EveUnityPlayableWorldAssetBinding asset, + out IReadOnlyDictionary metadata) + { + if (asset == null) throw new ArgumentNullException(nameof(asset)); + return _assetGeneration.NativeAssetMetadata.TryGetValue(asset.AssetRef, out metadata!); + } + + public bool TryGetRenderChannelLayer(string channel, out int layer) + { + return _assetGeneration.RenderChannelLayers.TryGetValue(channel ?? "", out layer); } private void EnsureOpen() { - if (_documentSession != null) + if (_meshClient != null) + return; + _meshClient = CreateMeshClient(new[] { _rendezvousEndpoint }, _authorityTrust); + } + + private CultMeshClient CreateMeshClient( + IReadOnlyList rendezvousEndpoints, + CultMeshAuthorityTrustPolicy authorityTrust) + { + return new CultMeshClient(new CultMeshClientOptions + { + RendezvousEndpoints = rendezvousEndpoints, + Discovery = EveUnityCultMeshConnectivity.Discovery(), + Sessions = new CultMeshSessionManagerOptions + { + Trust = authorityTrust ?? throw new ArgumentNullException(nameof(authorityTrust)) + }, + Connectors = EveUnityCultMeshConnectivity.SchemaConnectors(), + ContentConnectors = EveUnityCultMeshConnectivity.ContentConnectors(), + RealtimeConnectors = new ICultMeshRealtimeTransportConnector[] + { + new CultMeshNativeQuicRealtimeTransportConnector() + } + }); + } + + private CultMeshClient AssetMeshClient(AssetGeneration? generation = null) => + (generation ?? _assetGeneration).MeshClient ?? + _meshClient ?? throw new InvalidOperationException("The CultMesh client is not open."); + + private async Task ResolveAdvertisementAsync( + bool forceRefresh = false, + CancellationToken cancellationToken = default) + { + if (!forceRefresh && _advertisement != null && _advertisedSurface != null) + return; + using var lease = await _meshClient! + .LeaseCollectionAsync(_target, cancellationToken); + var documents = await lease.Handle.LatestAsync(); + _advertisement = documents.FirstOrDefault(document => + string.Equals(document.ProviderId, _providerId, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"Provider '{_providerId}' did not publish an Eve advertisement."); + _advertisedSurface = _advertisement.Surfaces.FirstOrDefault(surface => + string.Equals(surface.SurfaceId, _surfaceId, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"Provider '{_providerId}' did not advertise surface '{_surfaceId}'."); + if (!string.Equals(_advertisedSurface.Transport, "cultmesh-record", StringComparison.Ordinal)) + throw new InvalidOperationException("EveUnity CultMesh transport requires a cultmesh-record surface advertisement."); + if (string.IsNullOrWhiteSpace(_advertisedSurface.RecordRef)) + throw new InvalidOperationException("The provider surface advertisement does not publish a record reference."); + } + + private CultMeshBodyPublicationResolver CreateBodyResolver() + { + var producerIds = RequireAdvertisedBodyProducerIds(_advertisement); + if (string.IsNullOrWhiteSpace(_advertisement!.ServiceId)) + throw new InvalidOperationException( + $"Eve provider '{_advertisement.ProviderId}' has no service-instance identity for body transport."); + var mappedRoot = _cachePath; + var networkBodies = _meshClient!.BodyProvider( + _advertisement.ProviderId, + _target, + new CultMeshSessionBodyProviderOptions { ResponseTimeout = TimeSpan.FromSeconds(2) }); + return new CultMeshBodyPublicationResolver(new CultMeshBodyTransportService( + new ICultMeshBodyTransportAdapter[] + { + new CultMeshSharedMemoryBodyAdapter(), + new CultMeshMappedBodyAdapter(mappedRoot), + new CultMeshNetworkBodyAdapter(networkBodies) + }, + (candidateProducerId, _) => IsAdvertisedBodyProducer(producerIds, candidateProducerId))); + } + + private static bool IsAdvertisedBodyProducer( + IReadOnlyList advertisedProducerIds, + string candidateProducerId) => + advertisedProducerIds.Any(producerId => + string.Equals(candidateProducerId, producerId, StringComparison.Ordinal)); + + private static IReadOnlyList RequireAdvertisedBodyProducerIds(EveProviderAdvertisementDocument? advertisement) + { + if (advertisement == null) + throw new InvalidOperationException("The Eve provider advertisement must be resolved before body transport authorization."); + var producerIds = advertisement.AuthorizedBodyProducerIds + .Where(producerId => !string.IsNullOrWhiteSpace(producerId)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (producerIds.Length == 0) + throw new InvalidOperationException( + $"Eve provider '{advertisement.ProviderId}' does not advertise an authorized body producer."); + return producerIds; + } + + private static string NormalizeContentHash(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidDataException("CultMesh content hash is missing."); + var normalized = value.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) + ? value.Substring("sha256:".Length) + : value; + return normalized.ToLowerInvariant(); + } + + private void PublishEntityView( + EveEntitySoaViewDocument document, + CultMeshBodyPublicationDocument publication) + { + EnsurePrepared(); + if (!string.Equals(document.ProviderId, _advertisement!.ProviderId, StringComparison.Ordinal)) + throw new UnauthorizedAccessException( + $"Entity layout provider '{document.ProviderId}' does not match advertised Eve provider '{_advertisement.ProviderId}'."); + if (document.Buffers == null || document.Buffers.Length == 0) + throw new InvalidOperationException("Entity layout does not name a primary logical buffer."); + var handle = BodyPublicationHandle(document); + handle.Validate(publication); + var now = DateTimeOffset.UtcNow; + if (!IsPublicationLive(publication, now)) + return; + EnsureMappedEntityFrameCursor(publication); + if (_mappedEntityFrameCursor != null) + return; + EnsureRealtimeEntityState(); + } + + private void EnsureMappedEntityFrameCursor(CultMeshBodyPublicationDocument publication) + { + var descriptor = (publication.Representations ?? Array.Empty()) + .FirstOrDefault(CultMeshMappedFrameBodyCursor.CanOpen); + if (descriptor == null) + { + _mappedEntityFrameCursor?.Dispose(); + _mappedEntityFrameCursor = null; + _mappedEntityFrameContract = null; + return; + } + + var current = _mappedEntityFrameContract; + if (current != null && + string.Equals(FrameCapabilityIdentity(current.CapabilityToken), + FrameCapabilityIdentity(descriptor.CapabilityToken), StringComparison.Ordinal) && + string.Equals(current.BodyId, descriptor.BodyId, StringComparison.Ordinal) && + string.Equals(current.SchemaId, descriptor.SchemaId, StringComparison.Ordinal) && + current.LayoutVersion == descriptor.LayoutVersion && + current.Capacity == descriptor.Capacity && + current.ProducerEpoch == descriptor.ProducerEpoch && + current.ByteSize == descriptor.ByteSize) return; - var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(WireDocumentTypes); - _networkRegistry = CultMesh.CreateCultNetDocumentRegistry(WireDocumentTypes, cacheRegistry); - _documentSession = _mesh.ConnectAsync(_endpointId, CultMeshProtocols.Documents) - .GetAwaiter().GetResult(); + _mappedEntityFrameCursor?.Dispose(); + try + { + _mappedEntityFrameCursor = new CultMeshMappedFrameBodyCursor(descriptor); + _mappedEntityFrameContract = descriptor; + TraceHotState( + $"mapped frame cursor body={descriptor.BodyId} epoch={descriptor.ProducerEpoch} sequence={descriptor.Sequence}"); + } + catch (Exception error) when ( + error is IOException || + error is UnauthorizedAccessException || + error is InvalidOperationException) + { + _mappedEntityFrameCursor = null; + _mappedEntityFrameContract = null; + TraceHotState($"mapped frame unavailable; selecting remote realtime plane: {error.Message}"); + } } - private void ResolveAdvertisement() + private void EnsureRealtimeEntityState() { - _advertisement = _selection.Surface.Provider; - _advertisedSurface = _selection.Surface.Advertisement; + if (_realtimePump != null) + return; + if (_advertisement == null || string.IsNullOrWhiteSpace(_advertisement.VerseId)) + throw new InvalidOperationException( + $"Eve provider '{_providerId}' does not advertise the Verse identity required for realtime state."); + _realtimeLifetime = new CancellationTokenSource(); + if (!string.Equals(_advertisement.VerseId, _target.VerseId, StringComparison.Ordinal)) + throw new InvalidOperationException( + $"Eve provider '{_providerId}' advertisement Verse '{_advertisement.VerseId}' does not match selected Verse '{_target.VerseId}'."); + _realtimePump = Task.Run(() => PumpRealtimeEntityStateAsync(_realtimeLifetime.Token)); } - private void RefreshSurface() + private async Task PumpRealtimeEntityStateAsync(CancellationToken cancellationToken) { - var surface = _selection.Surface.Surface.LatestAsync().GetAwaiter().GetResult(); - PublishSurface(surface); + try + { + _realtimeSession = await _meshClient! + .ConnectRealtimeAsync(_target, cancellationToken) + .ConfigureAwait(false); + TraceHotState($"realtime state connected transport={_realtimeSession.TransportId} target={_target}"); + while (!cancellationToken.IsCancellationRequested) + { + var frame = await _realtimeSession.ReceiveAsync(cancellationToken).ConfigureAwait(false); + EnqueueRealtimeEntityFrame(frame); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (ObjectDisposedException) when (_disposed) + { + } + catch (Exception error) + { + _liveDocuments.Enqueue(error); + } } - private void PublishSurface(EveSurfaceDocument surface) + private void EnqueueRealtimeEntityFrame(CultMeshRealtimeFrame frame) { + if (frame == null) + throw new ArgumentNullException(nameof(frame)); + lock (_realtimeFrameGate) + { + if (_latestRealtimeFrames.TryGetValue(frame.BodyId, out var current) && + (frame.ProducerEpoch < current.ProducerEpoch || + (frame.ProducerEpoch == current.ProducerEpoch && frame.Sequence <= current.Sequence))) + return; + _latestRealtimeFrames[frame.BodyId] = frame; + if (_queuedRealtimeFrameBodies.Add(frame.BodyId)) + _liveDocuments.Enqueue(new PendingRealtimeEntityFrame(frame.BodyId)); + } + } + + private void PublishPendingRealtimeEntityFrame(string bodyId) + { + CultMeshRealtimeFrame? frame; + lock (_realtimeFrameGate) + { + _queuedRealtimeFrameBodies.Remove(bodyId); + if (!_latestRealtimeFrames.TryGetValue(bodyId, out frame)) + return; + _latestRealtimeFrames.Remove(bodyId); + } + PublishRealtimeEntityFrame(frame); + } + + private void PublishRealtimeEntityFrame(CultMeshRealtimeFrame frame) + { + var layout = _latestEntityLayout + ?? throw new InvalidOperationException("Realtime entity state arrived before its Eve SoA layout."); + var publication = _latestEntityPublication + ?? throw new InvalidOperationException("Realtime entity state arrived before its body publication."); + if (!string.Equals(frame.BodyId, publication.BodyId, StringComparison.Ordinal) || + !string.Equals(frame.SchemaId, layout.BodySchemaId, StringComparison.Ordinal)) + throw new UnauthorizedAccessException("Realtime entity state does not match the advertised Eve body contract."); + if (frame.Delivery != CultMeshRealtimeDelivery.LatestOnly) + throw new InvalidDataException("Playable entity state requires latest-only QUIC delivery."); + if (frame.ProducerEpoch < _lastPresentedEntityViewEpoch || + (frame.ProducerEpoch == _lastPresentedEntityViewEpoch && + frame.Sequence <= _lastPresentedEntityViewSequence)) + return; + var representation = (publication.Representations ?? Array.Empty()) + .FirstOrDefault(value => + string.Equals(value.BodyId, frame.BodyId, StringComparison.Ordinal) && + string.Equals(value.SchemaId, frame.SchemaId, StringComparison.Ordinal)) + ?? throw new InvalidDataException("Realtime entity state has no advertised body representation."); + if (frame.Payload.Length != representation.ByteSize) + throw new InvalidDataException( + $"Realtime entity body size {frame.Payload.Length} does not match advertised size {representation.ByteSize}."); + + var generation = MaterializeEntityGeneration(layout, frame.ProducerEpoch, frame.Sequence); + var lease = new RealtimeEntityBodyLease( + new CultMeshBodyDescriptor + { + BodyId = frame.BodyId, + SchemaId = frame.SchemaId, + LayoutVersion = layout.LayoutVersion, + ByteSize = frame.Payload.Length, + Capacity = layout.Capacity, + ProducerEpoch = frame.ProducerEpoch, + Sequence = frame.Sequence, + AccessMode = CultMeshBodyAccessMode.ReadOnly, + Synchronization = CultMeshBodySynchronization.EpochSequence, + LeaseExpiresAtUnixMs = DateTimeOffset.UtcNow.AddSeconds(2).ToUnixTimeMilliseconds(), + TransportKind = CultMeshBodyTransportKind.Network, + CapabilityToken = "authenticated-quic", + SemanticHash = "" + }, + frame.Payload.ToArray()); + var handler = EntityViewAvailable; + if (handler == null) + { + lease.Dispose(); + return; + } + try + { + handler(generation, lease); + _lastPresentedEntityViewEpoch = frame.ProducerEpoch; + _lastPresentedEntityViewSequence = frame.Sequence; + } + catch + { + lease.Dispose(); + throw; + } + } + + private sealed class PendingRealtimeEntityFrame + { + public PendingRealtimeEntityFrame(string bodyId) => + BodyId = string.IsNullOrWhiteSpace(bodyId) + ? throw new ArgumentException("Body id must be non-empty.", nameof(bodyId)) + : bodyId; + + public string BodyId { get; } + } + + private sealed class RealtimeEntityBodyLease : ICultMeshBodyReadLease + { + private byte[] _bytes; + + public RealtimeEntityBodyLease(CultMeshBodyDescriptor descriptor, byte[] bytes) + { + Descriptor = descriptor; + _bytes = bytes; + } + + public CultMeshBodyDescriptor Descriptor { get; } + public CultMeshBodyTransportKind TransportKind => CultMeshBodyTransportKind.Network; + public byte ReadByte(long offset) { Validate(offset, 1); return _bytes[(int)offset]; } + public int ReadInt32(long offset) { Validate(offset, 4); return BitConverter.ToInt32(_bytes, (int)offset); } + public long ReadInt64(long offset) { Validate(offset, 8); return BitConverter.ToInt64(_bytes, (int)offset); } + public float ReadSingle(long offset) { Validate(offset, 4); return BitConverter.ToSingle(_bytes, (int)offset); } + public double ReadDouble(long offset) { Validate(offset, 8); return BitConverter.ToDouble(_bytes, (int)offset); } + + public int CopyTo(long offset, byte[] destination, int destinationOffset, int count) + { + Validate(offset, count); + Buffer.BlockCopy(_bytes, (int)offset, destination, destinationOffset, count); + return count; + } + + public void Dispose() => _bytes = Array.Empty(); + + private void Validate(long offset, int count) + { + if (offset < 0 || count < 0 || offset > _bytes.LongLength - count) + throw new ArgumentOutOfRangeException(nameof(offset)); + } + } + + private void PumpMappedEntityFrame() + { + var cursor = _mappedEntityFrameCursor; + var layout = _latestEntityLayout; + if (cursor == null || layout == null || !cursor.TryAcquireLatest(out var lease)) + return; + + var descriptor = lease.Descriptor; + if (descriptor.ProducerEpoch < _lastPresentedEntityViewEpoch || + (descriptor.ProducerEpoch == _lastPresentedEntityViewEpoch && + descriptor.Sequence <= _lastPresentedEntityViewSequence)) + { + lease.Dispose(); + return; + } + + var generation = MaterializeEntityGeneration( + layout, + descriptor.ProducerEpoch, + descriptor.Sequence); + var handler = EntityViewAvailable; + if (handler == null) + { + lease.Dispose(); + return; + } + try + { + handler(generation, lease); + _lastPresentedEntityViewEpoch = descriptor.ProducerEpoch; + _lastPresentedEntityViewSequence = descriptor.Sequence; + } + catch + { + lease.Dispose(); + throw; + } + } + + private static string FrameCapabilityIdentity(string capabilityToken) + { + var separator = capabilityToken.LastIndexOf('.'); + return separator > 0 ? capabilityToken.Substring(0, separator) : capabilityToken; + } + + private static bool IsPublicationLive( + CultMeshBodyPublicationDocument publication, + DateTimeOffset now) => + publication.LivenessExpiresAtUnixMs > now.ToUnixTimeMilliseconds(); + + private async Task RefreshSurfaceAsync(CancellationToken cancellationToken) + { + var surface = await _meshClient! + .ReadAsync(_target, _advertisedSurface!.RecordRef, cancellationToken); + PublishBaseSurface(surface); + } + + private void PublishBaseSurface(EveSurfaceDocument surface) + { + if (surface == null) throw new ArgumentNullException(nameof(surface)); + if (!TryBeginBaseSurfaceCandidate(surface.Version, out var generation)) + return; + CancellationToken candidateToken; + lock (_presentationFinalityGate) + { + _surfaceAssetCandidateLifetime?.Cancel(); + _surfaceAssetCandidateLifetime?.Dispose(); + _surfaceAssetCandidateLifetime = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token); + candidateToken = _surfaceAssetCandidateLifetime.Token; + } + if (!_bootstrapped) + { + _baseSurface = surface; + PublishComposedSurface(surface.Version); + return; + } + + var source = ResolveAssetSource(surface); + if (string.Equals(source.Identity, CurrentAssetSourceIdentity, StringComparison.Ordinal) && + (source.RequiredCatalogVersion <= 0 || source.RequiredCatalogVersion == CurrentAssetCatalogVersion)) + { + _baseSurface = surface; + PublishComposedSurface(surface.Version); + return; + } + + // A surface and its provider-qualified asset catalog are one presentation + // candidate. Keep the previous surface mounted until the candidate assets + // have resolved and preloaded; never lower a remote Verse surface against + // the previous Verse's catalog merely because the surface record arrived first. + BeginPresentationTransition(surface.Version); + _ = RefreshAssetsForSurfaceAsync(surface, source, generation, candidateToken); + } + + private bool TryBeginBaseSurfaceCandidate(long version, out long generation) + { + lock (_presentationFinalityGate) + { + generation = -1; + if (version < _highestObservedBaseSurfaceVersion || + version <= _mountedBaseSurfaceVersion || + (version == _highestObservedBaseSurfaceVersion && + _activeBaseSurfaceCandidateVersion == version)) + return false; + _highestObservedBaseSurfaceVersion = Math.Max(_highestObservedBaseSurfaceVersion, version); + generation = Interlocked.Increment(ref _surfaceAssetGeneration); + _activeBaseSurfaceCandidateVersion = version; + _activeBaseSurfaceCandidateGeneration = generation; + return true; + } + } + + private void RetireFailedBaseSurfaceCandidate(long version, long generation) + { + lock (_presentationFinalityGate) + { + if (_activeBaseSurfaceCandidateVersion != version || + _activeBaseSurfaceCandidateGeneration != generation) + return; + _activeBaseSurfaceCandidateVersion = -1; + _activeBaseSurfaceCandidateGeneration = -1; + } + } + + private void PublishEmbeddedSurface(string recordKey, EveSurfaceDocument surface) + { + if (string.IsNullOrWhiteSpace(recordKey)) + throw new InvalidOperationException("An embedded Eve surface update has no record identity."); + _embeddedSurfaces[recordKey] = surface ?? throw new ArgumentNullException(nameof(surface)); + PublishComposedSurface(); + } + + private void PublishComposedSurface(long? committedBaseVersion = null) + { + ApplyPreparedSurface(PrepareComposedSurface( + _baseSurface ?? throw new InvalidOperationException("The base Eve surface is unavailable.")), + committedBaseVersion); + } + + private PreparedSurface PrepareComposedSurface(EveSurfaceDocument baseSurface) + { + var surface = ComposeSurface(baseSurface, _embeddedSurfaces); var interaction = RequireWorldInteraction(); - CurrentSurfaceDocument = new EveUnitySceneProviderSurfaceDocument( + var document = new EveUnitySceneProviderSurfaceDocument( surface, new EveUnitySceneProviderSurfaceAdvertisement( _advertisedSurface.SurfaceId, @@ -288,188 +1011,989 @@ namespace GameCult.Eve.UnityScene interaction.Ownership)), _advertisedSurface.RecordRef, surface.Version); - SurfaceDocumentAvailable?.Invoke(CurrentSurfaceDocument); - EnsureLiveSubscriptions(); + var lowered = new EveUnitySceneSurfaceLowerer() + .Lower(document.SurfaceDocument, document.AdvertisedSurface); + var continuousCommands = new HashSet(StringComparer.Ordinal); + if (!string.IsNullOrWhiteSpace(lowered.PlayableWorld?.MovementCommand)) + continuousCommands.Add(lowered.PlayableWorld.MovementCommand); + if (!string.IsNullOrWhiteSpace(lowered.PlayableWorld?.LookCommand)) + continuousCommands.Add(lowered.PlayableWorld.LookCommand); + return new PreparedSurface(document, continuousCommands); } - private void EnsureLiveSubscriptions() + private void ApplyPreparedSurface(PreparedSurface prepared, long? committedBaseVersion = null) + { + CurrentSurfaceDocument = prepared.Document; + _continuousCommandIds.Clear(); + foreach (var command in prepared.ContinuousCommands) + _continuousCommandIds.Add(command); + SurfaceDocumentAvailable?.Invoke(CurrentSurfaceDocument); + if (committedBaseVersion.HasValue) + CompletePresentationTransition(committedBaseVersion.Value); + } + + internal static EveSurfaceDocument ComposeSurface( + EveSurfaceDocument surface, + IReadOnlyDictionary embeddedSurfaces) + { + var root = ComposeComponent(surface.Surface.Root, embeddedSurfaces); + var version = embeddedSurfaces.Values.Select(value => value.Version) + .DefaultIfEmpty(surface.Version) + .Append(surface.Version) + .Max(); + return new EveSurfaceDocument( + surface.Type, + surface.Schema, + surface.ProviderId, + surface.ProviderKind, + surface.Title, + version, + surface.UpdatedAtUtc, + new EveSurfaceTree(surface.Surface.Id, root, surface.Surface.Styles), + surface.Commands); + } + + private static EveSurfaceComponent ComposeComponent( + EveSurfaceComponent component, + IReadOnlyDictionary embeddedSurfaces) + { + var children = component.Children.Select(child => ComposeComponent(child, embeddedSurfaces)).ToList(); + foreach (var slot in component.EmbeddedDocuments ?? Array.Empty()) + { + if (!string.Equals(slot.SchemaId, EveSurfaceDocument.SchemaId, StringComparison.Ordinal) || + !embeddedSurfaces.TryGetValue(slot.DocumentId, out var embedded)) + continue; + children.Add(ComposeComponent(embedded.Surface.Root, embeddedSurfaces)); + } + return new EveSurfaceComponent( + component.Id, + component.Kind, + component.Props, + children, + component.StateBindingRecords, + component.EmbeddedDocuments, + component.Layout, + component.Style); + } + + private async Task EnsureLiveDocumentsAsync(CancellationToken cancellationToken) + { + if (_liveDocumentsReady) return; + DisposeLiveDocuments(); + try + { + await OpenLiveDocumentsAsync(cancellationToken); + _liveDocumentsReady = true; + } + catch + { + DisposeLiveDocuments(); + throw; + } + } + + private async Task OpenLiveDocumentsAsync(CancellationToken cancellationToken) { - if (_liveSubscriptionsOpen) return; - _liveSubscriptionsOpen = true; - _liveWatches.Add(_selection.Surface.Surface.Watch(surface => _liveDocuments.Enqueue(surface))); var lowered = new EveUnitySceneSurfaceLowerer() .Lower(CurrentSurfaceDocument.SurfaceDocument, CurrentSurfaceDocument.AdvertisedSurface); string entityViewPointer = lowered.PlayableWorld == null ? "" : lowered.PlayableWorld.EntityViewPointerId; + string entityBodyId = lowered.PlayableWorld == null + ? "" + : lowered.PlayableWorld.EntityBodyId; + var fieldRefs = lowered.PlayableWorld == null + ? Array.Empty() + : lowered.PlayableWorld.FieldVolumes + .Select(field => field.DocumentRef) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .ToArray(); if (!string.IsNullOrWhiteSpace(entityViewPointer)) { - var entities = _mesh.DocumentAsync(_endpointId, entityViewPointer) - .GetAwaiter().GetResult(); - _liveDocuments.Enqueue(entities.LatestAsync().GetAwaiter().GetResult()); - _liveWatches.Add(entities.Watch(view => _liveDocuments.Enqueue(view))); + if (string.IsNullOrWhiteSpace(entityBodyId)) + throw new InvalidOperationException( + "The playable world advertises an entity-view pointer without its logical body id."); + _bodyResolver ??= CreateBodyResolver(); + await LeaseDocumentAsync(entityViewPointer, QueueEntityView, cancellationToken); + await LeaseCollectionAsync(publication => + { + if (string.Equals(publication.BodyId, entityBodyId, StringComparison.Ordinal)) + QueueBodyPublication(publication); + }, cancellationToken); } - var inputCapabilityPointer = lowered.PlayableWorld?.InputCapabilityPointerId ?? ""; - if (!string.IsNullOrWhiteSpace(inputCapabilityPointer)) + foreach (var fieldRef in fieldRefs) + await LeaseDocumentAsync( + fieldRef, + fields => _liveDocuments.Enqueue(fields), + cancellationToken); + + await LeaseDocumentAsync( + _advertisedSurface!.RecordRef, + surface => _liveDocuments.Enqueue(new PendingSurfaceDocument(_advertisedSurface.RecordRef, surface)), + cancellationToken, + publishInitial: false); + var embeddedSlots = EnumerateEmbeddedDocuments(CurrentSurfaceDocument.SurfaceDocument.Surface.Root) + .Where(slot => string.Equals(slot.SchemaId, EveSurfaceDocument.SchemaId, StringComparison.Ordinal)) + .Where(slot => !string.IsNullOrWhiteSpace(slot.DocumentId)) + .GroupBy(slot => slot.DocumentId, StringComparer.Ordinal) + .Select(group => group.First()) + .ToArray(); + foreach (var slot in embeddedSlots) { - var inputs = _mesh.DocumentAsync(_endpointId, inputCapabilityPointer) - .GetAwaiter().GetResult(); - CurrentInputCapability = inputs.LatestAsync().GetAwaiter().GetResult(); - _liveWatches.Add(inputs.Watch(document => _liveDocuments.Enqueue(document))); + await LeaseDocumentAsync( + slot.DocumentId, + surface => _liveDocuments.Enqueue(new PendingSurfaceDocument(slot.DocumentId, surface)), + cancellationToken); + } + var inputCapabilityRef = FindComponentProp( + CurrentSurfaceDocument.SurfaceDocument.Surface.Root, + "inputCapability"); + if (!string.IsNullOrWhiteSpace(inputCapabilityRef)) + { + await LeaseDocumentAsync( + inputCapabilityRef, + document => _liveDocuments.Enqueue(document), + cancellationToken); + } + var interaction = RequireWorldInteraction(); + if (!string.Equals(interaction.ReceiptSchema, EveCommandReceiptDocument.SchemaId, StringComparison.Ordinal)) + throw new InvalidOperationException( + $"The provider advertises receipt schema '{interaction.ReceiptSchema}', expected '{EveCommandReceiptDocument.SchemaId}'."); + if (string.IsNullOrWhiteSpace(interaction.ReceiptRecordRef)) + throw new InvalidOperationException("The provider advertisement does not publish a receipt record reference."); + } + + private Task EnsureReceiptSubscriptionAsync( + string commandId, + CancellationToken cancellationToken) + { + Task subscription; + lock (_pendingCommands) + { + if (_receiptSubscriptions.TryGetValue(commandId, out subscription!)) return subscription; + subscription = OpenReceiptSubscriptionAsync(commandId, cancellationToken); + _receiptSubscriptions.Add(commandId, subscription); + } + return RemoveFailedReceiptSubscriptionAsync(commandId, subscription); + } + + private async Task RemoveFailedReceiptSubscriptionAsync( + string commandId, + Task subscription) + { + try { return await subscription.ConfigureAwait(false); } + catch + { + lock (_pendingCommands) + { + if (_receiptSubscriptions.TryGetValue(commandId, out var current) && + ReferenceEquals(current, subscription)) + _receiptSubscriptions.Remove(commandId); + } + throw; } } - private void RefreshAssetCatalog() - { - var interaction = RequireWorldInteraction(); - if (string.IsNullOrWhiteSpace(interaction.AssetManifestRecordRef)) - return; - - if (string.Equals(_assetCatalogPointer, interaction.AssetManifestRecordRef, StringComparison.Ordinal)) - return; - - _assetCatalogPointer = interaction.AssetManifestRecordRef; - var platform = CurrentBundlePlatform(); - var cacheRoot = AssetCacheRoot(); - _assetCatalogTask = ObserveBackgroundAsync( - OpenAssetCatalogAsync(_assetCatalogPointer, platform, cacheRoot), - "CultMesh asset catalog preparation"); - } - - private async Task ObserveBackgroundAsync(Task operation, string name) + private async Task OpenReceiptSubscriptionAsync( + string commandId, + CancellationToken cancellationToken) { + var recordKey = ChildRecordKey(RequireWorldInteraction().ReceiptRecordRef, commandId); + var lease = await _meshClient!.LeaseDocumentAsync( + _target, + recordKey, + cancellationToken) + .ConfigureAwait(false); + IDisposable? watch = null; try { - await operation.ConfigureAwait(false); + watch = lease.Handle.Watch(receipt => QueueReceipt(commandId, receipt)); + try { QueueReceipt(commandId, await lease.Handle.LatestAsync().ConfigureAwait(false)); } + catch (KeyNotFoundException) { } + return new ReceiptSubscription(lease, watch); } - catch (Exception error) + catch { - _liveDocuments.Enqueue(new BackgroundFailure(name, error)); + watch?.Dispose(); + lease.Dispose(); + throw; } } - private async Task OpenAssetCatalogAsync(string pointer, string platform, string cacheRoot) + private void QueueReceipt(string expectedCommandId, EveCommandReceiptDocument receipt) { - var document = await _mesh.DocumentAsync(_endpointId, pointer).ConfigureAwait(false); - var catalog = await document.LatestAsync().ConfigureAwait(false); - _liveDocuments.Enqueue(await PrepareAssetCatalogAsync(catalog, platform, cacheRoot).ConfigureAwait(false)); - _liveWatches.Add(document.Watch(next => + if (receipt == null) return; + lock (_pendingCommands) { - _ = Task.Run(async () => - _liveDocuments.Enqueue(await PrepareAssetCatalogAsync(next, platform, cacheRoot).ConfigureAwait(false))); + if (!_pendingCommands.TryGetValue(expectedCommandId, out var request) || + !ReceiptMatches(request, receipt)) + return; + } + _liveDocuments.Enqueue(receipt); + } + + private static void DisposeReceiptSubscription(Task subscription) + { + if (subscription.Status == TaskStatus.RanToCompletion) + subscription.Result.Dispose(); + else + _ = subscription.ContinueWith( + completed => { if (completed.Status == TaskStatus.RanToCompletion) completed.Result.Dispose(); }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private void DisposeLiveDocuments() + { + foreach (var watch in _documentWatches) watch.Dispose(); + _documentWatches.Clear(); + foreach (var lease in _documentLeases) lease.Dispose(); + _documentLeases.Clear(); + _liveDocumentsReady = false; + } + + private async Task LeaseDocumentAsync( + string recordKey, + Action publish, + CancellationToken cancellationToken, + bool publishInitial = true) + where TDocument : class + { + var lease = await _meshClient!.LeaseDocumentAsync(_target, recordKey, cancellationToken); + _documentLeases.Add(lease); + if (publishInitial) publish(await lease.Handle.LatestAsync()); + _documentWatches.Add(lease.Handle.Watch(publish)); + } + + private async Task LeaseCollectionAsync( + Action publish, + CancellationToken cancellationToken, + bool includeInitialSnapshot = true) + where TDocument : class + { + var lease = await _meshClient!.LeaseCollectionAsync( + _target, + includeInitialSnapshot, + cancellationToken); + _documentLeases.Add(lease); + if (includeInitialSnapshot) + foreach (var document in await lease.Handle.LatestAsync()) + publish(document); + _documentWatches.Add(lease.Handle.WatchChanges(change => + { + if (change.Document != null) publish(change.Document); })); } - private void ApplyAssetCatalog(EveAssetCatalogDocument catalog) + private static IEnumerable EnumerateEmbeddedDocuments(EveSurfaceComponent component) { - if (catalog == null || catalog.Version == CurrentAssetCatalogVersion) return; - var platform = CurrentBundlePlatform(); - var cacheRoot = AssetCacheRoot(); - _assetCatalogTask = Task.Run(async () => - _liveDocuments.Enqueue(await PrepareAssetCatalogAsync(catalog, platform, cacheRoot).ConfigureAwait(false))); + foreach (var slot in component.EmbeddedDocuments ?? Array.Empty()) + yield return slot; + foreach (var child in component.Children ?? Array.Empty()) + foreach (var slot in EnumerateEmbeddedDocuments(child)) + yield return slot; } - private void ApplyPreparedAssetCatalog(PreparedAssetCatalog prepared) + private sealed class PendingSurfaceDocument { - var catalog = prepared.Catalog; - if (catalog.Version == CurrentAssetCatalogVersion) return; - - foreach (var bundle in _assetBundles) bundle.Unload(unloadAllLoadedObjects: false); - _assetBundles.Clear(); - _prefabs.Clear(); - _nativeAssets.Clear(); - _cameraCullingMasks.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, prepared.Platform, StringComparison.Ordinal)) - }) - .Where(selection => selection.Variant != null) - .ToArray(); - ReadCameraPolicies(selected.Select(selection => selection.Variant!)); - foreach (var group in selected.GroupBy(selection => selection.Variant!.Uri, StringComparer.Ordinal)) + public PendingSurfaceDocument(string recordKey, EveSurfaceDocument document) { - var variant = group.First().Variant!; - var bytes = prepared.BundleBytes[variant.Uri]; - 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) + RecordKey = recordKey ?? ""; + Document = document ?? throw new ArgumentNullException(nameof(document)); + } + + public string RecordKey { get; } + public EveSurfaceDocument Document { get; } + } + + private sealed class PreparedSurface + { + public PreparedSurface( + EveUnitySceneProviderSurfaceDocument document, + IReadOnlyCollection continuousCommands) + { + Document = document; + ContinuousCommands = continuousCommands; + } + + public EveUnitySceneProviderSurfaceDocument Document { get; } + public IReadOnlyCollection ContinuousCommands { get; } + } + + private static void TraceHotState(string message) + { + if (string.Equals(Environment.GetEnvironmentVariable("AETHERIA_TRACE_CLIENT_RUDP"), "1", StringComparison.Ordinal)) + Debug.Log($"EveUnity CultMesh hot state: {message}"); + } + + private async Task RefreshAssetCatalogAsync(CancellationToken cancellationToken) + { + var source = ResolveAssetSource( + _baseSurface ?? throw new InvalidOperationException("The provider surface must be available before its assets are resolved.")); + if (string.IsNullOrWhiteSpace(source.ManifestRecordRef)) + return; + + var candidate = await BuildAssetGenerationAsync(source, cancellationToken); + CommitAssetGeneration(candidate); + } + + private async Task RefreshAssetsForSurfaceAsync( + EveSurfaceDocument surface, + AssetSource source, + long generation, + CancellationToken cancellationToken) + { + Exception? finalError = null; + Interlocked.Exchange(ref _lastAssetPresentationAttemptCount, 0); + for (var attempt = 1; attempt <= AssetPresentationAttemptLimit; attempt++) + { + Interlocked.Exchange(ref _lastAssetPresentationAttemptCount, attempt); + try { - var value = bundle.LoadAsset(selection.Variant!.AssetKey); - if (value == null) continue; - _nativeAssets[selection.Asset.AssetRef] = value; - if (selection.Asset.Metadata.TryGetValue("presentationRole", out var role) && - !string.IsNullOrWhiteSpace(role)) - _nativeAssets[role] = value; - if (value is GameObject prefab) + if (generation != Interlocked.Read(ref _surfaceAssetGeneration)) + return; + await _assetGenerationGate.WaitAsync(cancellationToken); + try { - _prefabs[selection.Asset.AssetRef] = prefab; - if (!string.IsNullOrWhiteSpace(role)) _prefabs[role] = prefab; + if (generation != Interlocked.Read(ref _surfaceAssetGeneration)) + return; + var candidate = await BuildAssetGenerationAsync(source, cancellationToken); + if (generation != Interlocked.Read(ref _surfaceAssetGeneration)) + { + candidate.Dispose(); + return; + } + CommitAssetGeneration(candidate, surface); + return; + } + finally + { + _assetGenerationGate.Release(); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception error) + { + finalError = error; + if (generation != Interlocked.Read(ref _surfaceAssetGeneration)) + return; + if (attempt < AssetPresentationAttemptLimit) + { + try + { + await Task.Delay(TimeSpan.FromMilliseconds(100 * attempt), cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } } } } + RetireFailedBaseSurfaceCandidate(surface.Version, generation); + _liveDocuments.Enqueue(finalError ?? new InvalidOperationException( + $"Eve surface {surface.Version} asset preparation exhausted its retry policy.")); + } - CurrentAssetCatalogVersion = catalog.Version; - if (_deferredEntityView != null) + private AssetSource ResolveAssetSource(EveSurfaceDocument surface) + { + var world = FindWorldScene(surface.Surface.Root); + var manifestRecordRef = world?.GetProp("assetManifest") ?? ""; + if (string.IsNullOrWhiteSpace(manifestRecordRef)) + manifestRecordRef = RequireWorldInteraction().AssetManifestRecordRef; + var providerId = world?.GetProp("assetProviderId") ?? ""; + var verseId = world?.GetProp("assetVerseId") ?? ""; + var authorityRuntimeId = world?.GetProp("assetAuthorityRuntimeId") ?? ""; + var requiredCatalogVersion = long.TryParse( + world?.GetProp("assetCatalogVersion"), + out var parsedCatalogVersion) + ? Math.Max(0, parsedCatalogVersion) + : 0; + var rendezvousEndpoints = ParseEndpointList(world?.GetProp("assetRendezvousEndpoints") ?? ""); + var hasQualifiedSource = !string.IsNullOrWhiteSpace(providerId) || + !string.IsNullOrWhiteSpace(verseId) || + !string.IsNullOrWhiteSpace(authorityRuntimeId); + if (!hasQualifiedSource) + return new AssetSource( + _target, + _providerId, + manifestRecordRef, + Array.Empty(), + requiredCatalogVersion); + if (string.IsNullOrWhiteSpace(providerId) || string.IsNullOrWhiteSpace(verseId) || + string.IsNullOrWhiteSpace(authorityRuntimeId)) + throw new InvalidOperationException( + "A provider-qualified Eve asset source requires assetProviderId, assetVerseId, and assetAuthorityRuntimeId."); + return new AssetSource( + new CultMeshSessionTarget(verseId, authorityRuntimeId), + providerId, + manifestRecordRef, + rendezvousEndpoints, + requiredCatalogVersion); + } + + private async Task BuildAssetGenerationAsync( + AssetSource source, + CancellationToken cancellationToken) + { + while (true) { - var deferred = _deferredEntityView; - _deferredEntityView = null; - EntityViewAvailable?.Invoke(deferred); + cancellationToken.ThrowIfCancellationRequested(); + var crossTarget = IsCrossTarget(source); + var endpoints = AssetRendezvousEndpoints(source, crossTarget); + var candidate = new AssetGeneration( + source, + source.Identity, + catalogVersion: -1, + crossTarget || source.RendezvousEndpoints.Count > 0 + ? CreateMeshClient( + endpoints, + AssetAuthorityTrust(source)) + : null); + try + { + var catalog = await AttachAssetCatalogSubscriptionAsync( + candidate, + source.RequiredCatalogVersion, + cancellationToken); + ConfigureAssetCatalog(candidate, catalog); + ReuseCompatibleBundles(_assetGeneration, candidate); + await PreloadAssetsAsync(candidate, cancellationToken); + if (source.RequiredCatalogVersion > 0) + return candidate; + var latest = await candidate.CatalogLease!.Handle.LatestAsync(); + candidate.ObserveCatalog(latest); + if (latest.Version != candidate.CatalogVersion) + { + candidate.Dispose(); + continue; + } + return candidate; + } + catch + { + candidate.Dispose(); + throw; + } } } - private async Task PrepareAssetCatalogAsync( + private bool IsCrossTarget(AssetSource source) => + !string.Equals(source.Target.VerseId, _target.VerseId, StringComparison.Ordinal) || + !string.Equals(source.Target.AuthorityRuntimeId, _target.AuthorityRuntimeId, StringComparison.Ordinal); + + private CultMeshAuthorityTrustPolicy AssetAuthorityTrust(AssetSource source) => + IsCrossTarget(source) ? _crossTargetAuthorityTrust : _authorityTrust; + + private IReadOnlyList AssetRendezvousEndpoints(AssetSource source, bool crossTarget) + { + if (source.RendezvousEndpoints.Count > 0) + return source.RendezvousEndpoints; + if (crossTarget) + throw new InvalidOperationException( + $"Cross-target asset source '{source.Target.VerseId}/{source.Target.AuthorityRuntimeId}' " + + "must advertise at least one Odin rendezvous endpoint."); + return new[] { _rendezvousEndpoint }; + } + + private async Task AttachAssetCatalogSubscriptionAsync( + AssetGeneration candidate, + long requiredCatalogVersion, + CancellationToken cancellationToken) + { + var lease = await AssetMeshClient(candidate) + .LeaseDocumentAsync( + candidate.Source.Target, + candidate.Source.ManifestRecordRef, + cancellationToken); + IDisposable? watch = null; + try + { + var catalog = await lease.Handle.LatestAsync(); + candidate.ObserveCatalog(catalog); + if (requiredCatalogVersion > 0) + { + RequireCatalogVersion(catalog, requiredCatalogVersion, candidate.Source.ManifestRecordRef); + candidate.CatalogLease = lease; + return catalog; + } + + watch = lease.Handle.Watch(observedCatalog => + { + var pending = candidate.ObserveCatalog(observedCatalog); + if (pending != null) + QueueAssetCatalogUpdate(candidate.SourceIdentity, pending); + }); + candidate.CatalogWatch = watch; + candidate.CatalogLease = lease; + return catalog; + } + catch + { + watch?.Dispose(); + lease.Dispose(); + throw; + } + } + + private static void RequireCatalogVersion( EveAssetCatalogDocument catalog, - string platform, - string cacheRoot) + long requiredCatalogVersion, + string manifestRecordRef) { - var variants = catalog.Assets - .SelectMany(asset => asset.Variants) - .Where(variant => string.Equals(variant.RuntimeId, "unity-scene", StringComparison.Ordinal) && - string.Equals(variant.Platform, platform, StringComparison.Ordinal)) - .GroupBy(variant => variant.Uri, StringComparer.Ordinal) - .Select(group => group.First()) - .ToArray(); - var bundles = new Dictionary(StringComparer.Ordinal); - foreach (var variant in variants) - { - var descriptor = await _mesh.ReadAsync(_endpointId, variant.Uri) - .ConfigureAwait(false); - bundles[variant.Uri] = await ReadCachedBundleAsync(descriptor, variant, cacheRoot).ConfigureAwait(false); - } - return new PreparedAssetCatalog(catalog, platform, bundles); + if (catalog == null) throw new ArgumentNullException(nameof(catalog)); + if (requiredCatalogVersion <= 0 || catalog.Version == requiredCatalogVersion) + return; + throw new InvalidDataException( + $"Asset catalog record '{manifestRecordRef}' contains version {catalog.Version}, " + + $"but the Eve surface requires immutable version {requiredCatalogVersion}."); } - private void ReadCameraPolicies(IEnumerable variants) + private void CommitAssetGeneration(AssetGeneration candidate, EveSurfaceDocument? surface = null) + { + PreparedSurface prepared; + try + { + prepared = PrepareComposedSurface(surface ?? _baseSurface ?? + throw new InvalidOperationException("The base Eve surface is unavailable.")); + } + catch + { + candidate.Dispose(); + throw; + } + var previous = _assetGeneration; + previous.TransferSharedBundlesTo(candidate); + _assetGeneration = candidate; + var pendingCatalog = candidate.ActivateCatalogObservation(); + if (pendingCatalog != null && candidate.Source.RequiredCatalogVersion <= 0) + QueueAssetCatalogUpdate(candidate.SourceIdentity, pendingCatalog); + if (surface != null) + _baseSurface = surface; + try + { + ApplyPreparedSurface(prepared, surface?.Version); + } + finally + { + previous.Dispose(); + } + } + + private static void ReuseCompatibleBundles(AssetGeneration current, AssetGeneration candidate) + { + foreach (var pair in candidate.BundleVariants) + { + if (!current.LoadedBundlesByUri.TryGetValue(pair.Key, out var bundle) || + !current.BundleVariants.TryGetValue(pair.Key, out var existing) || + !string.Equals(existing.ContentHash, pair.Value.ContentHash, StringComparison.Ordinal) || + existing.SizeBytes != pair.Value.SizeBytes) + continue; + var selections = candidate.BundleSelections[pair.Key]; + if (selections.Any(selection => + !current.NativeAssets.ContainsKey(selection.Asset.AssetRef))) + continue; + + candidate.AssetBundles.Add(bundle); + candidate.BorrowedAssetBundles.Add(bundle); + candidate.LoadedBundlesByUri[pair.Key] = bundle; + candidate.LoadedBundleUris.Add(pair.Key); + foreach (var selection in selections) + { + var value = current.NativeAssets[selection.Asset.AssetRef]; + candidate.NativeAssets[selection.Asset.AssetRef] = value; + if (value is GameObject prefab) + candidate.Prefabs[selection.Asset.AssetRef] = prefab; + if (selection.Asset.Metadata.TryGetValue("presentationRole", out var role) && + !string.IsNullOrWhiteSpace(role)) + { + candidate.NativeAssets[role] = value; + if (value is GameObject rolePrefab) + candidate.Prefabs[role] = rolePrefab; + } + } + } + } + + private static EveSurfaceComponent? FindWorldScene(EveSurfaceComponent component) + { + if (string.Equals(component.Kind, "world.scene3d", StringComparison.Ordinal) || + string.Equals(component.Kind, "world", StringComparison.Ordinal)) + return component; + foreach (var child in component.Children ?? Array.Empty()) + { + var found = FindWorldScene(child); + if (found != null) return found; + } + return null; + } + + private static IReadOnlyList ParseEndpointList(string value) => + (value ?? "") + .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(endpoint => endpoint.Trim()) + .Where(endpoint => endpoint.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + private static void ConfigureAssetCatalog(AssetGeneration candidate, EveAssetCatalogDocument catalog) + { + var selected = catalog.Assets + .Select(asset => new SelectedAsset( + asset, + 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(); + if (selected.Length == 0) + { + var advertisedVariants = catalog.Assets + .SelectMany(asset => asset.Variants.Select(variant => + $"{asset.AssetRef}:{variant.RuntimeId}/{variant.Platform}")) + .ToArray(); + throw new InvalidOperationException( + $"Provider asset catalog '{catalog.CatalogId}' has no unity-scene/{CurrentBundlePlatform()} variant. " + + $"Advertised variants: {string.Join(", ", advertisedVariants)}"); + } + candidate.SetCatalogVersion(catalog.Version); + ReadCameraPolicies(candidate, selected.Select(selection => selection.Variant!)); + foreach (var selection in selected) + { + var variant = selection.Variant!; + candidate.AssetSelections[selection.Asset.AssetRef] = selection; + candidate.BundleVariants[variant.Uri] = variant; + if (!candidate.BundleSelections.TryGetValue(variant.Uri, out var group)) + { + group = new List(); + candidate.BundleSelections[variant.Uri] = group; + } + group.Add(selection); + var metadata = MergeAssetMetadata(selection.Asset.Metadata, variant.Metadata); + candidate.NativeAssetMetadata[selection.Asset.AssetRef] = metadata; + if (selection.Asset.Metadata.TryGetValue("presentationRole", out var role) && + !string.IsNullOrWhiteSpace(role)) + { + candidate.AssetSelections[role] = selection; + candidate.NativeAssetMetadata[role] = metadata; + } + } + } + + private void QueueAssetCatalogUpdate(string sourceIdentity, EveAssetCatalogDocument catalog) + { + if (catalog == null) throw new ArgumentNullException(nameof(catalog)); + if (_assetGeneration.Source.RequiredCatalogVersion > 0) + { + _pendingAssetCatalog = null; + return; + } + _pendingAssetCatalog = new PendingAssetCatalogUpdate( + sourceIdentity, + catalog); + if (_assetCatalogUpdateRunning) return; + _assetCatalogUpdateRunning = true; + _ = DrainAssetCatalogUpdatesAsync(); + } + + private async Task DrainAssetCatalogUpdatesAsync() + { + try + { + while (!_lifetime.IsCancellationRequested) + { + var update = _pendingAssetCatalog; + _pendingAssetCatalog = null; + if (update == null) break; + if (_assetGeneration.Source.RequiredCatalogVersion > 0) + continue; + if (!string.Equals(update.SourceIdentity, CurrentAssetSourceIdentity, StringComparison.Ordinal)) + continue; + if (update.Catalog.Version == CurrentAssetCatalogVersion) + continue; + await _assetGenerationGate.WaitAsync(_lifetime.Token); + try + { + if (!string.Equals(update.SourceIdentity, CurrentAssetSourceIdentity, StringComparison.Ordinal) || + update.Catalog.Version == CurrentAssetCatalogVersion || + _assetGeneration.Source.RequiredCatalogVersion > 0) + continue; + var currentSource = _assetGeneration.Source; + var source = new AssetSource( + currentSource.Target, + currentSource.ProviderId, + currentSource.ManifestRecordRef, + currentSource.RendezvousEndpoints, + 0); + var candidate = await BuildAssetGenerationAsync(source, _lifetime.Token); + if (!string.Equals(update.SourceIdentity, CurrentAssetSourceIdentity, StringComparison.Ordinal) || + _assetGeneration.Source.RequiredCatalogVersion > 0) + { + candidate.Dispose(); + continue; + } + CommitAssetGeneration(candidate); + } + finally + { + _assetGenerationGate.Release(); + } + } + } + catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) + { + } + catch (Exception error) + { + _liveDocuments.Enqueue(error); + } + finally + { + _assetCatalogUpdateRunning = false; + if (_pendingAssetCatalog != null && !_lifetime.IsCancellationRequested) + QueueAssetCatalogUpdate(_pendingAssetCatalog.SourceIdentity, _pendingAssetCatalog.Catalog); + } + } + + private async Task PreloadAssetsAsync(AssetGeneration candidate, CancellationToken cancellationToken) + { + foreach (var uri in candidate.BundleVariants.Keys.OrderBy(value => value, StringComparer.Ordinal).ToArray()) + await EnsureBundleLoadedAsync(candidate, uri, cancellationToken); + } + + private async Task EnsureBundleLoadedAsync( + AssetGeneration candidate, + string uri, + CancellationToken cancellationToken) + { + if (candidate.LoadedBundleUris.Contains(uri)) return; + if (!candidate.BundleVariants.TryGetValue(uri, out var variant)) + throw new InvalidOperationException($"Provider asset bundle '{uri}' is not present in the selected runtime catalog."); + if (!candidate.LoadingBundleUris.Add(uri)) + throw new InvalidOperationException($"Provider asset bundle dependency cycle includes '{uri}'."); + try + { + if (variant.Metadata.TryGetValue("unity.bundleDependencyUris", out var dependencies)) + foreach (var dependency in dependencies.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)) + await EnsureBundleLoadedAsync(candidate, dependency, cancellationToken); + + var groupElapsed = Stopwatch.StartNew(); + var descriptor = await AssetMeshClient(candidate) + .ReadAsync(candidate.Source.Target, uri, cancellationToken); + TraceStartup("asset-manifest", groupElapsed); + var bundle = await LoadVerifiedBundleAsync(candidate, descriptor, variant, cancellationToken); + TraceStartup("asset-transfer-and-bundle-load", groupElapsed); + if (bundle == null) + throw new InvalidOperationException($"Unity could not load provider asset bundle '{uri}'."); + candidate.AssetBundles.Add(bundle); + candidate.LoadedBundlesByUri[uri] = bundle; + var bundleAssetNames = bundle.GetAllAssetNames(); + foreach (var selection in candidate.BundleSelections[uri]) + { + var bundleAssetName = ResolveBundleAssetName(bundleAssetNames, selection.Variant!.AssetKey); + var value = bundleAssetName == null + ? null + : await LoadBundleAssetAsync(bundle, bundleAssetName, selection.Asset.AssetKind, cancellationToken); + if (value == null) + throw new InvalidOperationException( + $"Provider asset '{selection.Asset.AssetRef}' advertises missing Unity bundle asset " + + $"'{selection.Variant.AssetKey}'. Available assets: " + string.Join(", ", bundleAssetNames)); + candidate.NativeAssets[selection.Asset.AssetRef] = value; + if (selection.Asset.Metadata.TryGetValue("presentationRole", out var role) && + !string.IsNullOrWhiteSpace(role)) + candidate.NativeAssets[role] = value; + if (value is GameObject prefab) + { + candidate.Prefabs[selection.Asset.AssetRef] = prefab; + if (!string.IsNullOrWhiteSpace(role)) candidate.Prefabs[role] = prefab; + } + } + candidate.LoadedBundleUris.Add(uri); + TraceStartup("bundle-assets", groupElapsed); + } + finally + { + candidate.LoadingBundleUris.Remove(uri); + } + } + + private static async Task LoadBundleAssetAsync( + AssetBundle bundle, + string assetName, + string assetKind, + CancellationToken cancellationToken) + { + var request = string.Equals(assetKind, "sprite", StringComparison.Ordinal) + ? bundle.LoadAssetAsync(assetName) + : bundle.LoadAssetAsync(assetName); + await AwaitUnityAsync(request, cancellationToken); + return request.asset; + } + + private sealed class SelectedAsset + { + public SelectedAsset(EveAssetCatalogEntry asset, EveAssetVariant? variant) + { + Asset = asset; + Variant = variant; + } + + public EveAssetCatalogEntry Asset { get; } + public EveAssetVariant? Variant { get; } + } + + private sealed class AssetGeneration : IDisposable + { + private readonly object _catalogObservationGate = new object(); + private EveAssetCatalogDocument? _latestObservedCatalog; + private bool _catalogObservationActive; + + public AssetGeneration( + AssetSource source, + string sourceIdentity, + long catalogVersion, + CultMeshClient? meshClient) + { + Source = source; + SourceIdentity = sourceIdentity ?? ""; + CatalogVersion = catalogVersion; + MeshClient = meshClient; + } + + public AssetSource Source { get; } + public string SourceIdentity { get; } + public long CatalogVersion { get; private set; } + public CultMeshClient? MeshClient { get; } + public CultMeshDocumentLease? CatalogLease { get; set; } + public IDisposable? CatalogWatch { get; set; } + public CultMeshContentTransferService? ContentTransfer { get; set; } + public CultMeshVerifiedBodyMappingBroker? BodyMappings { get; set; } + public CultMeshBodyTransportKind? BodyTransportKind { get; set; } + public Dictionary Prefabs { get; } = new Dictionary(StringComparer.Ordinal); + public Dictionary NativeAssets { get; } = new Dictionary(StringComparer.Ordinal); + public Dictionary> NativeAssetMetadata { get; } = + new Dictionary>(StringComparer.Ordinal); + public List AssetBundles { get; } = new List(); + public HashSet BorrowedAssetBundles { get; } = new HashSet(); + public Dictionary LoadedBundlesByUri { get; } = + new Dictionary(StringComparer.Ordinal); + public Dictionary AssetSelections { get; } = + new Dictionary(StringComparer.Ordinal); + public Dictionary> BundleSelections { get; } = + new Dictionary>(StringComparer.Ordinal); + public Dictionary BundleVariants { get; } = + new Dictionary(StringComparer.Ordinal); + public HashSet LoadedBundleUris { get; } = new HashSet(StringComparer.Ordinal); + public HashSet LoadingBundleUris { get; } = new HashSet(StringComparer.Ordinal); + public List BodyLeases { get; } = new List(); + public Dictionary RenderChannelLayers { get; } = + new Dictionary(StringComparer.Ordinal); + + public void TransferSharedBundlesTo(AssetGeneration next) + { + foreach (var bundle in next.BorrowedAssetBundles) + AssetBundles.Remove(bundle); + next.BorrowedAssetBundles.Clear(); + } + + public void SetCatalogVersion(long catalogVersion) + { + if (CatalogVersion >= 0 && CatalogVersion != catalogVersion) + throw new InvalidOperationException("An asset generation cannot change its catalog version after configuration."); + CatalogVersion = catalogVersion; + } + + public EveAssetCatalogDocument? ObserveCatalog(EveAssetCatalogDocument catalog) + { + lock (_catalogObservationGate) + { + _latestObservedCatalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + return _catalogObservationActive && catalog.Version != CatalogVersion ? catalog : null; + } + } + + public EveAssetCatalogDocument? ActivateCatalogObservation() + { + lock (_catalogObservationGate) + { + _catalogObservationActive = true; + return _latestObservedCatalog != null && _latestObservedCatalog.Version != CatalogVersion + ? _latestObservedCatalog + : null; + } + } + + public void Dispose() + { + CatalogWatch?.Dispose(); + CatalogWatch = null; + CatalogLease?.Dispose(); + CatalogLease = null; + MeshClient?.Dispose(); + foreach (var bundle in AssetBundles) + if (!BorrowedAssetBundles.Contains(bundle)) + bundle.Unload(unloadAllLoadedObjects: false); + AssetBundles.Clear(); + BorrowedAssetBundles.Clear(); + foreach (var lease in BodyLeases) + lease.Dispose(); + BodyLeases.Clear(); + Prefabs.Clear(); + NativeAssets.Clear(); + NativeAssetMetadata.Clear(); + AssetSelections.Clear(); + BundleSelections.Clear(); + BundleVariants.Clear(); + LoadedBundleUris.Clear(); + LoadingBundleUris.Clear(); + LoadedBundlesByUri.Clear(); + RenderChannelLayers.Clear(); + } + } + + private static string? ResolveBundleAssetName(IEnumerable bundleAssetNames, string advertisedAssetKey) + { + return bundleAssetNames.FirstOrDefault(assetName => + string.Equals(assetName, advertisedAssetKey, StringComparison.OrdinalIgnoreCase)); + } + + private static IReadOnlyDictionary MergeAssetMetadata( + IReadOnlyDictionary assetMetadata, + IReadOnlyDictionary variantMetadata) + { + var merged = new Dictionary(StringComparer.Ordinal); + foreach (var pair in assetMetadata) merged[pair.Key] = pair.Value; + foreach (var pair in variantMetadata) merged[pair.Key] = pair.Value; + return merged; + } + + private static void ReadCameraPolicies(AssetGeneration candidate, IEnumerable variants) { - _cameraCullingMasks.Clear(); foreach (var variant in variants) { foreach (var pair in variant.Metadata) { - const string prefix = "view."; - const string suffix = ".excludeUnityLayers"; + const string prefix = "renderChannel."; + const string suffix = ".unityLayer"; if (!pair.Key.StartsWith(prefix, StringComparison.Ordinal) || !pair.Key.EndsWith(suffix, StringComparison.Ordinal)) continue; - var viewId = pair.Key.Substring(prefix.Length, pair.Key.Length - prefix.Length - suffix.Length); - var mask = -1; - foreach (var token in (pair.Value ?? "").Split(',')) - { - if (int.TryParse(token.Trim(), out var layer) && layer >= 0 && layer < 32) - mask &= ~(1 << layer); - } - _cameraCullingMasks[viewId] = mask; + var channel = pair.Key.Substring(prefix.Length, pair.Key.Length - prefix.Length - suffix.Length); + if (!string.IsNullOrWhiteSpace(channel) && + int.TryParse(pair.Value, out var layer) && layer >= 0 && layer < 32) + candidate.RenderChannelLayers[channel] = layer; } } } - private long CurrentAssetCatalogVersion { get; set; } = -1; + private long CurrentAssetCatalogVersion => _assetGeneration.CatalogVersion; + + private string CurrentAssetSourceIdentity => _assetGeneration.SourceIdentity; private static string CurrentBundlePlatform() { @@ -479,126 +2003,214 @@ namespace GameCult.Eve.UnityScene : Application.platform.ToString(); } - private byte[] ReadBundle(CultMeshCdnArtifactManifest manifest) - => ReadBundleAsync(manifest).GetAwaiter().GetResult(); - - private async Task ReadBundleAsync(CultMeshCdnArtifactManifest manifest) + private CultMeshContentTransferService ContentTransfer(AssetGeneration candidate) { - var bytes = new byte[checked((int)manifest.SizeBytes)]; - var references = manifest.Chunks.OrderBy(chunk => chunk.Offset).ToArray(); - var batches = references.Select((reference, index) => new { reference, index }) - .GroupBy(value => value.index / 2) - .Select(group => group.Select(value => value.reference).ToArray()) - .ToArray(); - using var concurrency = new SemaphoreSlim(2, 2); - var downloads = batches.Select(async batch => - { - await concurrency.WaitAsync().ConfigureAwait(false); - try + if (candidate.ContentTransfer != null) + return candidate.ContentTransfer; + if (_advertisement == null) + throw new InvalidOperationException("The Eve provider advertisement must be prepared before content transfer."); + if (string.IsNullOrWhiteSpace(_advertisement!.ServiceId)) + throw new InvalidOperationException($"Eve provider '{_advertisement.ProviderId}' has no service-instance identity for content transport."); + var cacheRoot = Environment.GetEnvironmentVariable("EVEUNITY_ASSET_CACHE_PATH"); + if (string.IsNullOrWhiteSpace(cacheRoot)) + cacheRoot = Path.Combine(Application.persistentDataPath, "EveUnity", "assets"); + candidate.BodyMappings = new CultMeshVerifiedBodyMappingBroker(cacheRoot); + _contentState ??= new CultCache( + CultMesh.CreateCultCacheDocumentRegistry(typeof(CultMeshContentTransferStateDocument))); + candidate.ContentTransfer = new CultMeshContentTransferService( + _contentState, + new[] { - var chunks = await _mesh.ReadManyAsync( - _endpointId, - batch.Select(reference => reference.RecordKey).ToArray()) - .ConfigureAwait(false); - foreach (var reference in batch) - { - var chunk = chunks.FirstOrDefault(candidate => - string.Equals(candidate.ChunkHash, reference.ChunkHash, StringComparison.Ordinal)); - 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); - } - } - finally - { - concurrency.Release(); - } - }).ToArray(); - await Task.WhenAll(downloads).ConfigureAwait(false); - return bytes; + AssetMeshClient(candidate).ContentProvider( + candidate.Source.ProviderId, + candidate.Source.Target) + }, + new CultMeshContentTransferOptions(cacheRoot), + candidate.BodyMappings); + return candidate.ContentTransfer; } - private byte[] ReadCachedBundle(CultMeshCdnArtifactManifest manifest, EveAssetVariant variant) - => ReadCachedBundleAsync(manifest, variant, AssetCacheRoot()).GetAwaiter().GetResult(); - - private async Task ReadCachedBundleAsync( + private async Task LoadVerifiedBundleAsync( + AssetGeneration candidate, CultMeshCdnArtifactManifest manifest, EveAssetVariant variant, - string cacheRoot) + CancellationToken cancellationToken) { - 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 = await ReadBundleAsync(manifest).ConfigureAwait(false); - 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 string AssetCacheRoot() - { - var cacheRoot = Environment.GetEnvironmentVariable("EVEUNITY_ASSET_CACHE_PATH"); - return string.IsNullOrWhiteSpace(cacheRoot) - ? Path.Combine(Application.persistentDataPath, "EveUnity", "assets") - : cacheRoot; - } - - private sealed class PreparedAssetCatalog - { - public PreparedAssetCatalog(EveAssetCatalogDocument catalog, string platform, Dictionary bundleBytes) - { - Catalog = catalog; - Platform = platform; - BundleBytes = bundleBytes; - } - - public EveAssetCatalogDocument Catalog { get; } - public string Platform { get; } - public Dictionary BundleBytes { get; } - } - - private sealed class BackgroundFailure - { - public BackgroundFailure(string operation, Exception error) - { - Operation = operation; - Error = error; - } - - public string Operation { get; } - public Exception Error { get; } - } - - private static void VerifyBundle(byte[] bytes, EveAssetVariant variant) - { - if (bytes.LongLength != variant.SizeBytes) + var hash = NormalizeContentHash(variant.ContentHash); + if (manifest.SizeBytes != 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)) + if (!string.Equals(NormalizeContentHash(manifest.ContentHash), + hash, StringComparison.Ordinal)) throw new InvalidOperationException($"Provider asset bundle '{variant.Uri}' hash did not match its catalog."); + + var now = DateTimeOffset.UtcNow; + var network = NetworkArtifactDescriptor(manifest, now, TimeSpan.FromMinutes(5)); + var elapsed = Stopwatch.StartNew(); + var mapped = await ContentTransfer(candidate) + .FetchMappedContentAsync(manifest, network, now, TimeSpan.FromMinutes(5), cancellationToken); + TraceStartup("content-materialization", elapsed); + var validationRequest = new CultMeshBodyValidationRequest + { + BodyId = network.BodyId, + SchemaId = network.SchemaId, + LayoutVersion = network.LayoutVersion, + ProducerEpoch = network.ProducerEpoch, + Sequence = network.Sequence, + Capacity = network.Capacity, + AccessMode = CultMeshBodyAccessMode.ReadOnly, + NowUtc = now + }; + var transport = new CultMeshBodyTransportService( + new ICultMeshBodyTransportAdapter[] + { + new CultMeshMappedBodyAdapter(candidate.BodyMappings!), + new CultMeshNetworkBodyAdapter(_ => File.ReadAllBytes(mapped.VerifiedPath)) + }, + descriptor => string.Equals(descriptor.BodyId, manifest.ArtifactId, StringComparison.Ordinal) && + string.Equals(descriptor.SemanticHash, hash, StringComparison.Ordinal)); + var negotiated = transport.NegotiateReadOnly(mapped.Descriptor, network, validationRequest); + candidate.BodyTransportKind = negotiated.SelectedTransport; + candidate.BodyLeases.Add(negotiated.Lease); + var bundleRequest = AssetBundle.LoadFromFileAsync(mapped.VerifiedPath); + await AwaitUnityAsync(bundleRequest, cancellationToken); + TraceStartup("asset-bundle-load-from-file", elapsed); + return bundleRequest.assetBundle; + } + + private static async Task AwaitUnityAsync(AsyncOperation operation, CancellationToken cancellationToken) + { + while (!operation.isDone) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + } + cancellationToken.ThrowIfCancellationRequested(); + } + + private static CultMeshBodyDescriptor NetworkArtifactDescriptor( + CultMeshCdnArtifactManifest manifest, + DateTimeOffset now, + TimeSpan leaseDuration) + { + if (manifest.SizeBytes > int.MaxValue) + throw new InvalidOperationException("CultMesh artifact capacity exceeds the current body descriptor bound."); + return new CultMeshBodyDescriptor + { + BodyId = manifest.ArtifactId, + SchemaId = "gamecult.mesh.cdn-artifact.v1", + LayoutVersion = 1, + ByteSize = manifest.SizeBytes, + Capacity = checked((int)manifest.SizeBytes), + ProducerEpoch = 1, + Sequence = 0, + AccessMode = CultMeshBodyAccessMode.ReadOnly, + Synchronization = CultMeshBodySynchronization.ImmutableSequence, + LeaseExpiresAtUnixMs = now.Add(leaseDuration).ToUnixTimeMilliseconds(), + TransportKind = CultMeshBodyTransportKind.Network, + CapabilityToken = CultMeshCdnArtifactManifest.CreateRecordKey(manifest).Value, + SemanticHash = NormalizeContentHash(manifest.ContentHash) + }; } private void PublishReceipt(EveCommandReceiptDocument receipt) { - if (!_pendingCommandIds.Contains(receipt.CommandId) || !_publishedReceiptIds.Add(receipt.ReceiptId)) - return; + var terminal = IsTerminalReceiptState(receipt.State); + lock (_pendingCommands) + { + if (!_pendingCommands.TryGetValue(receipt.CommandId, out var request) || + !ReceiptMatches(request, receipt)) + return; + } + if (terminal) + { + _commandOutbox?.Acknowledge(receipt.CommandId); + if (DeferTerminalReceiptUntilPresentation(receipt)) + return; + } + FinalizeReceipt(receipt, terminal); + } + + private void EnsurePresentationWritable() + { + lock (_presentationFinalityGate) + { + if (_presentationBarrierVersion > _mountedBaseSurfaceVersion) + throw new InvalidOperationException( + $"The Eve presentation is awaiting provider surface version {_presentationBarrierVersion}; " + + $"mounted base version {_mountedBaseSurfaceVersion} is read-only until that generation commits."); + } + } + + private void BeginPresentationTransition(long sourceVersion) + { + lock (_presentationFinalityGate) + { + if (sourceVersion <= _mountedBaseSurfaceVersion) + return; + _presentationBarrierVersion = Math.Max(_presentationBarrierVersion, sourceVersion); + } + } + + private bool DeferTerminalReceiptUntilPresentation(EveCommandReceiptDocument receipt) + { + if (receipt.PresentationSurfaceVersion <= 0) + return false; + lock (_presentationFinalityGate) + { + if (receipt.PresentationSurfaceVersion <= _mountedBaseSurfaceVersion) + return false; + _presentationBarrierVersion = Math.Max( + _presentationBarrierVersion, + receipt.PresentationSurfaceVersion); + _deferredTerminalReceipts[receipt.CommandId] = receipt; + return true; + } + } + + private void CompletePresentationTransition(long committedVersion) + { + EveCommandReceiptDocument[] ready; + lock (_presentationFinalityGate) + { + _mountedBaseSurfaceVersion = Math.Max(_mountedBaseSurfaceVersion, committedVersion); + if (_activeBaseSurfaceCandidateVersion == committedVersion) + { + _activeBaseSurfaceCandidateVersion = -1; + _activeBaseSurfaceCandidateGeneration = -1; + } + ready = _deferredTerminalReceipts.Values + .Where(receipt => receipt.PresentationSurfaceVersion <= _mountedBaseSurfaceVersion) + .ToArray(); + foreach (var receipt in ready) + _deferredTerminalReceipts.Remove(receipt.CommandId); + if (_presentationBarrierVersion <= _mountedBaseSurfaceVersion) + _presentationBarrierVersion = -1; + } + foreach (var receipt in ready) + FinalizeReceipt(receipt, terminal: true); + } + + private long MountedBaseSurfaceVersion + { + get + { + lock (_presentationFinalityGate) + return _mountedBaseSurfaceVersion; + } + } + + private void FinalizeReceipt(EveCommandReceiptDocument receipt, bool terminal) + { + if (terminal) + { + lock (_pendingCommands) + { + if (!_pendingCommands.ContainsKey(receipt.CommandId)) + return; + _pendingCommands.Remove(receipt.CommandId); + } + ForgetPendingCommand(receipt.CommandId); + } CommandReceiptAvailable?.Invoke(new EveUnitySceneCommandReceipt( receipt.ReceiptId, receipt.Command, @@ -611,11 +2223,101 @@ namespace GameCult.Eve.UnityScene receipt.SurfaceId, receipt.Message, DateTimeOffset.TryParse(receipt.IssuedAtUtc, out var issuedAt) ? issuedAt : null, - receipt.SourceVersion)); - if (string.Equals(receipt.State, "accepted", StringComparison.OrdinalIgnoreCase) || - string.Equals(receipt.State, "denied", StringComparison.OrdinalIgnoreCase) || - string.Equals(receipt.State, "reconciled", StringComparison.OrdinalIgnoreCase)) - _pendingCommandIds.Remove(receipt.CommandId); + receipt.SourceVersion, + receipt.Navigation == null + ? null + : new EveUnitySceneNavigationTarget( + receipt.Navigation.VerseId, + receipt.Navigation.ProviderId, + receipt.Navigation.SurfaceId, + receipt.Navigation.SurfaceKind, + receipt.Navigation.RendezvousEndpoints, + receipt.Navigation.AuthorityRuntimeId))); + } + + private sealed class AssetSource + { + public AssetSource( + CultMeshSessionTarget target, + string providerId, + string manifestRecordRef, + IReadOnlyList rendezvousEndpoints) + : this(target, providerId, manifestRecordRef, rendezvousEndpoints, 0) + { + } + + public AssetSource( + CultMeshSessionTarget target, + string providerId, + string manifestRecordRef, + IReadOnlyList rendezvousEndpoints, + long requiredCatalogVersion) + { + Target = target; + ProviderId = providerId ?? ""; + ManifestRecordRef = manifestRecordRef ?? ""; + RendezvousEndpoints = rendezvousEndpoints ?? Array.Empty(); + RequiredCatalogVersion = Math.Max(0, requiredCatalogVersion); + Identity = Target.VerseId + "\u001f" + Target.AuthorityRuntimeId + "\u001f" + + ProviderId + "\u001f" + ManifestRecordRef + "\u001f" + + string.Join("\u001e", RendezvousEndpoints); + } + + public CultMeshSessionTarget Target { get; } + public string ProviderId { get; } + public string ManifestRecordRef { get; } + public IReadOnlyList RendezvousEndpoints { get; } + public long RequiredCatalogVersion { get; } + public string Identity { get; } + } + + private sealed class PendingAssetCatalogUpdate + { + public PendingAssetCatalogUpdate(string sourceIdentity, EveAssetCatalogDocument catalog) + { + SourceIdentity = sourceIdentity ?? ""; + Catalog = catalog; + } + + public string SourceIdentity { get; } + public EveAssetCatalogDocument Catalog { get; } + } + + private bool ReceiptMatches(EveSurfaceCommandRequest request, EveCommandReceiptDocument receipt) => + string.Equals(receipt.Schema, EveCommandReceiptDocument.SchemaId, StringComparison.Ordinal) && + string.Equals(receipt.CommandId, request.CommandId, StringComparison.Ordinal) && + string.Equals(receipt.Command, request.Command, StringComparison.Ordinal) && + string.Equals(receipt.ProviderId, request.ProviderId, StringComparison.Ordinal) && + string.Equals(receipt.SurfaceId, request.SurfaceId, StringComparison.Ordinal) && + string.Equals(receipt.Authority, _target.AuthorityRuntimeId, StringComparison.Ordinal) && + string.Equals(receipt.InvocationHash, EveCommandInvocationHash.Compute(request), StringComparison.Ordinal) && + IsKnownReceiptState(receipt.State); + + private static bool IsKnownReceiptState(string state) => + string.Equals(state, "pending", StringComparison.OrdinalIgnoreCase) || + IsTerminalReceiptState(state); + + private static bool IsTerminalReceiptState(string state) => + string.Equals(state, "accepted", StringComparison.OrdinalIgnoreCase) || + string.Equals(state, "denied", StringComparison.OrdinalIgnoreCase) || + string.Equals(state, "reconciled", StringComparison.OrdinalIgnoreCase); + + private sealed class ReceiptSubscription : IDisposable + { + private IDisposable? _lease; + private IDisposable? _watch; + + public ReceiptSubscription(IDisposable lease, IDisposable watch) + { + _lease = lease; + _watch = watch; + } + + public void Dispose() + { + Interlocked.Exchange(ref _watch, null)?.Dispose(); + Interlocked.Exchange(ref _lease, null)?.Dispose(); + } } private EveWorldInteractionAdvertisement RequireWorldInteraction() diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs index 71c2425..3d356f3 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -1,9 +1,17 @@ using System; using System.IO; using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; using System.Threading.Tasks; +using Stopwatch = System.Diagnostics.Stopwatch; using GameCult.Eve.Surface; +using GameCult.Eve.PluginFields; +using GameCult.Eve.UnityScene.Fields; +using GameCult.Mesh; using UnityEngine; +using UnityEngine.Serialization; #nullable enable @@ -17,28 +25,39 @@ namespace GameCult.Eve.UnityScene IEveUnitySceneCommandReceiptSource, IEveUnityProviderRefreshSource, IEveUnityEntitySoaViewDocumentSource, - IEveUnityInputCapabilityDocumentSource, + IEveUnityFieldsSplatsDocumentSource, IEveUnityCameraRenderPolicySource, - IEveUnityNativeAssetProvider + IEveUnityNativeAssetProvider, + IEveUnityNativeAssetMetadataProvider, + IEveUnityInputCapabilitySource, + IEveUnityNavigableProvider { [SerializeField] private string rendezvousEndpoint = ""; [SerializeField] private string providerFilter = ""; [SerializeField] private string surfaceFilter = ""; [SerializeField] private string verseFilter = ""; [SerializeField] private string surfaceKind = "interactive-world"; - [SerializeField] private string replicaPath = ""; + [FormerlySerializedAs("replicaPath")] + [SerializeField] private string cacheDirectory = ""; [SerializeField] private string runtimeId = "eve-unity"; + private CultMeshAuthorityTrustPolicy _authorityTrust = new CultMeshAuthorityTrustPolicy( + CultMeshAuthorityTrustMode.AuthenticatedRemote); + private CultMeshAuthorityTrustPolicy _navigationAuthorityTrust = new CultMeshAuthorityTrustPolicy( + CultMeshAuthorityTrustMode.AuthenticatedRemote); private EveUnityCultMeshLiveProviderTransport? _transport; private EveUnitySceneLiveProviderBridge? _bridge; - private Task? _discovery; - private readonly ConcurrentQueue _pendingEntityViews = new ConcurrentQueue(); + private PreviousProvider? _previousProvider; + private bool _navigationRouteCommitted; + private int _connectionLeaseCount; + private Task? _preparation; + private CancellationTokenSource? _preparationLifetime; + private readonly ConcurrentQueue _pendingEntityViews = new ConcurrentQueue(); + private readonly ConcurrentQueue _pendingFields = new ConcurrentQueue(); - public EveEntitySoaViewDocument? CurrentEntityView { get; private set; } + public event Action? EntityViewAvailable; - public EveInputCapabilityDocument? CurrentInputCapability => _transport?.CurrentInputCapability; - - public event Action? EntityViewAvailable; + public event Action? FieldsSplatsAvailable; public EveUnityCultMeshProviderSelection? Selection { get; private set; } @@ -46,159 +65,541 @@ namespace GameCult.Eve.UnityScene public EveUnitySceneProviderSurfaceDocument CurrentDocument => Bridge.CurrentSurfaceDocument; - public event Action? DocumentAvailable + public EveInputCapabilityDocument CurrentInputCapability { - add { Bridge.DocumentAvailable += value; } - remove { Bridge.DocumentAvailable -= value; } + get + { + RequirePrepared(); + return _transport!.CurrentInputCapability; + } } - public event Action? ReceiptAvailable + public CultMeshBodyTransportKind? CurrentAssetBodyTransportKind { - add { Bridge.ReceiptAvailable += value; } - remove { Bridge.ReceiptAvailable -= value; } + get + { + RequirePrepared(); + return _transport!.CurrentAssetBodyTransportKind; + } } + public event Action? DocumentAvailable; + + public event Action? ReceiptAvailable; + public void Configure( string endpoint, - string replicaStorePath = "", + string localCacheDirectory = "", string providerId = "", string surfaceId = "", string verseId = "", string requiredSurfaceKind = "interactive-world", - string clientRuntimeId = "eve-unity") + string clientRuntimeId = "eve-unity", + CultMeshAuthorityTrustPolicy? authorityTrust = null, + CultMeshAuthorityTrustPolicy? navigationAuthorityTrust = null) { - if (_bridge != null) + if (_bridge != null || _preparation != null) throw new InvalidOperationException("Disconnect the active provider before changing discovery configuration."); rendezvousEndpoint = endpoint ?? ""; - replicaPath = replicaStorePath ?? ""; + cacheDirectory = localCacheDirectory ?? ""; providerFilter = providerId ?? ""; surfaceFilter = surfaceId ?? ""; verseFilter = verseId ?? ""; surfaceKind = string.IsNullOrWhiteSpace(requiredSurfaceKind) ? "interactive-world" : requiredSurfaceKind; runtimeId = string.IsNullOrWhiteSpace(clientRuntimeId) ? "eve-unity" : clientRuntimeId; + _authorityTrust = authorityTrust ?? new CultMeshAuthorityTrustPolicy( + CultMeshAuthorityTrustMode.AuthenticatedRemote); + _navigationAuthorityTrust = navigationAuthorityTrust ?? _authorityTrust; } public void Connect() { + // During navigation the candidate presentation connects before the route + // becomes canonical, so every new lease must connect its selected bridge. Bridge.Connect(); + checked { _connectionLeaseCount++; } + } + + public Task PrepareAsync() + { + if (_bridge != null) + return Task.CompletedTask; + if (_preparation == null || _preparation.IsCanceled || _preparation.IsFaulted) + { + _preparationLifetime?.Dispose(); + _preparationLifetime = new CancellationTokenSource(); + _preparation = PrepareTransportAsync(_preparationLifetime.Token); + } + return _preparation; } public void Disconnect() { - _bridge?.Disconnect(); + if (_connectionLeaseCount <= 0) + return; + _connectionLeaseCount--; + if (_connectionLeaseCount == 0) + _bridge?.Disconnect(); + } + + public async Task NavigateAsync(EveUnitySceneNavigationTarget target) + { + if (target == null) throw new ArgumentNullException(nameof(target)); + if (string.IsNullOrWhiteSpace(target.VerseId)) + throw new ArgumentException("Eve provider navigation requires a stable Verse identity.", nameof(target)); + if (string.IsNullOrWhiteSpace(target.SurfaceId)) + throw new ArgumentException("Eve provider navigation requires a surface identity.", nameof(target)); + + var endpoints = (target.RendezvousEndpoints ?? Array.Empty()) + .Where(endpoint => !string.IsNullOrWhiteSpace(endpoint)) + .Select(endpoint => endpoint.Trim()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (endpoints.Length == 0 && !string.IsNullOrWhiteSpace(rendezvousEndpoint)) + endpoints = new[] { rendezvousEndpoint }; + if (endpoints.Length == 0) + throw new InvalidOperationException("Eve provider navigation requires at least one Odin rendezvous endpoint."); + + var requiredSurfaceKind = string.IsNullOrWhiteSpace(target.SurfaceKind) + ? "interactive-world" + : target.SurfaceKind; + var prepared = await PrepareCandidateAsync( + endpoints, + target.ProviderId, + target.SurfaceId, + requiredSurfaceKind, + target.VerseId, + target.AuthorityRuntimeId, + _navigationAuthorityTrust, + _navigationAuthorityTrust, + CancellationToken.None).ConfigureAwait(false); + + StagePrepared(prepared); + rendezvousEndpoint = prepared.Selection.RendezvousEndpoint; + verseFilter = target.VerseId; + providerFilter = target.ProviderId; + surfaceFilter = target.SurfaceId; + surfaceKind = requiredSurfaceKind; + _authorityTrust = _navigationAuthorityTrust; + _preparation = Task.CompletedTask; + } + + public void CommitNavigation() + { + var previous = _previousProvider; + if (previous == null) return; + if (_navigationRouteCommitted) return; + if (previous.Bridge != null) + previous.Bridge.ReceiptAvailable -= ForwardReceipt; + AttachPrepared(_transport, _bridge); + _navigationRouteCommitted = true; + } + + public void FinalizeNavigation() + { + var previous = _previousProvider; + if (previous == null) return; + _previousProvider = null; + _navigationRouteCommitted = false; + SafeDispose(previous.Bridge); + SafeDispose(previous.Transport); + } + + public void RollbackNavigation() + { + var previous = _previousProvider; + if (previous == null) return; + if (previous.Bridge != null) + previous.Bridge.ReceiptAvailable -= ForwardReceipt; + DetachPrepared(_transport, _bridge); + var rejectedBridge = _bridge; + var rejectedTransport = _transport; + while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose(); + while (_pendingFields.TryDequeue(out _)) { } + + Selection = previous.Selection; + _transport = previous.Transport; + _bridge = previous.Bridge; + rendezvousEndpoint = previous.RendezvousEndpoint; + verseFilter = previous.VerseFilter; + providerFilter = previous.ProviderFilter; + surfaceFilter = previous.SurfaceFilter; + surfaceKind = previous.SurfaceKind; + _authorityTrust = previous.AuthorityTrust; + AttachPrepared(_transport, _bridge); + _previousProvider = null; + _navigationRouteCommitted = false; + _preparation = Task.CompletedTask; + SafeDispose(rejectedBridge); + SafeDispose(rejectedTransport); } public void Refresh() { - if (Bridge.IsConnected) - Bridge.Refresh(); + var activeBridge = _navigationRouteCommitted + ? Bridge + : _previousProvider?.Bridge ?? Bridge; + if (activeBridge.IsConnected) + activeBridge.Refresh(); else - Bridge.Connect(); - _transport?.PumpLiveEvents(); - DrainEntityViews(); + activeBridge.Connect(); } public void Submit(EveSurfaceCommandRequest request) { - Bridge.Submit(request); + // Navigation preparation is not a command-routing commit. The mounted + // presentation continues to target its old provider until the visual + // candidate has been lowered and accepted by the bootstrap. + (_navigationRouteCommitted ? Bridge : _previousProvider?.Bridge ?? Bridge).Submit(request); } public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) { - EnsureTransport(); + RequirePrepared(); return _transport!.ResolvePrefab(asset); } public UnityEngine.Object? ResolveAsset(EveUnityPlayableWorldAssetBinding asset, Type assetType) { - EnsureTransport(); + RequirePrepared(); return _transport!.ResolveAsset(asset, assetType); } - public bool TryGetCameraCullingMask(string viewId, out int cullingMask) + public bool TryResolveAssetMetadata( + EveUnityPlayableWorldAssetBinding asset, + out IReadOnlyDictionary metadata) { - EnsureTransport(); - return _transport!.TryGetCameraCullingMask(viewId, out cullingMask); + RequirePrepared(); + return _transport!.TryResolveAssetMetadata(asset, out metadata); + } + + public bool TryGetRenderChannelLayer(string channel, out int layer) + { + RequirePrepared(); + return _transport!.TryGetRenderChannelLayer(channel, out layer); } private void OnDestroy() { - var transportOwnedConnection = _transport != null; + ReleaseTransport(); + } + + private void ReleaseTransport() + { + _preparationLifetime?.Cancel(); + if (_bridge != null) + { + _bridge.DocumentAvailable -= ForwardDocument; + _bridge.ReceiptAvailable -= ForwardReceipt; + } _bridge?.Dispose(); _transport?.Dispose(); + _previousProvider?.Bridge?.Dispose(); + _previousProvider?.Transport?.Dispose(); + _previousProvider = null; + _navigationRouteCommitted = false; + _connectionLeaseCount = 0; + while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose(); + while (_pendingFields.TryDequeue(out _)) { } _bridge = null; _transport = null; + _preparation = null; + _preparationLifetime?.Dispose(); + _preparationLifetime = null; Selection = null; - if (!transportOwnedConnection && _discovery?.Status == TaskStatus.RanToCompletion) - _discovery.Result.Mesh.Dispose(); - _discovery = null; } private void Update() { - CompleteDiscoveryOnMainThread(); + _previousProvider?.Transport?.PumpLiveEvents(); _transport?.PumpLiveEvents(); - DrainEntityViews(); - } - - private void DrainEntityViews() - { - EveEntitySoaViewDocument? latest = null; - while (_pendingEntityViews.TryDequeue(out var next)) latest = next; + EveFieldsSplatsDocument? latestFields = null; + while (_pendingFields.TryDequeue(out var fields)) + latestFields = fields; + if (latestFields != null) + FieldsSplatsAvailable?.Invoke(latestFields); + EntityViewLease? latest = null; + while (_pendingEntityViews.TryDequeue(out var next)) + { + latest?.Lease.Dispose(); + latest = next; + } if (latest == null) return; - CurrentEntityView = latest; - EntityViewAvailable?.Invoke(latest); + var handler = EntityViewAvailable; + if (handler == null) latest.Lease.Dispose(); + else handler(latest.Document, latest.Lease); } private EveUnitySceneLiveProviderBridge Bridge { get { - EnsureTransport(); + RequirePrepared(); return _bridge!; } } - private void EnsureTransport() + private async Task PrepareTransportAsync(CancellationToken cancellationToken) { + var elapsed = Stopwatch.StartNew(); if (_transport != null) return; if (string.IsNullOrWhiteSpace(rendezvousEndpoint)) throw new InvalidOperationException("EveUnity requires a CultMesh rendezvous endpoint."); - if (_discovery == null) - _discovery = new EveUnityCultMeshProviderDiscovery().DiscoverAsync( - rendezvousEndpoint, - providerFilter, - surfaceFilter, - surfaceKind, - verseFilter); - CompleteDiscoveryOnMainThread(); - if (_transport == null) - throw new InvalidOperationException("EveUnity CultMesh provider discovery is still in progress."); + var prepared = await PrepareCandidateAsync( + new[] { rendezvousEndpoint }, + providerFilter, + surfaceFilter, + surfaceKind, + verseFilter, + "", + _authorityTrust, + _navigationAuthorityTrust, + cancellationToken).ConfigureAwait(false); + TraceStartup($"discovery and transport preparation {elapsed.Elapsed.TotalMilliseconds:0.###}ms"); + AdoptPrepared(prepared); } - private void CompleteDiscoveryOnMainThread() + private async Task PrepareCandidateAsync( + IReadOnlyList endpoints, + string requiredProviderId, + string requiredSurfaceId, + string requiredSurfaceKind, + string requiredVerseId, + string requiredAuthorityRuntimeId, + CultMeshAuthorityTrustPolicy trust, + CultMeshAuthorityTrustPolicy crossTargetTrust, + CancellationToken cancellationToken) { - if (_transport != null || _discovery == null || !_discovery.IsCompleted) - return; - if (_discovery.IsFaulted) - throw _discovery.Exception?.GetBaseException() - ?? new InvalidOperationException("EveUnity CultMesh provider discovery failed."); - if (_discovery.IsCanceled) - throw new InvalidOperationException("EveUnity CultMesh provider discovery was cancelled."); + var failures = new List(); + foreach (var endpoint in endpoints) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var selection = await new EveUnityCultMeshProviderDiscovery(trust).DiscoverAsync( + endpoint, + requiredProviderId, + requiredSurfaceId, + requiredSurfaceKind, + requiredVerseId, + cancellationToken, + requiredAuthorityRuntimeId).ConfigureAwait(false); + var resolvedCachePath = string.IsNullOrWhiteSpace(cacheDirectory) + ? Path.Combine(Application.temporaryCachePath, $"eve-unity-{GetInstanceID()}") + : cacheDirectory; + var transport = new EveUnityCultMeshLiveProviderTransport( + resolvedCachePath, + selection.RendezvousEndpoint, + selection.VerseId, + selection.AuthorityRuntimeId, + selection.ProviderId, + selection.SurfaceId, + runtimeId, + authorityTrust: trust, + crossTargetAuthorityTrust: crossTargetTrust); + try + { + await transport.PrepareAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return new PreparedProvider(selection, transport, new EveUnitySceneLiveProviderBridge(transport)); + } + catch + { + transport.Dispose(); + throw; + } + } + catch (Exception error) when (error is not OperationCanceledException) + { + failures.Add($"{endpoint}: {error.Message}"); + } + } - Selection = _discovery.Result; - var resolvedReplicaPath = string.IsNullOrWhiteSpace(replicaPath) - ? Path.Combine(Application.temporaryCachePath, $"eve-unity-{GetInstanceID()}.cc") - : replicaPath; - _transport = new EveUnityCultMeshLiveProviderTransport( - resolvedReplicaPath, + throw new InvalidOperationException( + $"No configured Odin endpoint prepared Verse '{requiredVerseId}' surface '{requiredSurfaceId}'. " + + string.Join(" | ", failures)); + } + + private void AdoptPrepared(PreparedProvider prepared) + { + if (_previousProvider != null) + throw new InvalidOperationException("A staged Eve provider navigation must be committed or rolled back first."); + var oldBridge = _bridge; + var oldTransport = _transport; + DetachPrepared(oldTransport, oldBridge); + + Selection = prepared.Selection; + _transport = prepared.Transport; + _bridge = prepared.Bridge; + AttachPrepared(_transport, _bridge); + + while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose(); + while (_pendingFields.TryDequeue(out _)) { } + oldBridge?.Dispose(); + oldTransport?.Dispose(); + } + + private void StagePrepared(PreparedProvider prepared) + { + if (_previousProvider != null) + { + prepared.Bridge.Dispose(); + prepared.Transport.Dispose(); + throw new InvalidOperationException("A staged Eve provider navigation must be committed or rolled back first."); + } + + _previousProvider = new PreviousProvider( Selection, - runtimeId); - _transport.EntityViewAvailable += view => _pendingEntityViews.Enqueue(view); - _bridge = new EveUnitySceneLiveProviderBridge(_transport); + _transport, + _bridge, + rendezvousEndpoint, + verseFilter, + providerFilter, + surfaceFilter, + surfaceKind, + _authorityTrust); + _navigationRouteCommitted = false; + DetachPrepared(_transport, _bridge); + if (_previousProvider.Bridge != null) + _previousProvider.Bridge.ReceiptAvailable += ForwardReceipt; + Selection = prepared.Selection; + _transport = prepared.Transport; + _bridge = prepared.Bridge; + while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose(); + while (_pendingFields.TryDequeue(out _)) { } + } + + private void AttachPrepared( + EveUnityCultMeshLiveProviderTransport? transport, + EveUnitySceneLiveProviderBridge? bridge) + { + if (transport != null) + { + transport.EntityViewAvailable += QueueEntityView; + transport.FieldsSplatsAvailable += QueueFields; + } + if (bridge != null) + { + bridge.DocumentAvailable += ForwardDocument; + bridge.ReceiptAvailable += ForwardReceipt; + } + } + + private void DetachPrepared( + EveUnityCultMeshLiveProviderTransport? transport, + EveUnitySceneLiveProviderBridge? bridge) + { + if (transport != null) + { + transport.EntityViewAvailable -= QueueEntityView; + transport.FieldsSplatsAvailable -= QueueFields; + } + if (bridge == null) return; + bridge.DocumentAvailable -= ForwardDocument; + bridge.ReceiptAvailable -= ForwardReceipt; + } + + private void QueueEntityView(EveEntitySoaViewDocument view, ICultMeshBodyReadLease lease) => + _pendingEntityViews.Enqueue(new EntityViewLease(view, lease)); + + private void QueueFields(EveFieldsSplatsDocument fields) => _pendingFields.Enqueue(fields); + + private void ForwardDocument(EveUnitySceneProviderSurfaceDocument document) => + DocumentAvailable?.Invoke(document); + + private void ForwardReceipt(EveUnitySceneCommandReceipt receipt) => + ReceiptAvailable?.Invoke(receipt); + + private static void SafeDispose(IDisposable? disposable) + { + try + { + disposable?.Dispose(); + } + catch (Exception error) + { + Debug.LogWarning($"Eve provider cleanup failed after navigation ownership changed: {error}"); + } + } + + private static void TraceStartup(string message) + { + if (string.Equals(Environment.GetEnvironmentVariable("EVEUNITY_TRACE_STARTUP_PHASES"), "1", StringComparison.Ordinal)) + Debug.Log($"EveUnity startup phase {message}."); + } + + private void RequirePrepared() + { + if (_transport == null || _bridge == null) + throw new InvalidOperationException( + "The EveUnity CultMesh provider is not prepared. Await PrepareAsync before mounting the playable world."); + } + + private sealed class EntityViewLease + { + public EntityViewLease(EveEntitySoaViewDocument document, ICultMeshBodyReadLease lease) + { + Document = document; + Lease = lease; + } + + public EveEntitySoaViewDocument Document { get; } + public ICultMeshBodyReadLease Lease { get; } + } + + private sealed class PreparedProvider + { + public PreparedProvider( + EveUnityCultMeshProviderSelection selection, + EveUnityCultMeshLiveProviderTransport transport, + EveUnitySceneLiveProviderBridge bridge) + { + Selection = selection; + Transport = transport; + Bridge = bridge; + } + + public EveUnityCultMeshProviderSelection Selection { get; } + public EveUnityCultMeshLiveProviderTransport Transport { get; } + public EveUnitySceneLiveProviderBridge Bridge { get; } + } + + private sealed class PreviousProvider + { + public PreviousProvider( + EveUnityCultMeshProviderSelection? selection, + EveUnityCultMeshLiveProviderTransport? transport, + EveUnitySceneLiveProviderBridge? bridge, + string rendezvousEndpoint, + string verseFilter, + string providerFilter, + string surfaceFilter, + string surfaceKind, + CultMeshAuthorityTrustPolicy authorityTrust) + { + Selection = selection; + Transport = transport; + Bridge = bridge; + RendezvousEndpoint = rendezvousEndpoint; + VerseFilter = verseFilter; + ProviderFilter = providerFilter; + SurfaceFilter = surfaceFilter; + SurfaceKind = surfaceKind; + AuthorityTrust = authorityTrust; + } + + public EveUnityCultMeshProviderSelection? Selection { get; } + public EveUnityCultMeshLiveProviderTransport? Transport { get; } + public EveUnitySceneLiveProviderBridge? Bridge { get; } + public string RendezvousEndpoint { get; } + public string VerseFilter { get; } + public string ProviderFilter { get; } + public string SurfaceFilter { get; } + public string SurfaceKind { get; } + public CultMeshAuthorityTrustPolicy AuthorityTrust { get; } } } } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs index fe34dba..7772dbc 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs @@ -1,9 +1,11 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using GameCult.Eve.Surface; using GameCult.Mesh; -using UnityEngine; +using GameCult.Networking; #nullable enable @@ -11,65 +13,155 @@ namespace GameCult.Eve.UnityScene { public sealed class EveUnityCultMeshProviderSelection { - internal EveUnityCultMeshProviderSelection( - string endpointId, - CultMeshClient mesh, - EveSurfaceSelection surface) + public EveUnityCultMeshProviderSelection( + string rendezvousEndpoint, + string verseId, + string authorityRuntimeId, + string providerId, + string surfaceId, + string surfaceKind) { - EndpointId = endpointId; - Mesh = mesh; - Surface = surface; + RendezvousEndpoint = rendezvousEndpoint ?? ""; + VerseId = verseId ?? ""; + AuthorityRuntimeId = authorityRuntimeId ?? ""; + ProviderId = providerId ?? ""; + SurfaceId = surfaceId ?? ""; + SurfaceKind = surfaceKind ?? ""; } - public string EndpointId { get; } - public string VerseId => Surface.Provider.VerseId; - public string ProviderId => Surface.Provider.ProviderId; - public string SurfaceId => Surface.Advertisement.SurfaceId; - public string SurfaceKind => Surface.Advertisement.SurfaceKind; - public CultMeshClient Mesh { get; } - public EveSurfaceSelection Surface { get; } + public string RendezvousEndpoint { get; } + public string VerseId { get; } + public string AuthorityRuntimeId { get; } + public string ProviderId { get; } + public string SurfaceId { get; } + public string SurfaceKind { get; } } public sealed class EveUnityCultMeshProviderDiscovery { + private readonly CultMeshAuthorityTrustPolicy _authorityTrust; + + public EveUnityCultMeshProviderDiscovery(CultMeshAuthorityTrustPolicy? authorityTrust = null) + { + _authorityTrust = authorityTrust ?? new CultMeshAuthorityTrustPolicy( + CultMeshAuthorityTrustMode.AuthenticatedRemote); + } + public async Task DiscoverAsync( string rendezvousEndpoint, string providerId = "", string surfaceId = "", string surfaceKind = "interactive-world", string verseId = "", - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string requiredAuthorityRuntimeId = "") { if (string.IsNullOrWhiteSpace(rendezvousEndpoint)) throw new ArgumentException("Rendezvous endpoint must be non-empty.", nameof(rendezvousEndpoint)); - var endpointId = !string.IsNullOrWhiteSpace(verseId) - ? verseId - : !string.IsNullOrWhiteSpace(providerId) ? providerId : "eve.providers"; - var mesh = new CultMeshClient(new CultMeshClientOptions - { - RendezvousEndpoints = new[] { rendezvousEndpoint } - }); + CultMeshVerseCatalogResponseMessage response; try { - var surface = await mesh.SurfaceAsync( - endpointId, - new EveSurfaceRequest + response = await CultMesh.CreateVerseDiscoveryClient(EveUnityCultMeshConnectivity.Discovery()).FetchAsync( + rendezvousEndpoint, + new CultMeshVerseCatalogRequestMessage { - ProviderId = providerId, - SurfaceId = surfaceId, - SurfaceKind = surfaceKind, - LoweringTarget = "unity-scene" - }, - cancellationToken, - stage => Debug.Log($"EveUnity CultMesh discovery: {stage}")).ConfigureAwait(false); - return new EveUnityCultMeshProviderSelection(endpointId, mesh, surface); + VerseIds = string.IsNullOrWhiteSpace(verseId) ? null : new[] { verseId }, + TransportVersion = "cultmesh.v0" + }); + cancellationToken.ThrowIfCancellationRequested(); } - catch + catch (Exception error) { - mesh.Dispose(); - throw; + throw new InvalidOperationException( + $"Could not query CultMesh rendezvous endpoint '{rendezvousEndpoint}'.", + error); } + + var candidates = response.Verses + .Where(verse => string.IsNullOrWhiteSpace(verseId) || + string.Equals(verse.VerseId, verseId, StringComparison.Ordinal)) + .Where(verse => !string.IsNullOrWhiteSpace(verse.VerseId) && + (verse.DiscoveryEndpoints ?? Array.Empty()).Any(endpoint => + !string.IsNullOrWhiteSpace(endpoint))) + .GroupBy(verse => verse.VerseId, StringComparer.Ordinal) + .Select(group => group.First()) + .ToArray(); + if (candidates.Length == 0) + throw new InvalidOperationException("The rendezvous endpoint advertised no compatible Verse endpoints."); + + var failures = new List(); + var observed = new List(); + using var mesh = new CultMeshClient(new CultMeshClientOptions + { + RendezvousEndpoints = new[] { rendezvousEndpoint }, + Discovery = EveUnityCultMeshConnectivity.Discovery(), + Sessions = new CultMeshSessionManagerOptions { Trust = _authorityTrust }, + Connectors = EveUnityCultMeshConnectivity.SchemaConnectors(), + ContentConnectors = EveUnityCultMeshConnectivity.ContentConnectors() + }); + foreach (var candidate in candidates) + { + foreach (var authorityRuntimeId in EligibleAuthorityRuntimeIds( + candidate.AuthorityRuntimeIds, + requiredAuthorityRuntimeId)) + { + try + { + var target = new CultMeshSessionTarget(candidate.VerseId, authorityRuntimeId); + using var advertisementsLease = await mesh + .LeaseCollectionAsync(target, cancellationToken) + .ConfigureAwait(false); + var advertisements = await advertisementsLease.Handle.LatestAsync().ConfigureAwait(false); + observed.AddRange(advertisements.Select(document => + $"{target}: {document.ProviderId}[{string.Join(",", document.Surfaces.Select(surface => $"{surface.SurfaceId}:{surface.SurfaceKind}"))}]")); + var advertisement = advertisements + .Where(document => string.IsNullOrWhiteSpace(providerId) || + string.Equals(document.ProviderId, providerId, StringComparison.Ordinal)) + .Select(document => new + { + Document = document, + Surface = document.Surfaces.FirstOrDefault(surface => + (string.IsNullOrWhiteSpace(surfaceId) || + string.Equals(surface.SurfaceId, surfaceId, StringComparison.Ordinal)) && + (string.IsNullOrWhiteSpace(surfaceKind) || + string.Equals(surface.SurfaceKind, surfaceKind, StringComparison.Ordinal))) + }) + .FirstOrDefault(match => match.Surface != null); + if (advertisement?.Surface == null) + continue; + + return new EveUnityCultMeshProviderSelection( + rendezvousEndpoint, + candidate.VerseId, + authorityRuntimeId, + advertisement.Document.ProviderId, + advertisement.Surface.SurfaceId, + advertisement.Surface.SurfaceKind); + } + catch (Exception error) + { + failures.Add($"{candidate.VerseId}/{authorityRuntimeId}: {error.Message}"); + } + } + } + + var filter = $"provider='{providerId}', surface='{surfaceId}', kind='{surfaceKind}', authority='{requiredAuthorityRuntimeId}'"; + var detail = failures.Count == 0 ? "" : $" Endpoint failures: {string.Join(" | ", failures)}"; + var observedDetail = observed.Count == 0 ? "" : $" Observed: {string.Join(" | ", observed)}"; + throw new InvalidOperationException($"No advertised Eve surface matched {filter}.{detail}{observedDetail}"); } + + internal static IReadOnlyList EligibleAuthorityRuntimeIds( + IEnumerable? authorityRuntimeIds, + string requiredAuthorityRuntimeId) => + (authorityRuntimeIds ?? Array.Empty()) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Where(id => string.IsNullOrWhiteSpace(requiredAuthorityRuntimeId) || + string.Equals(id, requiredAuthorityRuntimeId, StringComparison.Ordinal)) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal) + .ToArray(); + } } 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 405df3f..671a17f 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 @@ -4,6 +4,7 @@ "references": [ "GameCult.Eve.Surface", "GameCult.Eve.UnityScene", + "GameCult.Eve.PluginFields", "UnityEngine.AssetBundleModule" ], "includePlatforms": [], @@ -14,7 +15,9 @@ "GameCult.Caching.dll", "GameCult.Caching.MessagePack.dll", "GameCult.Mesh.dll", - "GameCult.Networking.dll" + "GameCult.Mesh.Quic.Native.dll", + "GameCult.Networking.dll", + "GameCult.Networking.WebSockets.dll" ], "autoReferenced": true, "defineConstraints": [], diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdaptiveExposureRenderer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdaptiveExposureRenderer.cs new file mode 100644 index 0000000..281d5e8 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdaptiveExposureRenderer.cs @@ -0,0 +1,226 @@ +using System; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.Rendering.RenderGraphModule; +using UnityEngine.Rendering.Universal; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityAdaptiveExposureRenderer : MonoBehaviour, IEveUnityRenderPassSource + { + private const int HistogramBins = 128; + private ComputeShader? _compute; + private Material? _multiplyMaterial; + private ComputeBuffer? _histogram; + private readonly RenderTexture?[] _exposure = new RenderTexture?[2]; + private int _currentExposure = -1; + private Camera? _camera; + private AdaptiveExposurePass? _pass; + + private float _lowPercent; + private float _highPercent; + private float _minimumEv; + private float _maximumEv; + private float _keyValue; + private float _speedUp; + private float _speedDown; + private bool _progressive; + + public long AppliedFrameCount { get; private set; } + public bool IsConfigured { get; private set; } + public static RenderPassEvent ExposureRenderPassEvent => + (RenderPassEvent)((int)RenderPassEvent.BeforeRenderingPostProcessing + 1); + int IEveUnityRenderPassSource.RenderOrder => 300; + + public void Configure( + float lowPercent, + float highPercent, + float minimumEv, + float maximumEv, + float keyValue, + string adaptation, + float speedUp, + float speedDown) + { + _lowPercent = Mathf.Clamp(lowPercent, 1f, 98.99f); + _highPercent = Mathf.Clamp(highPercent, _lowPercent + 0.01f, 99f); + _minimumEv = Mathf.Min(minimumEv, maximumEv); + _maximumEv = Mathf.Max(minimumEv, maximumEv); + _keyValue = Mathf.Max(0f, keyValue); + _speedUp = Mathf.Max(0f, speedUp); + _speedDown = Mathf.Max(0f, speedDown); + _progressive = string.Equals(adaptation, "progressive", StringComparison.Ordinal); + IsConfigured = true; + enabled = true; + } + + private void OnEnable() + { + _camera = GetComponent(); + EveUnityRenderPassRegistry.Register(this); + } + + private void OnDisable() + { + EveUnityRenderPassRegistry.Unregister(this); + ReleaseResources(); + } + + private void OnDestroy() => ReleaseResources(); + + bool IEveUnityRenderPassSource.TryEnqueuePass(Camera camera, ScriptableRenderer renderer) + { + if (!IsConfigured || _camera == null || camera != _camera) return false; + _pass ??= new AdaptiveExposurePass(this); + renderer.EnqueuePass(_pass); + return true; + } + + private bool EnsureResources() + { + _compute ??= Resources.Load("EveUnity/AdaptiveExposure"); + if (_multiplyMaterial == null) + { + var shader = Shader.Find("Hidden/EveUnity/AdaptiveExposureMultiply"); + if (shader != null) _multiplyMaterial = new Material(shader) { hideFlags = HideFlags.HideAndDontSave }; + } + _histogram ??= new ComputeBuffer(HistogramBins, sizeof(uint), ComputeBufferType.Structured); + for (var index = 0; index < _exposure.Length; index++) + { + if (_exposure[index] != null && _exposure[index]!.IsCreated()) continue; + _exposure[index] = new RenderTexture(1, 1, 0, RenderTextureFormat.RFloat, RenderTextureReadWrite.Linear) + { + name = $"Eve Adaptive Exposure {index}", + enableRandomWrite = true, + filterMode = FilterMode.Point, + wrapMode = TextureWrapMode.Clamp, + hideFlags = HideFlags.HideAndDontSave + }; + _exposure[index]!.Create(); + } + return _compute != null && _multiplyMaterial != null && _histogram != null; + } + + private void Apply(UnsafeCommandBuffer commands, TextureHandle colorTarget, int width, int height) + { + if (!EnsureResources() || _compute == null || _histogram == null || _multiplyMaterial == null) return; + + var clear = _compute.FindKernel("ClearHistogram"); + commands.SetComputeBufferParam(_compute, clear, "_Histogram", _histogram); + commands.DispatchCompute(_compute, clear, Mathf.CeilToInt(HistogramBins / 64f), 1, 1); + + var build = _compute.FindKernel("BuildHistogram"); + commands.SetComputeBufferParam(_compute, build, "_Histogram", _histogram); + commands.SetComputeTextureParam(_compute, build, "_Source", colorTarget); + commands.SetComputeVectorParam(_compute, "_HistogramScaleOffsetResolution", new Vector4( + 1f / 18f, + 0.5f, + Mathf.Max(1, width), + Mathf.Max(1, height))); + commands.DispatchCompute( + _compute, + build, + Mathf.CeilToInt(width / 2f / 16f), + Mathf.CeilToInt(height / 2f / 16f), + 1); + + var destination = _currentExposure < 0 ? 0 : (_currentExposure + 1) % 2; + var calculate = _compute.FindKernel( + _currentExposure < 0 || !_progressive ? "CalculateFixedExposure" : "CalculateProgressiveExposure"); + commands.SetComputeBufferParam(_compute, calculate, "_Histogram", _histogram); + commands.SetComputeVectorParam(_compute, "_ExposureFilterAndBrightness", new Vector4( + _lowPercent * 0.01f, + _highPercent * 0.01f, + Mathf.Pow(2f, _minimumEv), + Mathf.Pow(2f, _maximumEv))); + commands.SetComputeVectorParam(_compute, "_ExposureAdaptation", new Vector4( + _speedDown, + _speedUp, + _keyValue, + Mathf.Max(0f, Time.deltaTime))); + if (_currentExposure >= 0) + commands.SetComputeTextureParam(_compute, calculate, "_PreviousExposure", _exposure[_currentExposure]!); + commands.SetComputeTextureParam(_compute, calculate, "_DestinationExposure", _exposure[destination]!); + commands.DispatchCompute(_compute, calculate, 1, 1, 1); + + _currentExposure = destination; + _multiplyMaterial.SetTexture("_ExposureTexture", _exposure[_currentExposure]); + commands.SetRenderTarget(colorTarget); + commands.DrawProcedural(Matrix4x4.identity, _multiplyMaterial, 0, MeshTopology.Triangles, 3, 1); + AppliedFrameCount++; + } + + internal static float ExposureForAverageLuminance(float averageLuminance, float keyValue) => + Mathf.Max(0f, keyValue) / Mathf.Max(1e-6f, averageLuminance); + + internal static float AdaptExposure(float previous, float next, float deltaTime, float speedUp, float speedDown) + { + var speed = next > previous ? Mathf.Max(0f, speedDown) : Mathf.Max(0f, speedUp); + return previous + (next - previous) * (1f - Mathf.Pow(2f, -Mathf.Max(0f, deltaTime) * speed)); + } + + private void ReleaseResources() + { + _histogram?.Release(); + _histogram = null; + foreach (var texture in _exposure) + { + if (texture == null) continue; + texture.Release(); + if (Application.isPlaying) Destroy(texture); else DestroyImmediate(texture); + } + _exposure[0] = null; + _exposure[1] = null; + _currentExposure = -1; + if (_multiplyMaterial != null) + { + if (Application.isPlaying) Destroy(_multiplyMaterial); else DestroyImmediate(_multiplyMaterial); + _multiplyMaterial = null; + } + } + + private sealed class AdaptiveExposurePass : ScriptableRenderPass + { + private readonly EveUnityAdaptiveExposureRenderer _owner; + + private sealed class PassData + { + internal EveUnityAdaptiveExposureRenderer Owner = null!; + internal TextureHandle ColorTarget; + internal int Width; + internal int Height; + } + + internal AdaptiveExposurePass(EveUnityAdaptiveExposureRenderer owner) + { + _owner = owner; + renderPassEvent = ExposureRenderPassEvent; + // Histogram construction samples the active camera color. URP must therefore + // provide an intermediate color texture instead of exposing the back buffer. + requiresIntermediateTexture = true; + } + + public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData) + { + var cameraData = frameData.Get(); + if (cameraData.camera == null || cameraData.camera != _owner._camera) return; + var resourceData = frameData.Get(); + if (resourceData.isActiveTargetBackBuffer) return; + var colorTarget = resourceData.activeColorTexture; + if (!colorTarget.IsValid()) return; + + using var builder = renderGraph.AddUnsafePass("Eve Adaptive Exposure", out var passData); + passData.Owner = _owner; + passData.ColorTarget = colorTarget; + passData.Width = cameraData.camera.pixelWidth; + passData.Height = cameraData.camera.pixelHeight; + builder.UseTexture(passData.ColorTarget, AccessFlags.ReadWrite); + builder.AllowPassCulling(false); + builder.SetRenderFunc(static (PassData data, UnsafeGraphContext context) => + data.Owner.Apply(context.cmd, data.ColorTarget, data.Width, data.Height)); + } + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdaptiveExposureRenderer.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdaptiveExposureRenderer.cs.meta new file mode 100644 index 0000000..fc25c4a --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdaptiveExposureRenderer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3cb42e7da8b34febb348f01a598f2eea diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdvertisedInputAction.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdvertisedInputAction.cs new file mode 100644 index 0000000..8012fcc --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdvertisedInputAction.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using GameCult.Eve.Surface; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityAdvertisedInputAction + { + public const string ButtonHoldValueModel = "button-hold.v1"; + public const string AxisValueModel = "axis.v1"; + public const string ScalarValueModel = "scalar.v1"; + public const string ViewDirectionValueModel = "view-direction.v1"; + + private readonly EveInputActionDocument _action; + + private EveUnityAdvertisedInputAction(EveInputActionDocument action) + { + _action = action; + } + + public string ActionId => _action.ActionId ?? ""; + public string Operation => _action.Operation ?? ""; + public string Availability => _action.Availability ?? ""; + public string InputValueModel => _action.InputValue?.Model ?? ""; + public string InputValuePayloadKey => _action.InputValue?.PayloadKey ?? ""; + public IReadOnlyList InputValuePayloadKeys => _action.InputValue?.PayloadKeys ?? Array.Empty(); + public double? CurrentValue => _action.InputValue?.CurrentValue; + public double? MinimumValue => _action.InputValue?.MinimumValue; + public double? MaximumValue => _action.InputValue?.MaximumValue; + public double? StepValue => _action.InputValue?.StepValue; + public string Unit => _action.InputValue?.Unit ?? ""; + public bool IsButtonHold => string.Equals(InputValueModel, ButtonHoldValueModel, StringComparison.Ordinal); + public bool IsScalar => string.Equals(InputValueModel, ScalarValueModel, StringComparison.Ordinal); + public bool IsViewDirection => string.Equals(InputValueModel, ViewDirectionValueModel, StringComparison.Ordinal); + + public static EveUnityAdvertisedInputAction Resolve( + EveInputCapabilityDocument? capability, + string actionId, + bool requireAvailable = true) + { + if (capability == null) + throw new InvalidOperationException("The provider did not advertise an input capability."); + var action = (capability.Actions ?? Array.Empty()).FirstOrDefault(candidate => + string.Equals(candidate?.ActionId, actionId, StringComparison.Ordinal)); + if (action == null || string.IsNullOrWhiteSpace(action.Operation)) + throw new InvalidOperationException($"Input action '{actionId}' is not advertised with an operation."); + if (requireAvailable && !string.Equals(action.Availability, "available", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Input action '{actionId}' is not available."); + return new EveUnityAdvertisedInputAction(action); + } + + public IReadOnlyDictionary BuildPayload(string entityId, float? inputValue = null) + { + var payload = _action.Payload == null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(_action.Payload, StringComparer.Ordinal); + payload["entityId"] = entityId ?? ""; + payload["actionId"] = ActionId; + + if (inputValue.HasValue) + { + if (float.IsNaN(inputValue.Value) || float.IsInfinity(inputValue.Value)) + throw new ArgumentOutOfRangeException(nameof(inputValue), "Input action value must be finite."); + if (string.IsNullOrWhiteSpace(InputValueModel) || string.IsNullOrWhiteSpace(InputValuePayloadKey)) + throw new InvalidOperationException($"Input action '{ActionId}' does not advertise a value payload."); + payload[InputValuePayloadKey] = inputValue.Value.ToString("R", CultureInfo.InvariantCulture); + } + + return payload; + } + + public IReadOnlyDictionary BuildViewDirectionPayload( + string entityId, + float directionX, + float directionY, + float directionZ) + { + if (!IsViewDirection) + throw new InvalidOperationException($"Input action '{ActionId}' does not advertise a view-direction value."); + if (InputValuePayloadKeys.Count != 3 || InputValuePayloadKeys.Any(string.IsNullOrWhiteSpace) || + InputValuePayloadKeys.Distinct(StringComparer.Ordinal).Count() != 3) + throw new InvalidOperationException($"Input action '{ActionId}' does not advertise three distinct view-direction payload keys."); + if (!IsFinite(directionX) || !IsFinite(directionY) || !IsFinite(directionZ)) + throw new ArgumentOutOfRangeException(nameof(directionX), "View direction must be finite."); + var length = Math.Sqrt( + (directionX * directionX) + + (directionY * directionY) + + (directionZ * directionZ)); + if (length <= 0.000001) + throw new ArgumentOutOfRangeException(nameof(directionX), "View direction must be non-zero."); + + var payload = BuildPayload(entityId).ToDictionary(value => value.Key, value => value.Value, StringComparer.Ordinal); + payload[InputValuePayloadKeys[0]] = ((float)(directionX / length)).ToString("R", CultureInfo.InvariantCulture); + payload[InputValuePayloadKeys[1]] = ((float)(directionY / length)).ToString("R", CultureInfo.InvariantCulture); + payload[InputValuePayloadKeys[2]] = ((float)(directionZ / length)).ToString("R", CultureInfo.InvariantCulture); + return payload; + } + + public IReadOnlyDictionary BuildScalarPayload(string entityId, double value) + { + if (!IsScalar) + throw new InvalidOperationException($"Input action '{ActionId}' does not advertise a scalar value."); + if (double.IsNaN(value) || double.IsInfinity(value)) + throw new ArgumentOutOfRangeException(nameof(value), "Scalar input value must be finite."); + if (!CurrentValue.HasValue || double.IsNaN(CurrentValue.Value) || double.IsInfinity(CurrentValue.Value)) + throw new InvalidOperationException($"Input action '{ActionId}' does not advertise a finite current scalar value."); + if (MinimumValue.HasValue && value < MinimumValue.Value) + throw new ArgumentOutOfRangeException(nameof(value), $"Scalar input value is below the advertised minimum {MinimumValue.Value}."); + if (MaximumValue.HasValue && value > MaximumValue.Value) + throw new ArgumentOutOfRangeException(nameof(value), $"Scalar input value is above the advertised maximum {MaximumValue.Value}."); + if (MinimumValue.HasValue && MaximumValue.HasValue && MinimumValue.Value > MaximumValue.Value) + throw new InvalidOperationException($"Input action '{ActionId}' advertises an inverted scalar range."); + if (StepValue.HasValue && (!IsFinite(StepValue.Value) || StepValue.Value <= 0)) + throw new InvalidOperationException($"Input action '{ActionId}' advertises an invalid scalar step."); + if (string.IsNullOrWhiteSpace(InputValuePayloadKey)) + throw new InvalidOperationException($"Input action '{ActionId}' does not advertise a scalar payload key."); + + var payload = BuildPayload(entityId).ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + payload[InputValuePayloadKey] = value.ToString("R", CultureInfo.InvariantCulture); + return payload; + } + + private static bool IsFinite(float value) => !float.IsNaN(value) && !float.IsInfinity(value); + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdvertisedInputAction.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdvertisedInputAction.cs.meta new file mode 100644 index 0000000..a2236f2 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdvertisedInputAction.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c7555b03e7f9451aa891e92958b845c5 diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAimPresentation.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAimPresentation.cs new file mode 100644 index 0000000..4b9bdf4 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAimPresentation.cs @@ -0,0 +1,78 @@ +using System; +using System.Globalization; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityAimPresentation + { + public EveUnityAimPresentation(EveUnitySceneNode node) + { + if (node == null) throw new ArgumentNullException(nameof(node)); + ControlledEntityId = Get(node, "controlledEntityId"); + ConvergenceTargetEntityId = Get(node, "convergenceTargetEntityId"); + ViewDotRole = Get(node, "viewDotRole"); + MinimumConvergenceDistance = Math.Max(0.01f, Number(node, "minimumConvergenceDistance", 50f)); + ViewDotRadius = Math.Max(0.01f, Number(node, "viewDotRadius", 0.8f)); + } + + public string ControlledEntityId { get; } + public string ConvergenceTargetEntityId { get; } + public string ViewDotRole { get; } + public float MinimumConvergenceDistance { get; } + public float ViewDotRadius { get; } + + public bool TryResolveViewPoint(EveUnityPlayableWorldClientHost? host, out Vector3 viewPoint) + { + viewPoint = default; + if (host == null || string.IsNullOrWhiteSpace(ControlledEntityId)) return false; + var controlled = FindMarker(host, ControlledEntityId); + if (controlled == null) return false; + var distance = MinimumConvergenceDistance; + var target = FindMarker(host, ConvergenceTargetEntityId); + if (target != null) + distance = Mathf.Max(distance, Vector3.Distance(controlled.transform.position, target.transform.position)); + var forward = controlled.transform.forward; + forward.y = 0f; + if (forward.sqrMagnitude <= 0.0001f) forward = Vector3.forward; + forward.Normalize(); + viewPoint = controlled.transform.position + forward * distance; + return true; + } + + public static EveUnityAimPresentation? Find(EveUnitySceneProjection? projection) => + projection == null ? null : Find(projection.Root); + + private static EveUnityAimPresentation? Find(EveUnitySceneNode node) + { + if (string.Equals(node.ComponentKind, "aim.presentation", StringComparison.Ordinal)) + return new EveUnityAimPresentation(node); + foreach (var child in node.Children) + { + var found = Find(child); + if (found != null) return found; + } + return null; + } + + private static EveUnityPlayableWorldEntityMarker? FindMarker( + EveUnityPlayableWorldClientHost host, + string entityId) + { + if (string.IsNullOrWhiteSpace(entityId)) return null; + foreach (var marker in host.SceneRoot.GetComponentsInChildren(true)) + if (string.Equals(marker.EntityId, entityId, StringComparison.Ordinal)) return marker; + return null; + } + + private static string Get(EveUnitySceneNode node, string key) => + node.Props.TryGetValue(key, out var value) ? value ?? "" : ""; + + private static float Number(EveUnitySceneNode node, string key, float fallback) => + float.TryParse(Get(node, key), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) + ? value + : fallback; + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAimPresentation.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAimPresentation.cs.meta new file mode 100644 index 0000000..8a50d5a --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAimPresentation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f859926d39664cefa5aa96ed3c2d8903 diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAimPresentationRenderer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAimPresentationRenderer.cs new file mode 100644 index 0000000..321ca96 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAimPresentationRenderer.cs @@ -0,0 +1,123 @@ +using System; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityAimPresentationRenderer : MonoBehaviour + { + [SerializeField] private Color viewDotColor = new Color(0.35f, 0.95f, 1f, 0.95f); + [SerializeField] private float width = 0.1f; + + private EveUnityPlayableWorldClientHost? _host; + private LineRenderer? _ring; + private LineRenderer? _horizontal; + private LineRenderer? _vertical; + private Material? _material; + + public EveUnityAimPresentation? Current { get; private set; } + public bool ViewDotVisible => _ring != null && _ring.enabled; + public Vector3 ViewDotPosition { get; private set; } + + public void Bind(EveUnityPlayableWorldClientHost host) + { + _host = host != null ? host : throw new ArgumentNullException(nameof(host)); + } + + public void RefreshNow() + { + Current = EveUnityAimPresentation.Find(_host?.ActiveProjection); + if (Current == null || string.IsNullOrWhiteSpace(Current.ControlledEntityId)) + { + SetVisible(false); + return; + } + + if (!Current.TryResolveViewPoint(_host, out var viewPoint)) + { + SetVisible(false); + return; + } + ViewDotPosition = viewPoint; + + EnsureVisuals(); + var camera = _host?.ActiveCameraTransform; + if (camera == null) + { + SetVisible(false); + return; + } + var right = camera.right; + var up = camera.up; + var center = ViewDotPosition - camera.forward * 0.1f; + Ring(_ring!, center, right, up, Current.ViewDotRadius); + Segment(_horizontal!, center, right, Current.ViewDotRadius * 1.6f); + Segment(_vertical!, center, up, Current.ViewDotRadius * 1.6f); + SetVisible(true); + } + + private void LateUpdate() => RefreshNow(); + + private void EnsureVisuals() + { + if (_ring != null) return; + var root = new GameObject("Eve aim presentation"); + root.transform.SetParent(transform, false); + _ring = Line(root.transform, "View direction ring"); + _horizontal = Line(root.transform, "View direction horizontal"); + _vertical = Line(root.transform, "View direction vertical"); + } + + private LineRenderer Line(Transform parent, string name) + { + var child = new GameObject(name); + child.transform.SetParent(parent, false); + var line = child.AddComponent(); + line.useWorldSpace = true; + line.material = SharedMaterial(); + line.startColor = line.endColor = viewDotColor; + line.startWidth = line.endWidth = Mathf.Max(0.01f, width); + line.numCapVertices = 2; + return line; + } + + private Material SharedMaterial() + { + if (_material != null) return _material; + var shader = Shader.Find("Sprites/Default") ?? Shader.Find("Universal Render Pipeline/Unlit"); + if (shader == null) throw new InvalidOperationException("EveUnity requires an unlit shader for aim presentation."); + return _material = new Material(shader) { name = "EveUnity aim presentation" }; + } + + private static void Ring(LineRenderer line, Vector3 center, Vector3 right, Vector3 up, float radius) + { + const int segments = 33; + line.positionCount = segments; + for (var index = 0; index < segments; index++) + { + var angle = index / (float)(segments - 1) * Mathf.PI * 2f; + line.SetPosition(index, center + right * (Mathf.Cos(angle) * radius) + up * (Mathf.Sin(angle) * radius)); + } + } + + private static void Segment(LineRenderer line, Vector3 center, Vector3 axis, float radius) + { + line.positionCount = 2; + line.SetPosition(0, center - axis * radius); + line.SetPosition(1, center + axis * radius); + } + + private void SetVisible(bool visible) + { + if (_ring != null) _ring.enabled = visible; + if (_horizontal != null) _horizontal.enabled = visible; + if (_vertical != null) _vertical.enabled = visible; + } + + private void OnDestroy() + { + if (_material != null) Destroy(_material); + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAimPresentationRenderer.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAimPresentationRenderer.cs.meta new file mode 100644 index 0000000..66058c7 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAimPresentationRenderer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 97c7b809756a4350a6e61c67bba69b82 diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentation.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentation.cs new file mode 100644 index 0000000..6a0e5a5 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentation.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityBeamPresentation + { + public const string SourceForwardDirection = "source-forward.v1"; + + public EveUnityBeamPresentation(EveUnitySceneNode node) + { + if (node == null) throw new ArgumentNullException(nameof(node)); + Id = node.Id ?? ""; + SourceEntityId = Get(node, "sourceEntityId"); + AssetRole = Get(node, "assetRole"); + DirectionMode = Get(node, "directionMode"); + if (string.IsNullOrWhiteSpace(DirectionMode)) DirectionMode = SourceForwardDirection; + RenderChannel = Get(node, "renderChannel"); + ActivationActionId = Get(node, "activationActionId"); + Power = NonNegative(node, "power", 0f); + PowerStateSemantic = Get(node, "powerStateSemantic"); + ActivationThreshold = NonNegative(node, "activationThreshold", 0.01f); + Radius = NonNegative(node, "radius", 0f); + MaximumDistance = NonNegative(node, "maximumDistance", 0f); + } + + public string Id { get; } + public string SourceEntityId { get; } + public string AssetRole { get; } + public string DirectionMode { get; } + public string RenderChannel { get; } + public string ActivationActionId { get; } + public float Power { get; } + public string PowerStateSemantic { get; } + public float ActivationThreshold { get; } + public float Radius { get; } + public float MaximumDistance { get; } + public bool UsesSourceForward => + string.Equals(DirectionMode, SourceForwardDirection, StringComparison.Ordinal); + + public static IReadOnlyList FindAll(EveUnitySceneProjection? projection) + { + var found = new List(); + if (projection != null) FindAll(projection.Root, found); + return found; + } + + private static void FindAll(EveUnitySceneNode node, ICollection found) + { + if (string.Equals(node.ComponentKind, "beam.presentation", StringComparison.Ordinal)) + found.Add(new EveUnityBeamPresentation(node)); + foreach (var child in node.Children) FindAll(child, found); + } + + private static string Get(EveUnitySceneNode node, string key) => + node.Props.TryGetValue(key, out var value) ? value ?? "" : ""; + + private static float NonNegative(EveUnitySceneNode node, string key, float fallback) + { + if (!float.TryParse(Get(node, key), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) || + float.IsNaN(value) || float.IsInfinity(value)) + return fallback; + return Math.Max(0f, value); + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentation.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentation.cs.meta new file mode 100644 index 0000000..4acc86b --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3086cd42c94f2624197d19704909f3f5 \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentationRenderer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentationRenderer.cs new file mode 100644 index 0000000..3a28964 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentationRenderer.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityBeamPresentationRenderer : MonoBehaviour + { + private readonly Dictionary _active = new Dictionary(StringComparer.Ordinal); + private EveUnityPlayableWorldClientHost? _host; + private IEveUnityGameObjectAssetProvider? _assetProvider; + + public int ActiveBeamCount => _active.Count; + public int ActiveParticleSystemCount + { + get + { + var count = 0; + foreach (var beam in _active.Values) + if (beam.Root != null) + count += beam.Root.GetComponentsInChildren(true).Length; + return count; + } + } + + public void Bind( + EveUnityPlayableWorldClientHost host, + IEveUnityGameObjectAssetProvider? assetProvider = null) + { + _host = host != null ? host : throw new ArgumentNullException(nameof(host)); + _assetProvider = assetProvider; + } + + public void RefreshNow() + { + Apply( + EveUnityBeamPresentation.FindAll(_host?.ActiveProjection), + _host?.PresentedEntities, + _assetProvider); + } + + public void Apply( + IReadOnlyList presentations, + IEveUnityPresentedEntityRegistry? entities, + IEveUnityGameObjectAssetProvider? assetProvider) + { + var retained = new HashSet(StringComparer.Ordinal); + foreach (var presentation in presentations ?? Array.Empty()) + { + if (string.IsNullOrWhiteSpace(presentation.Id) || + string.IsNullOrWhiteSpace(presentation.SourceEntityId) || + string.IsNullOrWhiteSpace(presentation.AssetRole) || + !presentation.UsesSourceForward || entities == null || + !entities.TryGetByEntityId(presentation.SourceEntityId, out var source)) + continue; + + retained.Add(presentation.Id); + if (!_active.TryGetValue(presentation.Id, out var beam) || + !string.Equals(beam.SourceEntityId, presentation.SourceEntityId, StringComparison.Ordinal) || + !string.Equals(beam.AssetRole, presentation.AssetRole, StringComparison.Ordinal) || + beam.Root == null) + { + Remove(presentation.Id); + var prefab = assetProvider?.ResolvePrefab(new EveUnityPlayableWorldAssetBinding( + presentation.AssetRole, presentation.AssetRole, "provider-asset-ref")); + if (prefab == null) continue; + var root = Instantiate(prefab, source.Transform, false); + root.name = "Eve beam " + presentation.Id; + beam = new ActiveBeam(root, presentation.SourceEntityId, presentation.AssetRole); + _active[presentation.Id] = beam; + } + else if (beam.Root.transform.parent != source.Transform) + { + beam.Root.transform.SetParent(source.Transform, false); + } + + var power = presentation.Power; + if (!string.IsNullOrWhiteSpace(presentation.PowerStateSemantic)) + power = source.Entity.TryGetScalar(presentation.PowerStateSemantic, out var livePower) + ? Math.Max(0f, livePower) + : 0f; + var visiblePower = power > presentation.ActivationThreshold + ? power + : 0f; + foreach (var particles in beam.Root.GetComponentsInChildren(true)) + { + var emission = particles.emission; + emission.rateOverTimeMultiplier = visiblePower; + } + beam.Power = visiblePower; + } + + var stale = new List(); + foreach (var id in _active.Keys) + if (!retained.Contains(id)) stale.Add(id); + foreach (var id in stale) Remove(id); + } + + public bool TryGetPower(string presentationId, out float power) + { + if (_active.TryGetValue(presentationId ?? "", out var beam) && beam.Root != null) + { + power = beam.Power; + return true; + } + power = 0f; + return false; + } + + private void LateUpdate() => RefreshNow(); + + private void Remove(string id) + { + if (!_active.TryGetValue(id, out var beam)) return; + _active.Remove(id); + if (beam.Root != null) + { + if (Application.isPlaying) Destroy(beam.Root); + else DestroyImmediate(beam.Root); + } + } + + private void OnDestroy() + { + foreach (var beam in _active.Values) + if (beam.Root != null) + { + if (Application.isPlaying) Destroy(beam.Root); + else DestroyImmediate(beam.Root); + } + _active.Clear(); + _host = null; + _assetProvider = null; + } + + private sealed class ActiveBeam + { + public ActiveBeam(GameObject root, string sourceEntityId, string assetRole) + { + Root = root; + SourceEntityId = sourceEntityId; + AssetRole = assetRole; + } + + public GameObject Root { get; } + public string SourceEntityId { get; } + public string AssetRole { get; } + public float Power { get; set; } + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentationRenderer.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentationRenderer.cs.meta new file mode 100644 index 0000000..ecae7e9 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityBeamPresentationRenderer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f91c99c24bc0e20488fa03daef2eec06 \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentation.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentation.cs new file mode 100644 index 0000000..d3879af --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentation.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityCombatPresentation + { + public EveUnityCombatPresentation(EveUnitySceneNode node) + { + if (node == null) throw new ArgumentNullException(nameof(node)); + ControlledEntityId = Get(node, "controlledEntityId"); + ControlledEntityIndex = Int(node, "controlledEntityIndex", -1); + SelectedTargetEntityId = Get(node, "selectedTargetEntityId"); + SelectedTargetEntityIndex = Int(node, "selectedTargetEntityIndex", -1); + TargetVisible = Bool(node, "targetVisible"); + TargetHostile = Bool(node, "targetHostile"); + ContactInformation = Ratio(node, "contactInformation"); + ShieldRatio = Ratio(node, "shieldRatio"); + HullRatio = Ratio(node, "hullRatio"); + LockProgress = Ratio(node, "lockProgress"); + ReticleRole = Get(node, "reticleRole"); + LockRole = Get(node, "lockRole"); + ShieldMeterRole = Get(node, "shieldMeterRole"); + HullMeterRole = Get(node, "hullMeterRole"); + HitMarkerRole = Get(node, "hitMarkerRole"); + LockDisplayThreshold = Ratio(node, "lockDisplayThreshold", 0.01f); + HitMarkerDurationSeconds = Math.Max(0.01f, Number(node, "hitMarkerDurationSeconds", 0.25f)); + RadialFillMinimum = Ratio(node, "radialFillMinimum", 0.25f); + RadialFillMaximum = Ratio(node, "radialFillMaximum", 0.75f); + } + + public string ControlledEntityId { get; } + public int ControlledEntityIndex { get; } + public string SelectedTargetEntityId { get; } + public int SelectedTargetEntityIndex { get; } + public bool TargetVisible { get; } + public bool TargetHostile { get; } + public float ContactInformation { get; } + public float ShieldRatio { get; } + public float HullRatio { get; } + public float LockProgress { get; } + public string ReticleRole { get; } + public string LockRole { get; } + public string ShieldMeterRole { get; } + public string HullMeterRole { get; } + public string HitMarkerRole { get; } + public float LockDisplayThreshold { get; } + public float HitMarkerDurationSeconds { get; } + public float RadialFillMinimum { get; } + public float RadialFillMaximum { get; } + + public float RadialFill(float ratio) => + RadialFillMinimum + (RadialFillMaximum - RadialFillMinimum) * Math.Max(0, Math.Min(1, ratio)); + + public bool PresentsHit(EveUnityShotReceipt receipt) => + receipt != null && + receipt.SourceEntityIndex == ControlledEntityIndex && + receipt.TargetEntityIndex == SelectedTargetEntityIndex && + receipt.Hit && + (receipt.AppliedDamage > 0 || receipt.ShieldAbsorbedDamage > 0); + + public static EveUnityCombatPresentation? Find(EveUnitySceneProjection? projection) + { + if (projection == null) return null; + return Find(projection.Root); + } + + private static EveUnityCombatPresentation? Find(EveUnitySceneNode node) + { + if (string.Equals(node.ComponentKind, "combat.presentation", StringComparison.Ordinal)) + return new EveUnityCombatPresentation(node); + foreach (var child in node.Children) + { + var found = Find(child); + if (found != null) return found; + } + return null; + } + + private static string Get(EveUnitySceneNode node, string key) => + node.Props.TryGetValue(key, out var value) ? value ?? "" : ""; + + private static int Int(EveUnitySceneNode node, string key, int fallback) => + int.TryParse(Get(node, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : fallback; + + private static bool Bool(EveUnitySceneNode node, string key) => + bool.TryParse(Get(node, key), out var value) && value; + + private static float Number(EveUnitySceneNode node, string key, float fallback = 0) => + float.TryParse(Get(node, key), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : fallback; + + private static float Ratio(EveUnitySceneNode node, string key, float fallback = 0) => + Math.Max(0, Math.Min(1, Number(node, key, fallback))); + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentation.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentation.cs.meta new file mode 100644 index 0000000..e0219d9 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e15acab8e9094b18b6dbd3abea0e055d diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentationRenderer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentationRenderer.cs new file mode 100644 index 0000000..8c23259 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentationRenderer.cs @@ -0,0 +1,279 @@ +using System; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityCombatPresentationRenderer : MonoBehaviour + { + [SerializeField] private Color reticleColor = new Color(0.2f, 0.9f, 1f, 0.9f); + [SerializeField] private Color lockColor = new Color(1f, 0.7f, 0.15f, 1f); + [SerializeField] private Color shieldColor = new Color(0.15f, 0.65f, 1f, 1f); + [SerializeField] private Color hullColor = new Color(1f, 0.35f, 0.2f, 1f); + [SerializeField] private Color hitColor = Color.white; + [SerializeField] private float width = 0.12f; + + private EveUnityPlayableWorldClientHost? _host; + private Transform? _visualRoot; + private LineRenderer? _reticle; + private LineRenderer? _lock; + private LineRenderer? _shield; + private LineRenderer? _hull; + private LineRenderer? _hitA; + private LineRenderer? _hitB; + private Material? _material; + private float _hitUntil; + private float _pendingHitDuration; + private EveUnityCombatPresentation? _lastPresentation; + private Vector3 _lastCenter; + private Vector3 _lastRight; + private Vector3 _lastUp; + private float _lastRadius; + private bool _hasLastTarget; + + public EveUnityCombatPresentation? Current { get; private set; } + public bool ReticleVisible => _reticle != null && _reticle.enabled; + public bool LockVisible => _lock != null && _lock.enabled; + public bool HitMarkerVisible => _hitA != null && _hitA.enabled; + + public void Bind(EveUnityPlayableWorldClientHost host) + { + if (host == null) throw new ArgumentNullException(nameof(host)); + if (ReferenceEquals(_host, host)) return; + Unbind(); + _host = host; + _host.ShotAvailable += OnShotAvailable; + } + + public void Unbind() + { + if (_host != null) _host.ShotAvailable -= OnShotAvailable; + _host = null; + Current = null; + SetVisible(false); + } + + public void RefreshNow() + { + if (_pendingHitDuration > 0) + { + _hitUntil = Time.unscaledTime + _pendingHitDuration; + _pendingHitDuration = 0; + } + Current = EveUnityCombatPresentation.Find(_host?.ActiveProjection); + if (Current == null || string.IsNullOrWhiteSpace(Current.SelectedTargetEntityId)) + { + PresentRetainedHit(); + return; + } + + _lastPresentation = Current; + var marker = FindMarker(Current.SelectedTargetEntityId); + if (marker == null) + { + PresentRetainedHit(); + return; + } + + EnsureVisuals(); + PresentationAxes(out var right, out var up, out var towardCamera, out var cullingMask); + var visualBounds = VisualBounds(marker.transform, cullingMask); + var center = visualBounds?.center ?? (marker.transform.position + Vector3.up * 0.25f); + var radius = Math.Max(0.8f, visualBounds?.extents.magnitude ?? marker.transform.lossyScale.magnitude * 0.8f); + center += towardCamera * 0.1f; + _lastCenter = center; + _lastRight = right; + _lastUp = up; + _lastRadius = radius; + _hasLastTarget = true; + Arc(_reticle!, center, right, up, radius * 1.35f, 1f, 0f); + _reticle!.enabled = Current.TargetVisible; + + var lockVisible = Current.TargetVisible && Current.TargetHostile && + Current.LockProgress > Current.LockDisplayThreshold; + var noise = (1f - Current.LockProgress) * radius * 0.2f; + var frequency = Mathf.Lerp(0.01f, 10f, Mathf.Pow(Current.LockProgress, 4f)); + var noisyCenter = center + + right * ((Mathf.PerlinNoise(Time.unscaledTime * frequency, 0.37f) - 0.5f) * noise) + + up * ((Mathf.PerlinNoise(0.73f, Time.unscaledTime * frequency) - 0.5f) * noise); + var spin = Time.unscaledTime * Mathf.Lerp(5f, 720f, Current.LockProgress * Current.LockProgress); + Arc(_lock!, noisyCenter, right, up, radius * 1.55f, Current.LockProgress, spin); + _lock!.enabled = lockVisible; + + Arc(_shield!, center, right, up, radius * 1.78f, Current.RadialFill(Current.ShieldRatio), -90f); + Arc(_hull!, center, right, up, radius * 1.98f, Current.RadialFill(Current.HullRatio), -90f); + _shield!.enabled = Current.TargetVisible; + _hull!.enabled = Current.TargetVisible; + + var hitVisible = Time.unscaledTime < _hitUntil; + Cross(_hitA!, _hitB!, center, right, up, radius * 0.55f); + _hitA!.enabled = hitVisible; + _hitB!.enabled = hitVisible; + } + + private void PresentRetainedHit() + { + if (!_hasLastTarget || Time.unscaledTime >= _hitUntil) + { + SetVisible(false); + return; + } + EnsureVisuals(); + _reticle!.enabled = _lock!.enabled = _shield!.enabled = _hull!.enabled = false; + Cross(_hitA!, _hitB!, _lastCenter, _lastRight, _lastUp, _lastRadius * 0.55f); + _hitA!.enabled = _hitB!.enabled = true; + } + + private void LateUpdate() => RefreshNow(); + + private void OnShotAvailable(EveUnityShotReceipt receipt) + { + Current = EveUnityCombatPresentation.Find(_host?.ActiveProjection); + var presentation = Current; + if (presentation?.PresentsHit(receipt) != true) + presentation = _lastPresentation; + if (presentation?.PresentsHit(receipt) == true) + { + PresentationAxes(out var right, out var up, out _, out _); + _lastCenter = new Vector3( + (float)receipt.Endpoint.X, + (float)receipt.Endpoint.Y, + (float)receipt.Endpoint.Z); + _lastRight = right; + _lastUp = up; + _lastRadius = Math.Max(0.8f, _lastRadius); + _hasLastTarget = true; + _pendingHitDuration = Math.Max(_pendingHitDuration, presentation.HitMarkerDurationSeconds); + } + } + + private EveUnityPlayableWorldEntityMarker? FindMarker(string entityId) + { + if (_host == null) return null; + foreach (var marker in _host.SceneRoot.GetComponentsInChildren(true)) + if (string.Equals(marker.EntityId, entityId, StringComparison.Ordinal)) return marker; + return null; + } + + private void EnsureVisuals() + { + if (_visualRoot != null) return; + var root = new GameObject("Eve combat presentation"); + root.transform.SetParent(transform, false); + _visualRoot = root.transform; + _reticle = Line("Selected target reticle", reticleColor, width); + _lock = Line("Target lock", lockColor, width * 1.15f); + _shield = Line("Target shield", shieldColor, width * 0.75f); + _hull = Line("Target hull", hullColor, width * 0.75f); + _hitA = Line("Hit marker A", hitColor, width * 1.3f); + _hitB = Line("Hit marker B", hitColor, width * 1.3f); + } + + private LineRenderer Line(string name, Color color, float lineWidth) + { + var child = new GameObject(name); + child.transform.SetParent(_visualRoot, false); + var line = child.AddComponent(); + line.useWorldSpace = true; + line.loop = false; + line.material = SharedMaterial(); + line.startColor = line.endColor = color; + line.startWidth = line.endWidth = Math.Max(0.01f, lineWidth); + line.numCapVertices = 2; + return line; + } + + private Material SharedMaterial() + { + if (_material != null) return _material; + var shader = Shader.Find("Sprites/Default") ?? Shader.Find("Universal Render Pipeline/Unlit"); + if (shader == null) throw new InvalidOperationException("EveUnity requires an unlit shader for combat presentation."); + _material = new Material(shader) { name = "EveUnity combat presentation" }; + return _material; + } + + private static void PresentationAxes(out Vector3 right, out Vector3 up, out Vector3 towardCamera, out int cullingMask) + { + Camera? selected = Camera.main; + if (selected == null) + { + foreach (var candidate in Camera.allCameras) + { + if (!candidate.isActiveAndEnabled || candidate.orthographic) + continue; + selected = candidate; + break; + } + } + right = selected == null ? Vector3.right : selected.transform.right; + up = selected == null ? Vector3.up : selected.transform.up; + towardCamera = selected == null ? Vector3.back : -selected.transform.forward; + cullingMask = selected == null ? -1 : selected.cullingMask; + } + + private static Bounds? VisualBounds(Transform root, int cullingMask) + { + Bounds? bounds = null; + foreach (var renderer in root.GetComponentsInChildren(includeInactive: false)) + { + if (renderer == null || !renderer.enabled) + continue; + if ((cullingMask & (1 << renderer.gameObject.layer)) == 0) + continue; + if (renderer is LineRenderer || renderer is TrailRenderer || + string.Equals(renderer.GetType().Name, "ParticleSystemRenderer", StringComparison.Ordinal)) + continue; + if (bounds.HasValue) + { + var combined = bounds.Value; + combined.Encapsulate(renderer.bounds); + bounds = combined; + } + else + { + bounds = renderer.bounds; + } + } + return bounds; + } + + private static void Arc(LineRenderer line, Vector3 center, Vector3 right, Vector3 up, float radius, float fill, float rotationDegrees) + { + fill = Mathf.Clamp01(fill); + var segments = Math.Max(2, Mathf.CeilToInt(64 * fill) + 1); + line.positionCount = segments; + for (var index = 0; index < segments; index++) + { + var progress = segments <= 1 ? 0 : index / (float)(segments - 1); + var angle = (rotationDegrees + progress * 360f * fill) * Mathf.Deg2Rad; + line.SetPosition(index, center + right * (Mathf.Cos(angle) * radius) + up * (Mathf.Sin(angle) * radius)); + } + } + + private static void Cross(LineRenderer a, LineRenderer b, Vector3 center, Vector3 right, Vector3 up, float radius) + { + a.positionCount = b.positionCount = 2; + a.SetPosition(0, center - right * radius - up * radius); + a.SetPosition(1, center + right * radius + up * radius); + b.SetPosition(0, center - right * radius + up * radius); + b.SetPosition(1, center + right * radius - up * radius); + } + + private void SetVisible(bool visible) + { + if (_reticle != null) _reticle.enabled = visible; + if (_lock != null) _lock.enabled = visible; + if (_shield != null) _shield.enabled = visible; + if (_hull != null) _hull.enabled = visible; + if (_hitA != null) _hitA.enabled = visible && Time.unscaledTime < _hitUntil; + if (_hitB != null) _hitB.enabled = visible && Time.unscaledTime < _hitUntil; + } + + private void OnDestroy() + { + Unbind(); + if (_material != null) Destroy(_material); + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentationRenderer.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentationRenderer.cs.meta new file mode 100644 index 0000000..25e78e2 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentationRenderer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5c953d99a7454322b2935b878295b357 diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaPresenter.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaPresenter.cs index 31ba21c..d16691a 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaPresenter.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaPresenter.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using GameCult.Eve.Surface; +using GameCult.Mesh; #nullable enable @@ -9,13 +10,7 @@ namespace GameCult.Eve.UnityScene { public interface IEveUnityEntitySoaViewDocumentSource { - EveEntitySoaViewDocument? CurrentEntityView { get; } - event Action EntityViewAvailable; - } - - public interface IEveUnityInputCapabilityDocumentSource - { - EveInputCapabilityDocument? CurrentInputCapability { get; } + event Action EntityViewAvailable; } /// Projects portable entity SoA generations into the ordinary Unity scene sink. @@ -29,15 +24,10 @@ namespace GameCult.Eve.UnityScene _sink = sink ?? throw new ArgumentNullException(nameof(sink)); } - public int ActiveEntities => _known.Count; - - public void Apply(EveEntitySoaViewDocument document) + public void Apply(EveEntitySoaViewDocument document, ICultMeshBodyReadLease lease) { - using var view = EveUnityEntitySoaView.Open(document); - var identities = (document.Identities ?? Array.Empty()) - .Where(identity => identity != null && identity.EntityIndex >= 0 && !string.IsNullOrWhiteSpace(identity.EntityId)) - .GroupBy(identity => identity.EntityIndex) - .ToDictionary(group => group.Key, group => group.First()); + using var view = EveUnityEntitySoaView.Open(document, lease); + var presented = new List(); var next = new HashSet(StringComparer.Ordinal); for (var row = 0; row < view.EntityCount; row++) { @@ -45,45 +35,60 @@ namespace GameCult.Eve.UnityScene if (view.TryReadByte("render.visibility", row, out var visible) && visible == 0) continue; if (!view.TryReadVector3("transform.position", row, out var position)) continue; view.TryReadFloat("transform.rotation.radians", row, out var rotationRadians); + view.TryReadVector3("transform.velocity", row, out var velocity); + view.TryReadFloat("physics.body.radius", row, out var radius); view.TryReadFloat("render.scale", row, out var scale); + view.TryReadInt32("render.lod", row, out var lod); view.TryReadUInt32("render.group.id", row, out var groupId); var renderGroup = view.RenderGroups.FirstOrDefault(group => group.GroupId == groupId); - if (!identities.TryGetValue(entityIndex, out var identity)) - throw new InvalidOperationException( - $"Eve entity SoA view '{document.ViewId}' does not map entity index {entityIndex} to a logical identity."); - var entityId = identity.EntityId; + var identity = document.Identities.FirstOrDefault(item => item.Index == entityIndex); + var entityId = identity?.EntityId ?? $"entity:{entityIndex}"; next.Add(entityId); - var assetRef = string.IsNullOrWhiteSpace(identity.AssetRef) + var assetRef = string.IsNullOrWhiteSpace(identity?.AssetRef) ? renderGroup?.MeshAssetRef ?? "" - : identity.AssetRef; + : identity!.AssetRef; + var scalarState = new Dictionary(StringComparer.Ordinal); + foreach (var semantic in view.FloatSemantics) + if (view.TryReadFloat(semantic, row, out var scalar)) + scalarState[semantic] = scalar; + var fact = new EveUnityPresentedEntity( + entityIndex, + entityId, + identity?.EntityKind ?? "entity", + string.IsNullOrWhiteSpace(identity?.Label) ? entityId : identity!.Label, + identity?.Faction ?? "", + position, + rotationRadians * 57.2957795f, + velocity, + radius, + scale > 0f ? scale : renderGroup?.DefaultScale ?? 1f, + groupId, + lod, + identity?.Selectable ?? true, + identity?.Controllable ?? false, + assetRef, + scalarState); + presented.Add(fact); + if (_sink is IEveUnityEntityGenerationSink) + continue; _sink.UpsertEntity( - new EveUnityPlayableWorldEntity( - entityId, - entityId, - identity.Kind, - string.IsNullOrWhiteSpace(identity.Label) ? entityId : identity.Label, - identity.Faction, - assetRef, - position.x, - position.y, - position.z, - rotationRadians * 57.2957795f, - scale > 0f ? scale : renderGroup?.DefaultScale ?? 1f, - selectable: identity.Selectable, - controllable: identity.Controllable, - focusCommand: "", - moveCommand: "", - targetCommand: "", - actionCommand: "", - props: new Dictionary()), + fact.ToPlayableWorldEntity(), new EveUnityPlayableWorldAssetBinding( assetRef, - identity.Kind, + "entity", string.IsNullOrWhiteSpace(assetRef) ? "unity-generated-placeholder" : "provider-asset-ref")); } - foreach (var entityId in _known.Where(entityId => !next.Contains(entityId)).ToArray()) - _sink.RemoveEntity(entityId); + if (_sink is IEveUnityEntityGenerationSink generationSink) + { + generationSink.ApplyGeneration(new EveUnityPresentedEntityGeneration( + document.ViewId, document.ProducerEpoch, document.Sequence, presented)); + } + else + { + foreach (var entityId in _known.Where(entityId => !next.Contains(entityId)).ToArray()) + _sink.RemoveEntity(entityId); + } _known.Clear(); foreach (var entityId in next) _known.Add(entityId); } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaView.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaView.cs index db2fd6d..5475ddf 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaView.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaView.cs @@ -11,111 +11,131 @@ namespace GameCult.Eve.UnityScene { public sealed class EveUnityEntitySoaView : IDisposable { - private readonly ICultMeshBodyReadLease _body; + private readonly ICultMeshBodyReadLease _lease; + private readonly Dictionary _buffers; private readonly Dictionary _columns; + private readonly string[] _floatSemantics; - private EveUnityEntitySoaView(EveEntitySoaViewDocument document, ICultMeshBodyReadLease body) + private EveUnityEntitySoaView(EveEntitySoaViewDocument document, ICultMeshBodyReadLease lease) { Document = document; - _body = body; + _lease = lease; + _buffers = document.Buffers.ToDictionary(buffer => buffer.BufferId, StringComparer.Ordinal); _columns = document.Columns.ToDictionary(column => column.Semantic, StringComparer.Ordinal); + _floatSemantics = document.Columns + .Where(column => string.Equals(column.ScalarType, "float32", StringComparison.Ordinal)) + .Select(column => column.Semantic) + .Where(semantic => !string.IsNullOrWhiteSpace(semantic)) + .ToArray(); } public EveEntitySoaViewDocument Document { get; } - public long Generation => Document.Generation; + public long Generation => Document.Sequence; public IReadOnlyList RenderGroups => Document.RenderGroups; + public int EntityCount => Document.Columns.Length == 0 ? 0 : Document.Columns.Min(column => column.ElementCount); + public IReadOnlyList FloatSemantics => _floatSemantics; - public static EveUnityEntitySoaView Open(EveEntitySoaViewDocument document) + public static EveUnityEntitySoaView Open(EveEntitySoaViewDocument document, ICultMeshBodyReadLease lease) { if (document == null) throw new ArgumentNullException(nameof(document)); - if (!string.Equals(document.Schema, EveEntitySoaViewDocument.SchemaId, StringComparison.Ordinal)) - throw new ArgumentException($"Expected {EveEntitySoaViewDocument.SchemaId}, got '{document.Schema}'.", nameof(document)); - if (document.Body == null) - throw new InvalidOperationException("Eve entity SoA view did not advertise a CultMesh body."); - var request = new CultMeshBodyValidationRequest - { - BodyId = document.Body.BodyId, - SchemaId = document.Body.SchemaId, - LayoutVersion = document.Body.LayoutVersion, - ProducerEpoch = document.Body.ProducerEpoch, - AccessMode = CultMeshBodyAccessMode.ReadOnly, - NowUtc = DateTimeOffset.UtcNow - }; - var body = new CultMeshSharedMemoryBodyAdapter().OpenReadOnly(document.Body, request); - return new EveUnityEntitySoaView(document, body); + if (lease == null) throw new ArgumentNullException(nameof(lease)); + Validate(document, lease); + return new EveUnityEntitySoaView(document, lease); } public bool TryReadVector3(string semantic, int index, out Vector3 value) { value = default; - if (!TryColumn(semantic, index, "float3", 12, out var column)) return false; - var offset = column.ByteOffset + (long)index * column.ElementStride; - value = new Vector3(_body.ReadSingle(offset), _body.ReadSingle(offset + 4), _body.ReadSingle(offset + 8)); + if (!TryColumn(semantic, index, "float3", 12, out var offset)) return false; + value = new Vector3(_lease.ReadSingle(offset), _lease.ReadSingle(offset + 4), _lease.ReadSingle(offset + 8)); return true; } public bool TryReadFloat(string semantic, int index, out float value) { value = default; - if (!TryColumn(semantic, index, "float32", 4, out var column)) return false; - value = _body.ReadSingle(column.ByteOffset + (long)index * column.ElementStride); + if (!TryColumn(semantic, index, "float32", 4, out var offset)) return false; + value = _lease.ReadSingle(offset); return true; } public bool TryReadInt32(string semantic, int index, out int value) { value = default; - if (!TryColumn(semantic, index, "int32", 4, out var column)) return false; - value = _body.ReadInt32(column.ByteOffset + (long)index * column.ElementStride); + if (!TryColumn(semantic, index, "int32", 4, out var offset)) return false; + value = _lease.ReadInt32(offset); return true; } public bool TryReadUInt32(string semantic, int index, out uint value) { value = default; - if (!TryColumn(semantic, index, "uint32", 4, out var column)) return false; - value = unchecked((uint)_body.ReadInt32(column.ByteOffset + (long)index * column.ElementStride)); + if (!TryColumn(semantic, index, "uint32", 4, out var offset)) return false; + value = unchecked((uint)_lease.ReadInt32(offset)); return true; } - public int EntityCount => Document.Columns.Length == 0 - ? 0 - : Document.Columns.Min(column => column.ElementCount); - public bool TryReadByte(string semantic, int index, out byte value) { value = default; - if (!TryColumn(semantic, index, "uint8", 1, out var column)) return false; - value = _body.ReadByte(column.ByteOffset + (long)index * column.ElementStride); + if (!TryColumn(semantic, index, "uint8", 1, out var offset)) return false; + value = _lease.ReadByte(offset); return true; } - public void Dispose() + public void Dispose() => _lease.Dispose(); + + private static void Validate(EveEntitySoaViewDocument document, ICultMeshBodyReadLease lease) { - _body.Dispose(); + if (!string.Equals(document.Schema, EveEntitySoaViewDocument.SchemaId, StringComparison.Ordinal)) + throw new ArgumentException($"Expected {EveEntitySoaViewDocument.SchemaId}, got '{document.Schema}'.", nameof(document)); + if (document.Buffers == null || document.Buffers.Length == 0) + throw new ArgumentException("Entity SoA view requires a primary logical buffer.", nameof(document)); + var descriptor = lease.Descriptor ?? throw new ArgumentException("Body lease has no descriptor.", nameof(lease)); + var primary = document.Buffers[0]; + if (!string.Equals(descriptor.BodyId, primary.BufferId, StringComparison.Ordinal) || + !string.Equals(descriptor.SchemaId, document.BodySchemaId, StringComparison.Ordinal) || + descriptor.LayoutVersion != document.LayoutVersion || + descriptor.ProducerEpoch != document.ProducerEpoch || + descriptor.Sequence != document.Sequence || + descriptor.Capacity != document.Capacity) + throw new InvalidOperationException("CultMesh body lease generation does not match the Eve entity layout."); + if (document.Capacity < 0 || document.Buffers.Any(buffer => + string.IsNullOrWhiteSpace(buffer.BufferId) || buffer.ByteOffset < 0 || buffer.ByteLength < 0 || + buffer.ByteOffset > descriptor.ByteSize - buffer.ByteLength)) + throw new InvalidOperationException("Eve entity layout contains an invalid logical buffer range."); + var buffers = document.Buffers.ToDictionary(buffer => buffer.BufferId, StringComparer.Ordinal); + foreach (var column in document.Columns ?? Array.Empty()) + { + if (!buffers.TryGetValue(column.BufferId, out var buffer) || column.ByteOffset < 0 || + column.ElementStride <= 0 || column.ElementCount < 0 || column.ElementCount > document.Capacity) + throw new InvalidOperationException($"Eve entity column '{column.ColumnId}' has invalid layout metadata."); + var width = ScalarWidth(column.ScalarType); + var occupied = column.ElementCount == 0 ? 0 : (long)(column.ElementCount - 1) * column.ElementStride + width; + if (width == 0 || column.ByteOffset > buffer.ByteLength - occupied) + throw new InvalidOperationException($"Eve entity column '{column.ColumnId}' exceeds its logical buffer."); + } } - private bool TryColumn( - string semantic, - int index, - string scalarType, - int minimumStride, - out EveEntitySoaColumn column) + private bool TryColumn(string semantic, int index, string scalarType, int minimumStride, out long offset) { - column = null!; - if (index < 0 || !_columns.TryGetValue(semantic ?? "", out column)) return false; + offset = 0; + if (index < 0 || !_columns.TryGetValue(semantic ?? "", out var column)) return false; if (index >= column.ElementCount || column.ElementStride < minimumStride) return false; if (!string.Equals(column.ScalarType, scalarType, StringComparison.Ordinal)) return false; - return DocumentBufferContains(column); + if (!_buffers.TryGetValue(column.BufferId, out var buffer)) return false; + offset = buffer.ByteOffset + column.ByteOffset + (long)index * column.ElementStride; + return true; } - private bool DocumentBufferContains(EveEntitySoaColumn column) + private static int ScalarWidth(string scalarType) => scalarType switch { - var buffer = Document.Buffers.FirstOrDefault(candidate => - string.Equals(candidate.BufferId, column.BufferId, StringComparison.Ordinal)); - if (buffer == null || buffer.ByteOffset != 0 || Document.Body == null) return false; - var columnEnd = column.ByteOffset + (long)column.ElementStride * column.ElementCount; - return column.ByteOffset >= 0 && columnEnd <= Document.Body.ByteSize; - } + "float3" => 12, + "float32" => 4, + "int32" => 4, + "uint32" => 4, + "uint8" => 1, + _ => 0 + }; } } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityFeedbackPresenter.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityFeedbackPresenter.cs index 1f189fe..723671f 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityFeedbackPresenter.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityFeedbackPresenter.cs @@ -11,10 +11,13 @@ namespace GameCult.Eve.UnityScene public EveUnityFeedbackEvent(EveUnitySceneNode node) { EventId = Get(node, "eventId"); Kind = Get(node, "eventKind"); SubjectKey = Get(node, "subjectKey"); - ItemKey = Get(node, "itemKey"); Position = Get(node, "position"); + ItemKey = Get(node, "itemKey"); Position = Get(node, "position"); Reason = Get(node, "reason"); FrameId = Long(node, "frameId", -1); ZoneIndex = Int(node, "zoneIndex", -1); SourceEntityIndex = Int(node, "sourceEntityIndex", -1); TargetEntityIndex = Int(node, "targetEntityIndex", -1); PickupIndex = Int(node, "pickupIndex", -1); ScalarValue = Double(node, "scalarValue", 0); + AuxiliaryValue = Double(node, "auxiliaryValue", 0); + CargoQuantityBefore = Double(node, "cargoQuantityBefore", 0); + CargoQuantityAfter = Double(node, "cargoQuantityAfter", 0); } public string EventId { get; } public string Kind { get; } @@ -26,7 +29,11 @@ namespace GameCult.Eve.UnityScene public string SubjectKey { get; } public string ItemKey { get; } public string Position { get; } + public string Reason { get; } public double ScalarValue { get; } + public double AuxiliaryValue { get; } + public double CargoQuantityBefore { get; } + public double CargoQuantityAfter { get; } private static string Get(EveUnitySceneNode node, string key) => node.Props.TryGetValue(key, out var value) ? value ?? "" : ""; private static int Int(EveUnitySceneNode node, string key, int fallback) => int.TryParse(Get(node, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : fallback; private static long Long(EveUnitySceneNode node, string key, long fallback) => long.TryParse(Get(node, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : fallback; diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs index e4cb824..bf1dad0 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using UnityEngine; #nullable enable @@ -16,13 +17,26 @@ namespace GameCult.Eve.UnityScene UnityEngine.Object? ResolveAsset(EveUnityPlayableWorldAssetBinding asset, Type assetType); } - public sealed class EveUnityGameObjectPlayableWorldSceneSink : IEveUnityPlayableWorldSceneSink + public interface IEveUnityNativeAssetMetadataProvider + { + bool TryResolveAssetMetadata( + EveUnityPlayableWorldAssetBinding asset, + out IReadOnlyDictionary metadata); + } + + public sealed class EveUnityGameObjectPlayableWorldSceneSink : + IEveUnityPlayableWorldSceneSink, + IEveUnityEntityGenerationSink, + IEveUnityPresentedEntityRegistry, + IDisposable { private readonly Transform _root; private readonly IEveUnityGameObjectAssetProvider _assetProvider; private readonly Dictionary _instances = new Dictionary(StringComparer.Ordinal); - private string _subjectEntityId = ""; - private bool _subjectVisible = true; + private IReadOnlyDictionary _presentedById = + new Dictionary(StringComparer.Ordinal); + private IReadOnlyDictionary _presentedByIndex = + new Dictionary(); public EveUnityGameObjectPlayableWorldSceneSink( Transform root, @@ -33,15 +47,19 @@ namespace GameCult.Eve.UnityScene } public int ActiveEntityCount => _instances.Count; + public EveUnityPresentedEntityGeneration? CurrentGeneration { get; private set; } + + public bool TryGetByEntityId(string entityId, out EveUnityPresentedEntityHandle handle) => + _presentedById.TryGetValue(entityId ?? "", out handle!); + + public bool TryGetBySourceIndex(int sourceIndex, out EveUnityPresentedEntityHandle handle) => + _presentedByIndex.TryGetValue(sourceIndex, out handle!); public void ConfigureWorld(EveUnityPlayableWorldProjection world) { if (world == null) throw new ArgumentNullException(nameof(world)); if (!string.IsNullOrWhiteSpace(world.WorldRootId)) _root.name = world.WorldRootId; - _subjectEntityId = world.PlayerEntityId; - _subjectVisible = world.SubjectVisible; - ApplySubjectVisibility(); } public void UpsertEntity(EveUnityPlayableWorldEntity entity, EveUnityPlayableWorldAssetBinding asset) @@ -69,7 +87,90 @@ namespace GameCult.Eve.UnityScene } ApplyEntity(instance, entity, asset); - ApplySubjectVisibility(instance, entity.EntityId); + } + + public void ApplyGeneration(EveUnityPresentedEntityGeneration generation) + { + if (generation == null) throw new ArgumentNullException(nameof(generation)); + if (generation.Entities.Any(entity => string.IsNullOrWhiteSpace(entity.EntityId))) + throw new InvalidOperationException("Presented entity generations require stable entity ids."); + var factsById = generation.Entities.ToDictionary(entity => entity.EntityId, StringComparer.Ordinal); + var factsByIndex = generation.Entities.ToDictionary(entity => entity.SourceIndex); + GameObject? staging = null; + var created = new Dictionary(StringComparer.Ordinal); + var nextInstances = new Dictionary(StringComparer.Ordinal); + try + { + foreach (var fact in generation.Entities) + { + if (_instances.TryGetValue(fact.EntityId, out var existing) && existing != null) + { + nextInstances.Add(fact.EntityId, existing); + continue; + } + + staging ??= new GameObject($"{_root.name}.generation.{generation.ProducerEpoch}.{generation.Sequence}"); + if (staging.transform.parent == null) + { + staging.SetActive(false); + staging.transform.SetParent(_root, false); + } + var entity = fact.ToPlayableWorldEntity(); + var asset = new EveUnityPlayableWorldAssetBinding( + fact.AssetRef, + fact.EntityKind, + string.IsNullOrWhiteSpace(fact.AssetRef) ? "unity-generated-placeholder" : "provider-asset-ref"); + var instance = InstantiateEntity(entity, asset, staging.transform); + created.Add(fact.EntityId, instance); + nextInstances.Add(fact.EntityId, instance); + } + + foreach (var fact in generation.Entities) + { + var entity = fact.ToPlayableWorldEntity(); + var asset = new EveUnityPlayableWorldAssetBinding( + fact.AssetRef, + fact.EntityKind, + string.IsNullOrWhiteSpace(fact.AssetRef) ? "unity-generated-placeholder" : "provider-asset-ref"); + ApplyEntity(nextInstances[fact.EntityId], entity, asset); + } + + var nextById = factsById.ToDictionary( + pair => pair.Key, + pair => new EveUnityPresentedEntityHandle(pair.Value, nextInstances[pair.Key].transform), + StringComparer.Ordinal); + var nextByIndex = factsByIndex.ToDictionary( + pair => pair.Key, + pair => nextById[pair.Value.EntityId]); + + foreach (var pair in _instances) + if (!nextInstances.ContainsKey(pair.Key) && pair.Value != null) + DestroyInstance(pair.Value); + _instances.Clear(); + foreach (var pair in nextInstances) + { + if (created.ContainsKey(pair.Key)) + { + pair.Value.transform.SetParent(_root, true); + pair.Value.SetActive(true); + } + _instances.Add(pair.Key, pair.Value); + } + _presentedById = nextById; + _presentedByIndex = nextByIndex; + CurrentGeneration = generation; + } + catch + { + foreach (var instance in created.Values) + if (instance != null) DestroyInstance(instance); + throw; + } + finally + { + if (staging != null) + DestroyInstance(staging); + } } public void RemoveEntity(string entityId) @@ -86,14 +187,33 @@ namespace GameCult.Eve.UnityScene DestroyInstance(instance); } - private GameObject InstantiateEntity(EveUnityPlayableWorldEntity entity, EveUnityPlayableWorldAssetBinding asset) + public void Dispose() { + foreach (var instance in _instances.Values) + if (instance != null) + { + instance.transform.SetParent(null, false); + instance.SetActive(false); + DestroyInstance(instance); + } + _instances.Clear(); + _presentedById = new Dictionary(StringComparer.Ordinal); + _presentedByIndex = new Dictionary(); + CurrentGeneration = null; + } + + private GameObject InstantiateEntity( + EveUnityPlayableWorldEntity entity, + EveUnityPlayableWorldAssetBinding asset, + Transform? parent = null) + { + parent ??= _root; var prefab = _assetProvider.ResolvePrefab(asset); GameObject instance; if (prefab != null) { instance = new GameObject(entity.EntityId); - instance.transform.SetParent(_root, false); + instance.transform.SetParent(parent, false); var visual = UnityEngine.Object.Instantiate(prefab, instance.transform); visual.transform.localPosition = Vector3.zero; visual.transform.localRotation = Quaternion.identity; @@ -103,8 +223,8 @@ namespace GameCult.Eve.UnityScene instance = GameObject.CreatePrimitive(PrimitiveType.Capsule); } - if (instance.transform.parent != _root) - instance.transform.SetParent(_root, false); + if (instance.transform.parent != parent) + instance.transform.SetParent(parent, false); return instance; } @@ -119,9 +239,7 @@ namespace GameCult.Eve.UnityScene : $"{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 && - string.Equals(asset.PresentationKind, "unity-generated-placeholder", StringComparison.Ordinal)) - instance.transform.localScale = Vector3.one * entity.Radius; + instance.transform.localScale = Vector3.one * (entity.Radius > 0f ? entity.Radius : 1f); var marker = instance.GetComponent(); if (marker == null) @@ -146,19 +264,6 @@ namespace GameCult.Eve.UnityScene else UnityEngine.Object.DestroyImmediate(instance); } - - private void ApplySubjectVisibility() - { - foreach (var pair in _instances) - ApplySubjectVisibility(pair.Value, pair.Key); - } - - private void ApplySubjectVisibility(GameObject instance, string entityId) - { - if (instance == null || !string.Equals(entityId, _subjectEntityId, StringComparison.Ordinal)) - return; - instance.SetActive(_subjectVisible); - } } public sealed class EveUnitySemanticEntityPresentation : MonoBehaviour diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityInputActionBar.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityInputActionBar.cs new file mode 100644 index 0000000..d434950 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityInputActionBar.cs @@ -0,0 +1,295 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using GameCult.Eve.Surface; +using UnityEngine; +using UnityEngine.UIElements; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityInputActionBar : MonoBehaviour + { + private readonly Dictionary _rows = + new Dictionary(StringComparer.Ordinal); + private readonly HashSet _pressedHolds = new HashSet(StringComparer.Ordinal); + private EveUnityPlayableWorldClientHost? _host; + private UIDocument? _document; + private VisualElement? _root; + private string _structureKey = ""; + private float _nextRefreshAt; + + public VisualElement? Root => _root; + public IReadOnlyCollection PresentedActionIds => _rows.Keys; + + private void Awake() => EnsureBuilt(); + + public void Bind(EveUnityPlayableWorldClientHost host) + { + _host = host != null ? host : throw new ArgumentNullException(nameof(host)); + EnsureBuilt(); + RefreshNow(); + } + + private void Update() + { + if (Time.unscaledTime < _nextRefreshAt) return; + _nextRefreshAt = Time.unscaledTime + 0.2f; + RefreshNow(); + } + + public void RefreshNow() + { + EnsureBuilt(); + var capability = _host?.InputCapability; + var actions = SelectActions(capability).ToArray(); + var structureKey = string.Join("\n", actions.Select(action => + $"{action.ActionId}\t{action.Label}\t{action.IconRef}\t{action.InputValue?.Model}")); + if (!string.Equals(_structureKey, structureKey, StringComparison.Ordinal)) + { + ReleaseAllHolds(); + Rebuild(actions); + _structureKey = structureKey; + } + foreach (var action in actions) + if (_rows.TryGetValue(action.ActionId ?? "", out var row)) + Apply(row, action); + if (_root != null) + _root.style.display = actions.Length == 0 ? DisplayStyle.None : DisplayStyle.Flex; + } + + public static IReadOnlyList SelectActions(EveInputCapabilityDocument? capability) + { + if (capability == null) return Array.Empty(); + var actions = (capability.Actions ?? Array.Empty()) + .Where(action => action != null && !string.IsNullOrWhiteSpace(action.ActionId)) + .ToDictionary(action => action.ActionId, StringComparer.Ordinal); + var selected = new List(); + var seen = new HashSet(StringComparer.Ordinal); + var profile = (capability.DefaultProfiles ?? Array.Empty()) + .FirstOrDefault(candidate => string.Equals(candidate.DeviceClass, "keyboard-mouse", StringComparison.Ordinal)) + ?? (capability.DefaultProfiles ?? Array.Empty()).FirstOrDefault(); + foreach (var binding in profile?.Bindings ?? Array.Empty()) + if (binding.ActionBar && actions.TryGetValue(binding.ActionId ?? "", out var action) && seen.Add(action.ActionId)) + selected.Add(action); + foreach (var action in actions.Values.OrderBy(candidate => candidate.ActionId, StringComparer.Ordinal)) + if (action.ActionBar && seen.Add(action.ActionId)) + selected.Add(action); + return selected; + } + + public void SubmitPerformed(string actionId) + { + var host = RequireHost(); + var action = EveUnityAdvertisedInputAction.Resolve(host.InputCapability, actionId); + if (action.IsViewDirection) + { + var view = host.ActiveCameraTransform; + if (view == null || view.forward.sqrMagnitude <= 0.000001f) return; + var direction = view.forward.normalized; + host.SubmitAdvertisedActionViewDirectionIntent( + RequireEntityId(host), actionId, direction.x, direction.y, direction.z); + return; + } + host.SubmitAdvertisedActionIntent(RequireEntityId(host), actionId); + } + + public void SubmitHold(string actionId, bool active) + { + var host = RequireHost(); + host.SubmitAdvertisedActionValueIntent(RequireEntityId(host), actionId, active ? 1f : 0f); + } + + public void SubmitScalar(string actionId, string value) + { + if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) || + double.IsNaN(parsed) || double.IsInfinity(parsed)) + throw new FormatException($"'{value}' is not a finite scalar value."); + var host = RequireHost(); + host.SubmitAdvertisedActionScalarIntent(RequireEntityId(host), actionId, parsed); + } + + private void Rebuild(IReadOnlyList actions) + { + _root!.Clear(); + _rows.Clear(); + _pressedHolds.Clear(); + foreach (var action in actions) + { + var row = BuildRow(action); + _rows[action.ActionId] = row; + _root.Add(row.Root); + } + } + + private void OnDisable() => ReleaseAllHolds(); + + private void ReleaseAllHolds() + { + foreach (var actionId in _pressedHolds.ToArray()) + { + try { SubmitHold(actionId, false); } + catch (InvalidOperationException) { } + } + _pressedHolds.Clear(); + } + + private ActionRow BuildRow(EveInputActionDocument action) + { + var root = new VisualElement { name = "eve-action-slot-" + SafeName(action.ActionId) }; + root.AddToClassList("eve-input-action-slot"); + root.style.width = 112; + root.style.minHeight = 72; + root.style.paddingLeft = root.style.paddingRight = 6; + root.style.paddingTop = root.style.paddingBottom = 5; + root.style.marginLeft = root.style.marginRight = 3; + root.style.backgroundColor = new Color(0.025f, 0.03f, 0.035f, 0.88f); + root.style.borderLeftWidth = root.style.borderRightWidth = 1; + root.style.borderTopWidth = root.style.borderBottomWidth = 1; + root.style.borderLeftColor = root.style.borderRightColor = new Color(0.35f, 0.42f, 0.46f, 0.8f); + root.style.borderTopColor = root.style.borderBottomColor = new Color(0.35f, 0.42f, 0.46f, 0.8f); + + var icon = new Image { name = "icon", scaleMode = ScaleMode.ScaleToFit }; + icon.AddToClassList("eve-input-action-icon"); + icon.style.height = 28; + root.Add(icon); + + var label = new Label(action.Label ?? action.ActionId) { name = "label" }; + label.AddToClassList("eve-input-action-label"); + label.style.fontSize = 9; + label.style.color = Color.white; + label.style.unityTextAlign = TextAnchor.MiddleCenter; + label.style.whiteSpace = WhiteSpace.Normal; + root.Add(label); + + TextField? scalar = null; + Button? trigger = null; + if (string.Equals(action.InputValue?.Model, EveUnityAdvertisedInputAction.ScalarValueModel, StringComparison.Ordinal)) + { + var controls = new VisualElement(); + controls.style.flexDirection = FlexDirection.Row; + scalar = new TextField { name = "scalar" }; + scalar.style.flexGrow = 1; + scalar.RegisterCallback(evt => + { + if (evt.keyCode != KeyCode.Return && evt.keyCode != KeyCode.KeypadEnter) return; + SubmitScalar(action.ActionId, scalar.value); + evt.StopPropagation(); + }); + controls.Add(scalar); + trigger = new Button(() => SubmitScalar(action.ActionId, scalar.value)) { text = "Set", name = "submit" }; + controls.Add(trigger); + root.Add(controls); + } + else + { + trigger = new Button { text = "Use", name = "trigger" }; + if (string.Equals(action.InputValue?.Model, EveUnityAdvertisedInputAction.ButtonHoldValueModel, StringComparison.Ordinal)) + { + trigger.RegisterCallback(evt => + { + if (!_pressedHolds.Add(action.ActionId)) return; + trigger.CapturePointer(evt.pointerId); + SubmitHold(action.ActionId, true); + }); + trigger.RegisterCallback(evt => + { + ReleaseHold(action.ActionId); + if (trigger.HasPointerCapture(evt.pointerId)) trigger.ReleasePointer(evt.pointerId); + }); + trigger.RegisterCallback(_ => ReleaseHold(action.ActionId)); + } + else + { + trigger.clicked += () => SubmitPerformed(action.ActionId); + } + root.Add(trigger); + } + return new ActionRow(root, icon, label, trigger, scalar); + } + + private void Apply(ActionRow row, EveInputActionDocument action) + { + row.Label.text = action.Label ?? action.ActionId; + var available = string.Equals(action.Availability, "available", StringComparison.OrdinalIgnoreCase); + row.Root.SetEnabled(available); + row.Root.style.opacity = available ? 1f : 0.45f; + if (row.Scalar != null && action.InputValue?.CurrentValue is double current && + row.Scalar.focusController?.focusedElement != row.Scalar) + { + row.Scalar.SetValueWithoutNotify(current.ToString("R", CultureInfo.InvariantCulture)); + row.Scalar.tooltip = string.IsNullOrWhiteSpace(action.InputValue.Unit) + ? "" : action.InputValue.Unit; + } + if (!string.IsNullOrWhiteSpace(action.IconRef) && _host?.NativeAssetProvider != null) + { + var texture = _host.NativeAssetProvider.ResolveAsset( + new EveUnityPlayableWorldAssetBinding(action.IconRef, "", "provider-asset-ref"), + typeof(Texture2D)) as Texture2D; + row.Icon.image = texture; + } + else row.Icon.image = null; + } + + private void ReleaseHold(string actionId) + { + if (!_pressedHolds.Remove(actionId)) return; + SubmitHold(actionId, false); + } + + private EveUnityPlayableWorldClientHost RequireHost() => + _host ?? throw new InvalidOperationException("The generic input action bar is not bound to a playable-world host."); + + private static string RequireEntityId(EveUnityPlayableWorldClientHost host) => + host.ActiveWorld?.PlayerEntityId ?? throw new InvalidOperationException("The playable world has no active player entity."); + + private void EnsureBuilt() + { + if (_root != null) + { + AttachToDocument(); + return; + } + _document = EveUnityUiDocumentRuntime.Ensure(gameObject); + _root = new VisualElement { name = "eve-input-action-bar" }; + _root.AddToClassList("eve-input-action-bar"); + _root.style.position = Position.Absolute; + _root.style.left = Length.Percent(50); + _root.style.bottom = 18; + _root.style.translate = new Translate(Length.Percent(-50), 0); + _root.style.flexDirection = FlexDirection.Row; + _root.style.alignItems = Align.FlexEnd; + AttachToDocument(); + } + + private void AttachToDocument() + { + var documentRoot = _document?.rootVisualElement; + if (documentRoot != null && _root != null && _root.parent == null) + documentRoot.Add(_root); + } + + private static string SafeName(string value) => + new string((value ?? "").Select(character => char.IsLetterOrDigit(character) ? character : '-').ToArray()); + + private sealed class ActionRow + { + public ActionRow(VisualElement root, Image icon, Label label, Button? trigger, TextField? scalar) + { + Root = root; + Icon = icon; + Label = label; + Trigger = trigger; + Scalar = scalar; + } + + public VisualElement Root { get; } + public Image Icon { get; } + public Label Label { get; } + public Button? Trigger { get; } + public TextField? Scalar { get; } + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityInputActionBar.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityInputActionBar.cs.meta new file mode 100644 index 0000000..36502a1 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityInputActionBar.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bb62601554a846f39e46a6969c10eff7 diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs index 942e9f9..19d1a63 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs @@ -1,4 +1,6 @@ using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.Rendering.Universal; #nullable enable @@ -6,11 +8,13 @@ namespace GameCult.Eve.UnityScene { public interface IEveUnityCameraRenderPolicySource { - bool TryGetCameraCullingMask(string viewId, out int cullingMask); + bool TryGetRenderChannelLayer(string channel, out int layer); } public sealed class EveUnityPlayableWorldCameraRig : MonoBehaviour { + private static EveUnityPlayableWorldCameraRig? _environmentOwner; + [SerializeField] private EveUnityPlayableWorldClientHost? host; [SerializeField] private Transform? cameraTransform; [SerializeField] private bool driveInLateUpdate = true; @@ -19,17 +23,49 @@ namespace GameCult.Eve.UnityScene [SerializeField] private float yawDegrees = 35f; [SerializeField] private float followDamping = 12f; private IEveUnityCameraRenderPolicySource? _renderPolicySource; + private EveUnityPlayableWorldClientHost? _cameraHost; + private bool _ownsAmbientEnvironment; + private AmbientMode _previousAmbientMode; + private Color _previousAmbientLight; + private float _previousAmbientIntensity; + private Material? _previousSkybox; + private DefaultReflectionMode _previousReflectionMode; + private Texture? _previousCustomReflection; + private float _previousReflectionIntensity; + private Camera? _environmentCamera; + private CameraClearFlags _previousCameraClearFlags; + private GameObject? _keyLightObject; + private GameObject? _postProcessVolumeObject; + private Volume? _postProcessVolume; + private UniversalAdditionalCameraData? _postProcessCameraData; + private EveUnityAdaptiveExposureRenderer? _adaptiveExposure; + private bool _createdPostProcessCameraData; + private bool _previousRenderPostProcessing; + private AntialiasingMode _previousAntialiasing; + private TemporalAA.Settings _previousTaaSettings; + private UniversalRenderPipelineAsset? _colorGradingPipelineAsset; + private ColorGradingMode _previousColorGradingMode; public EveUnityPlayableWorldClientHost? Host { get => host; - set => host = value; + set + { + if (ReferenceEquals(host, value)) return; + ReleaseRig(); + host = value; + } } public Transform? CameraTransform { get => cameraTransform; - set => cameraTransform = value; + set + { + if (ReferenceEquals(cameraTransform, value)) return; + ReleaseRig(); + cameraTransform = value; + } } public IEveUnityCameraRenderPolicySource? RenderPolicySource @@ -38,34 +74,102 @@ namespace GameCult.Eve.UnityScene set => _renderPolicySource = value; } + public void ReleaseRig() + { + ReleaseCamera(); + RestoreAmbientEnvironment(); + } + public bool ApplyRig(float deltaTime) { var resolvedHost = ResolveHost(); var activeWorld = resolvedHost?.ActiveWorld; if (resolvedHost == null || activeWorld == null) + { + ReleaseCamera(); + RestoreAmbientEnvironment(); return false; + } + if ((activeWorld.CameraRig == "planar.top-down-follow.v1" || + activeWorld.CameraRig == "perspective.entity-forward-follow.v1") && + !HasValidFramedFollowContract(activeWorld)) + { + ReleaseCamera(); + RestoreAmbientEnvironment(); + return false; + } if (!string.IsNullOrWhiteSpace(activeWorld.CameraRig) && + activeWorld.CameraRig != "planar.top-down-follow.v1" && + activeWorld.CameraRig != "perspective.entity-forward-follow.v1" && activeWorld.CameraRig != "arpg.orbital-follow.v1" && activeWorld.CameraRig != "third-person-orbit") { + ReleaseCamera(); return false; } var camera = cameraTransform != null ? cameraTransform : transform; - var targetMarker = FindEntityMarker(resolvedHost, activeWorld.CameraTargetEntityId); - if (targetMarker == null) + var targetEntityId = string.IsNullOrWhiteSpace(activeWorld.CameraTargetEntityId) + ? activeWorld.PlayerEntityId + : activeWorld.CameraTargetEntityId; + var player = FindEntity(resolvedHost, targetEntityId); + if (player == null) + { + ReleaseCamera(); return false; + } + if (!TryResolveEnvironment(resolvedHost, activeWorld, out var skybox, out var reflection, out var postProcessProfile)) + { + ReleaseRig(); + return false; + } + if (!resolvedHost.TryClaimWorldCamera(this, camera)) + { + RestoreAmbientEnvironment(); + return false; + } + _cameraHost = resolvedHost; + if (_environmentOwner != null && !ReferenceEquals(_environmentOwner, this)) + { + ReleaseCamera(); + return false; + } + _environmentOwner = this; var cameraComponent = camera.GetComponent(); - if (cameraComponent != null && - _renderPolicySource != null && - _renderPolicySource.TryGetCameraCullingMask(activeWorld.ViewId, out var cullingMask)) + if (postProcessProfile != null && cameraComponent == null) { + ReleaseRig(); + return false; + } + if (!ApplyColorGradingSpace(activeWorld)) + { + ReleaseRig(); + return false; + } + ApplyAmbientEnvironment(activeWorld, cameraComponent, skybox, reflection, postProcessProfile); + ApplyKeyLight(activeWorld); + if (cameraComponent != null && _renderPolicySource != null) + { + var cullingMask = cameraComponent.cullingMask; + foreach (var channel in activeWorld.ExcludedRenderChannels) + { + if (_renderPolicySource.TryGetRenderChannelLayer(channel, out var layer) && + layer >= 0 && layer < 32) + cullingMask &= ~(1 << layer); + } cameraComponent.cullingMask = cullingMask; } - var bounds = CalculateVisualBounds(targetMarker); - var target = bounds?.center ?? targetMarker.transform.position; + if (activeWorld.CameraRig == "planar.top-down-follow.v1") + return ApplyPlanarTopDown(activeWorld, camera, cameraComponent, player.position, deltaTime); + if (activeWorld.CameraRig == "perspective.entity-forward-follow.v1") + return ApplyEntityForwardFollow(activeWorld, resolvedHost, camera, cameraComponent, player, deltaTime); + + var bounds = CalculateVisualBounds( + player, + cameraComponent != null ? cameraComponent.cullingMask : -1); + var target = bounds?.center ?? player.position; var radius = bounds?.extents.magnitude ?? 0f; var verticalFov = cameraComponent != null ? cameraComponent.fieldOfView : 60f; var framingDistance = radius > 0f @@ -84,23 +188,524 @@ namespace GameCult.Eve.UnityScene return true; } - private static Bounds? CalculateVisualBounds(EveUnityPlayableWorldEntityMarker target) + private static bool ApplyPlanarTopDown( + EveUnityPlayableWorldProjection world, + Transform camera, + Camera? cameraComponent, + Vector3 target, + float deltaTime) { - var renderers = target.GetComponentsInChildren(includeInactive: false); - Bounds? bounds = null; - foreach (var visual in renderers) + var resolvedDistance = world.CameraDistance; + var verticalFov = world.CameraVerticalFieldOfViewDegrees; + if (cameraComponent != null) { - if (visual == null || !visual.enabled) - continue; - if (bounds.HasValue) + cameraComponent.orthographic = false; + cameraComponent.lensShift = Vector2.zero; + cameraComponent.fieldOfView = verticalFov; + cameraComponent.nearClipPlane = world.CameraNearClipPlane; + cameraComponent.farClipPlane = world.CameraFarClipPlane; + } + var aspect = cameraComponent != null ? cameraComponent.aspect : 16f / 9f; + var verticalHalfExtent = resolvedDistance * Mathf.Tan(verticalFov * 0.5f * Mathf.Deg2Rad); + var horizontalHalfExtent = verticalHalfExtent * Mathf.Max(0.01f, aspect); + var screenOffset = new Vector3( + (Mathf.Clamp01(world.CameraTargetScreenX) - 0.5f) * 2f * horizontalHalfExtent, + 0f, + (Mathf.Clamp01(world.CameraTargetScreenY) - 0.5f) * 2f * verticalHalfExtent); + var desiredPosition = target + (Vector3.up * resolvedDistance) - screenOffset; + var damping = Mathf.Max(0f, world.CameraPositionDamping); + var t = deltaTime <= 0f || damping <= 0f + ? 1f + : 1f - Mathf.Exp(-damping * deltaTime); + camera.position = Vector3.Lerp(camera.position, desiredPosition, t); + camera.rotation = Quaternion.LookRotation(Vector3.down, Vector3.forward); + + return true; + } + + private static bool ApplyEntityForwardFollow( + EveUnityPlayableWorldProjection world, + EveUnityPlayableWorldClientHost host, + Transform camera, + Camera? cameraComponent, + Transform target, + float deltaTime) + { + var resolvedDistance = world.CameraDistance; + var verticalFov = world.CameraVerticalFieldOfViewDegrees; + if (cameraComponent != null) + { + cameraComponent.orthographic = false; + cameraComponent.lensShift = Vector2.zero; + cameraComponent.fieldOfView = verticalFov; + cameraComponent.nearClipPlane = world.CameraNearClipPlane; + cameraComponent.farClipPlane = world.CameraFarClipPlane; + } + + var forward = target.forward; + forward.y = 0f; + if (forward.sqrMagnitude <= 0.0001f) + forward = Vector3.forward; + forward.Normalize(); + var rotation = Quaternion.LookRotation(forward, Vector3.up); + var aspect = cameraComponent != null ? cameraComponent.aspect : 16f / 9f; + var verticalHalfExtent = resolvedDistance * Mathf.Tan(verticalFov * 0.5f * Mathf.Deg2Rad); + var horizontalHalfExtent = verticalHalfExtent * Mathf.Max(0.01f, aspect); + var horizontalOffset = (Mathf.Clamp01(world.CameraTargetScreenX) - 0.5f) * 2f * horizontalHalfExtent; + var verticalOffset = (Mathf.Clamp01(world.CameraTargetScreenY) - 0.5f) * 2f * verticalHalfExtent; + if (string.Equals(world.CameraLookAt, "aim.convergence-point.v1", System.StringComparison.Ordinal)) + { + var aim = EveUnityAimPresentation.Find(host.ActiveProjection); + if (aim == null || !aim.TryResolveViewPoint(host, out var aimPoint)) return false; + var targetToAim = aimPoint - target.position; + var targetToAimDistanceSquared = targetToAim.sqrMagnitude; + var screenOffsetSquared = horizontalOffset * horizontalOffset + verticalOffset * verticalOffset; + if (targetToAimDistanceSquared <= screenOffsetSquared + 0.0001f) return false; + var targetToAimDirection = targetToAim.normalized; + var opticalDepthDelta = Mathf.Sqrt(targetToAimDistanceSquared - screenOffsetSquared); + var cameraLocalTargetToAim = new Vector3( + -horizontalOffset, + -verticalOffset, + opticalDepthDelta); + rotation = Quaternion.LookRotation(targetToAimDirection, Vector3.up) * + Quaternion.FromToRotation(cameraLocalTargetToAim.normalized, Vector3.forward); + } + var right = rotation * Vector3.right; + var up = rotation * Vector3.up; + var opticalForward = rotation * Vector3.forward; + var desiredPosition = target.position - + (opticalForward * resolvedDistance) - + (right * horizontalOffset) - + (up * verticalOffset); + var damping = Mathf.Max(0f, world.CameraPositionDamping); + var t = deltaTime <= 0f || damping <= 0f + ? 1f + : 1f - Mathf.Exp(-damping * deltaTime); + camera.position = Vector3.Lerp(camera.position, desiredPosition, t); + camera.rotation = rotation; + return true; + } + + private static bool HasValidFramedFollowContract(EveUnityPlayableWorldProjection world) => + world.CameraDistance > 0f && + world.CameraVerticalFieldOfViewDegrees > 0f && + world.CameraVerticalFieldOfViewDegrees < 180f && + world.CameraTargetScreenX >= 0f && world.CameraTargetScreenX <= 1f && + world.CameraTargetScreenY >= 0f && world.CameraTargetScreenY <= 1f && + world.CameraPositionDamping >= 0f && + world.CameraNearClipPlane > 0f && + world.CameraFarClipPlane > world.CameraNearClipPlane; + + private static bool TryResolveEnvironment( + EveUnityPlayableWorldClientHost host, + EveUnityPlayableWorldProjection world, + out Material? skybox, + out Cubemap? reflection, + out VolumeProfile? postProcessProfile) + { + skybox = null; + reflection = null; + postProcessProfile = null; + if (!IsFinite(world.AmbientLightR) || + !IsFinite(world.AmbientLightG) || + !IsFinite(world.AmbientLightB) || + !IsFinite(world.AmbientLightIntensity) || + !IsFinite(world.ReflectionIntensity) || + !IsFinite(world.KeyLightDirectionX) || + !IsFinite(world.KeyLightDirectionY) || + !IsFinite(world.KeyLightDirectionZ) || + !IsFinite(world.KeyLightColorR) || + !IsFinite(world.KeyLightColorG) || + !IsFinite(world.KeyLightColorB) || + !IsFinite(world.KeyLightIntensity) || + !IsFinite(world.TemporalHistoryBlend) || + !IsFinite(world.TemporalJitterScale) || + !IsFinite(world.TemporalSharpening) || + (!string.IsNullOrWhiteSpace(world.CameraReconstruction) && + !string.Equals(world.CameraReconstruction, "temporal-reprojection.v1", System.StringComparison.Ordinal)) || + (!string.IsNullOrWhiteSpace(world.ColorGradingSpace) && + !string.Equals(world.ColorGradingSpace, "hdr-before-tonemap.v1", System.StringComparison.Ordinal)) || + (string.Equals(world.CameraReconstruction, "temporal-reprojection.v1", System.StringComparison.Ordinal) && + (world.TemporalHistoryBlend < 0f || world.TemporalHistoryBlend > 1f || + world.TemporalJitterScale < 0f || world.TemporalJitterScale > 1f || + world.TemporalSharpening < 0f || world.TemporalSharpening > 1f || + !IsTemporalQuality(world.TemporalQuality)))) + return false; + var assets = host.NativeAssetProvider; + if (!string.IsNullOrWhiteSpace(world.SkyboxAssetRef)) + { + skybox = assets?.ResolveAsset( + new EveUnityPlayableWorldAssetBinding( + world.SkyboxAssetRef, + "", + "provider-asset-ref"), + typeof(Material)) as Material; + if (skybox == null || skybox.shader == null || !skybox.shader.isSupported) + return false; + } + if (!string.IsNullOrWhiteSpace(world.ReflectionAssetRef)) + { + reflection = assets?.ResolveAsset( + new EveUnityPlayableWorldAssetBinding( + world.ReflectionAssetRef, + "", + "provider-asset-ref"), + typeof(Cubemap)) as Cubemap; + if (reflection == null) + return false; + } + if (!string.IsNullOrWhiteSpace(world.PostProcessProfileAssetRef)) + { + postProcessProfile = assets?.ResolveAsset( + new EveUnityPlayableWorldAssetBinding( + world.PostProcessProfileAssetRef, + "", + "provider-asset-ref"), + typeof(VolumeProfile)) as VolumeProfile; + if (postProcessProfile == null) + return false; + } + return true; + } + + private bool ApplyColorGradingSpace(EveUnityPlayableWorldProjection world) + { + if (string.IsNullOrWhiteSpace(world.ColorGradingSpace)) + { + RestoreColorGradingSpace(); + return true; + } + var asset = GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset; + if (asset == null || + !string.Equals(world.ColorGradingSpace, "hdr-before-tonemap.v1", System.StringComparison.Ordinal)) + return false; + if (!ReferenceEquals(_colorGradingPipelineAsset, asset)) + { + RestoreColorGradingSpace(); + _colorGradingPipelineAsset = asset; + _previousColorGradingMode = asset.colorGradingMode; + } + asset.colorGradingMode = ColorGradingMode.HighDynamicRange; + return true; + } + + private void RestoreColorGradingSpace() + { + if (_colorGradingPipelineAsset == null) return; + _colorGradingPipelineAsset.colorGradingMode = _previousColorGradingMode; + _colorGradingPipelineAsset = null; + } + + private void ApplyKeyLight(EveUnityPlayableWorldProjection world) + { + var direction = new Vector3( + world.KeyLightDirectionX, + world.KeyLightDirectionY, + world.KeyLightDirectionZ); + if (world.KeyLightIntensity <= 0f || direction.sqrMagnitude <= 0.000001f) + { + ReleaseKeyLight(); + return; + } + + if (_keyLightObject == null) + { + _keyLightObject = new GameObject("Eve World Key Light"); + _keyLightObject.transform.SetParent(transform, worldPositionStays: false); + var created = _keyLightObject.AddComponent(); + created.type = LightType.Directional; + created.shadows = LightShadows.None; + } + + var light = _keyLightObject.GetComponent(); + light.color = new Color( + Mathf.Max(0f, world.KeyLightColorR), + Mathf.Max(0f, world.KeyLightColorG), + Mathf.Max(0f, world.KeyLightColorB), + 1f); + light.intensity = Mathf.Max(0f, world.KeyLightIntensity); + var normalized = direction.normalized; + var up = Mathf.Abs(Vector3.Dot(normalized, Vector3.up)) > 0.999f + ? Vector3.forward + : Vector3.up; + _keyLightObject.transform.rotation = Quaternion.LookRotation(normalized, up); + _keyLightObject.SetActive(true); + } + + private void ReleaseKeyLight() + { + if (_keyLightObject == null) + return; + _keyLightObject.SetActive(false); + if (Application.isPlaying) + Destroy(_keyLightObject); + else + DestroyImmediate(_keyLightObject); + _keyLightObject = null; + } + + private static bool IsFinite(float value) => !float.IsNaN(value) && !float.IsInfinity(value); + + private void ApplyAmbientEnvironment( + EveUnityPlayableWorldProjection world, + Camera? camera, + Material? skybox, + Cubemap? reflection, + VolumeProfile? postProcessProfile) + { + var environmentChanged = !ReferenceEquals(RenderSettings.skybox, skybox); + if (!_ownsAmbientEnvironment) + { + _previousAmbientMode = RenderSettings.ambientMode; + _previousAmbientLight = RenderSettings.ambientLight; + _previousAmbientIntensity = RenderSettings.ambientIntensity; + _previousSkybox = RenderSettings.skybox; + _previousReflectionMode = RenderSettings.defaultReflectionMode; + _previousCustomReflection = RenderSettings.customReflectionTexture; + _previousReflectionIntensity = RenderSettings.reflectionIntensity; + _environmentCamera = camera; + if (camera != null) + _previousCameraClearFlags = camera.clearFlags; + _ownsAmbientEnvironment = true; + } + if (skybox != null) + { + RenderSettings.skybox = skybox; + RenderSettings.ambientMode = AmbientMode.Skybox; + RenderSettings.ambientIntensity = Mathf.Max(0f, world.AmbientLightIntensity); + if (camera != null) + camera.clearFlags = CameraClearFlags.Skybox; + } + else + { + RenderSettings.skybox = _previousSkybox; + RenderSettings.ambientMode = AmbientMode.Flat; + RenderSettings.ambientIntensity = _previousAmbientIntensity; + RenderSettings.ambientLight = new Color( + Mathf.Max(0f, world.AmbientLightR * world.AmbientLightIntensity), + Mathf.Max(0f, world.AmbientLightG * world.AmbientLightIntensity), + Mathf.Max(0f, world.AmbientLightB * world.AmbientLightIntensity), + 1f); + if (_environmentCamera != null) + _environmentCamera.clearFlags = _previousCameraClearFlags; + } + if (reflection != null) + { + RenderSettings.defaultReflectionMode = DefaultReflectionMode.Custom; + RenderSettings.customReflectionTexture = reflection; + RenderSettings.reflectionIntensity = Mathf.Max(0f, world.ReflectionIntensity); + } + else + { + RenderSettings.defaultReflectionMode = _previousReflectionMode; + RenderSettings.customReflectionTexture = _previousCustomReflection; + RenderSettings.reflectionIntensity = _previousReflectionIntensity; + } + if (environmentChanged) + DynamicGI.UpdateEnvironment(); + ApplyCameraRendering(camera, postProcessProfile, world); + } + + private void ApplyCameraRendering( + Camera? camera, + VolumeProfile? profile, + EveUnityPlayableWorldProjection world) + { + var usesTemporalReprojection = string.Equals( + world.CameraReconstruction, + "temporal-reprojection.v1", + System.StringComparison.Ordinal); + var usesAdaptiveExposure = string.Equals( + world.ExposureMode, + "histogram.v1", + System.StringComparison.Ordinal); + if (profile == null && !usesTemporalReprojection && !usesAdaptiveExposure) + { + RestorePostProcess(); + return; + } + if (camera == null) + return; + if (_postProcessCameraData != null && !ReferenceEquals(_postProcessCameraData.gameObject, camera.gameObject)) + RestorePostProcess(); + if (_postProcessCameraData == null) + { + _postProcessCameraData = camera.GetComponent(); + _createdPostProcessCameraData = _postProcessCameraData == null; + if (_postProcessCameraData == null) + _postProcessCameraData = camera.gameObject.AddComponent(); + _previousRenderPostProcessing = _postProcessCameraData.renderPostProcessing; + _previousAntialiasing = _postProcessCameraData.antialiasing; + _previousTaaSettings = _postProcessCameraData.taaSettings; + } + _postProcessCameraData.renderPostProcessing = profile != null || _previousRenderPostProcessing; + if (usesTemporalReprojection) + { + _postProcessCameraData.antialiasing = AntialiasingMode.TemporalAntiAliasing; + ref var settings = ref _postProcessCameraData.taaSettings; + settings = TemporalAA.Settings.Create(); + settings.quality = ParseTemporalQuality(world.TemporalQuality); + settings.baseBlendFactor = world.TemporalHistoryBlend; + settings.jitterScale = world.TemporalJitterScale; + settings.contrastAdaptiveSharpening = world.TemporalSharpening; + } + else + { + _postProcessCameraData.antialiasing = _previousAntialiasing; + _postProcessCameraData.taaSettings = _previousTaaSettings; + } + if (profile != null) + { + if (_postProcessVolumeObject == null) { - var combined = bounds.Value; - combined.Encapsulate(visual.bounds); - bounds = combined; + _postProcessVolumeObject = new GameObject("Eve World Post Process"); + _postProcessVolumeObject.transform.SetParent(transform, worldPositionStays: false); + _postProcessVolume = _postProcessVolumeObject.AddComponent(); + _postProcessVolume.isGlobal = true; + _postProcessVolume.priority = 1000f; + _postProcessVolume.weight = 1f; + } + _postProcessVolume!.sharedProfile = profile; + _postProcessVolumeObject.SetActive(true); + } + else if (_postProcessVolumeObject != null) + { + _postProcessVolumeObject.SetActive(false); + } + + if (usesAdaptiveExposure) + { + if (_adaptiveExposure == null) + _adaptiveExposure = camera.gameObject.AddComponent(); + _adaptiveExposure.Configure( + world.ExposureLowPercent, + world.ExposureHighPercent, + world.ExposureMinimumEv, + world.ExposureMaximumEv, + world.ExposureKeyValue, + world.ExposureAdaptation, + world.ExposureSpeedUp, + world.ExposureSpeedDown); + } + else + { + ReleaseAdaptiveExposure(); + } + } + + private static bool IsTemporalQuality(string quality) => + quality == "very-low" || quality == "low" || quality == "medium" || + quality == "high" || quality == "very-high"; + + private static TemporalAAQuality ParseTemporalQuality(string quality) + { + switch (quality) + { + case "very-low": return TemporalAAQuality.VeryLow; + case "low": return TemporalAAQuality.Low; + case "medium": return TemporalAAQuality.Medium; + case "very-high": return TemporalAAQuality.VeryHigh; + default: return TemporalAAQuality.High; + } + } + + private void RestorePostProcess() + { + RestoreColorGradingSpace(); + ReleaseAdaptiveExposure(); + if (_postProcessCameraData != null) + { + if (_createdPostProcessCameraData) + { + if (Application.isPlaying) + Destroy(_postProcessCameraData); + else + DestroyImmediate(_postProcessCameraData); } else { - bounds = visual.bounds; + _postProcessCameraData.renderPostProcessing = _previousRenderPostProcessing; + _postProcessCameraData.antialiasing = _previousAntialiasing; + _postProcessCameraData.taaSettings = _previousTaaSettings; + } + } + _postProcessCameraData = null; + _createdPostProcessCameraData = false; + if (_postProcessVolumeObject != null) + { + if (Application.isPlaying) + Destroy(_postProcessVolumeObject); + else + DestroyImmediate(_postProcessVolumeObject); + } + _postProcessVolumeObject = null; + _postProcessVolume = null; + } + + private void ReleaseAdaptiveExposure() + { + if (_adaptiveExposure == null) return; + if (Application.isPlaying) + Destroy(_adaptiveExposure); + else + DestroyImmediate(_adaptiveExposure); + _adaptiveExposure = null; + } + + private void RestoreAmbientEnvironment() + { + ReleaseKeyLight(); + RestorePostProcess(); + if (!_ownsAmbientEnvironment) return; + var environmentChanged = !ReferenceEquals(RenderSettings.skybox, _previousSkybox); + RenderSettings.ambientMode = _previousAmbientMode; + RenderSettings.ambientLight = _previousAmbientLight; + RenderSettings.ambientIntensity = _previousAmbientIntensity; + RenderSettings.skybox = _previousSkybox; + RenderSettings.defaultReflectionMode = _previousReflectionMode; + RenderSettings.customReflectionTexture = _previousCustomReflection; + RenderSettings.reflectionIntensity = _previousReflectionIntensity; + if (_environmentCamera != null) + _environmentCamera.clearFlags = _previousCameraClearFlags; + _environmentCamera = null; + _previousSkybox = null; + _previousCustomReflection = null; + _ownsAmbientEnvironment = false; + if (ReferenceEquals(_environmentOwner, this)) + _environmentOwner = null; + if (environmentChanged) + DynamicGI.UpdateEnvironment(); + } + + private void ReleaseCamera() + { + _cameraHost?.ReleaseWorldCamera(this); + _cameraHost = null; + } + + private static Bounds? CalculateVisualBounds(Transform player, int cullingMask) + { + Bounds? bounds = null; + foreach (var root in new[] { player }) + { + foreach (var visual in root.GetComponentsInChildren(includeInactive: false)) + { + if (visual == null || !visual.enabled || + (cullingMask & (1 << visual.gameObject.layer)) == 0) + continue; + if (visual is LineRenderer || visual is TrailRenderer || + string.Equals(visual.GetType().Name, "ParticleSystemRenderer", System.StringComparison.Ordinal)) + continue; + if (bounds.HasValue) + { + var combined = bounds.Value; + combined.Encapsulate(visual.bounds); + bounds = combined; + } + else + { + bounds = visual.bounds; + } } } return bounds; @@ -112,6 +717,10 @@ namespace GameCult.Eve.UnityScene ApplyRig(Time.deltaTime); } + private void OnDisable() => ReleaseRig(); + + private void OnDestroy() => ReleaseRig(); + private EveUnityPlayableWorldClientHost? ResolveHost() { if (host != null) @@ -121,18 +730,23 @@ namespace GameCult.Eve.UnityScene return host; } - private static EveUnityPlayableWorldEntityMarker? FindEntityMarker( + private static Transform? FindEntity( EveUnityPlayableWorldClientHost host, - string entityId) + string playerEntityId) { - if (string.IsNullOrWhiteSpace(entityId)) + if (string.IsNullOrWhiteSpace(playerEntityId)) return null; - var markers = host.SceneRoot.GetComponentsInChildren(includeInactive: true); + if (host.PresentedEntities?.CurrentGeneration != null) + return host.PresentedEntities.TryGetByEntityId(playerEntityId, out var presented) + ? presented.Transform + : null; + + var markers = host.SceneRoot.GetComponentsInChildren(); foreach (var marker in markers) { - if (marker != null && marker.EntityId == entityId) - return marker; + if (marker != null && marker.EntityId == playerEntityId) + return marker.transform; } return null; diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs index fcd0924..ea39b34 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using UnityEngine; #nullable enable @@ -29,13 +30,378 @@ namespace GameCult.Eve.UnityScene public EveUnityPlayableWorldPresentation? LastPresentation { get; private set; } + public EveUnitySceneNavigationFailure? LastNavigationFailure { get; private set; } + + public event Action? NavigationFailed; + + private IEveUnitySceneCommandReceiptSource? _navigationReceiptSource; + private bool _navigationInProgress; + private StagedPresentation? _ownedPresentation; + public void ConfigureProvider(MonoBehaviour providerComponent) { + UnbindNavigation(); provider = providerComponent != null ? providerComponent : throw new ArgumentNullException(nameof(providerComponent)); providerSurfaceDocuments = provider; commandSink = provider; assetManifestDocuments = provider; receiptSource = provider; + BindNavigation(); + } + + private void OnDestroy() + { + UnbindNavigation(); + _ownedPresentation?.Dispose(); + _ownedPresentation = null; + } + + private void BindNavigation() + { + if (provider is not IEveUnityNavigableProvider || provider is not IEveUnitySceneCommandReceiptSource source) + return; + _navigationReceiptSource = source; + source.ReceiptAvailable += OnReceiptAvailable; + } + + private void UnbindNavigation() + { + if (_navigationReceiptSource != null) + _navigationReceiptSource.ReceiptAvailable -= OnReceiptAvailable; + _navigationReceiptSource = null; + } + + private void OnReceiptAvailable(EveUnitySceneCommandReceipt receipt) + { + if (_navigationInProgress || receipt.Navigation == null) + return; + if (!string.Equals(receipt.State, "accepted", StringComparison.OrdinalIgnoreCase) && + !string.Equals(receipt.State, "reconciled", StringComparison.OrdinalIgnoreCase)) + return; + StartCoroutine(NavigateAsync(receipt.Navigation)); + } + + private IEnumerator NavigateAsync(EveUnitySceneNavigationTarget target) + { + if (provider is not IEveUnityNavigableProvider navigable) + yield break; + _navigationInProgress = true; + var navigation = navigable.NavigateAsync(target); + StagedPresentation? staged = null; + PresentationSwap? swap = null; + Transform? previousRoot = null; + var previousRootWasActive = false; + while (!navigation.IsCompleted) + yield return null; + try + { + navigation.GetAwaiter().GetResult(); + staged = PrepareStagedPresentation(); + previousRoot = sceneRoot; + previousRootWasActive = previousRoot == null || previousRoot.gameObject.activeSelf; + if (previousRoot != null && !ReferenceEquals(previousRoot, transform) && + !ReferenceEquals(previousRoot, staged.SceneRoot)) + previousRoot.gameObject.SetActive(false); + navigable.CommitNavigation(); + swap = ActivateStagedPresentation(staged, previousRootWasActive); + navigable.FinalizeNavigation(); + swap.Complete(); + swap = null; + staged = null; + LastNavigationFailure = null; + } + catch (Exception error) + { + var providerRestored = false; + try + { + navigable.RollbackNavigation(); + providerRestored = true; + } + catch (Exception rollbackError) + { + Debug.LogError($"Eve provider route rollback failed; the previous presentation will remain inactive. {rollbackError}"); + } + if (providerRestored) + { + try + { + if (swap != null) + swap.Rollback(); + else if (previousRoot != null) + previousRoot.gameObject.SetActive(previousRootWasActive); + } + catch (Exception rollbackError) + { + Debug.LogError($"Eve previous presentation rollback failed after provider restoration. {rollbackError}"); + } + } + try + { + staged?.Dispose(); + } + catch (Exception cleanupError) + { + Debug.LogWarning($"Eve rejected presentation cleanup failed. {cleanupError}"); + } + var failure = new EveUnitySceneNavigationFailure(target, error.Message, DateTimeOffset.UtcNow); + LastNavigationFailure = failure; + NavigationFailed?.Invoke(failure); + Debug.LogError($"Eve provider navigation failed; the current surface remains mounted. {error}"); + } + finally + { + _navigationInProgress = false; + } + } + + private StagedPresentation PrepareStagedPresentation() + { + var resolvedProvider = provider; + var surfaceDocuments = ResolveBehaviour( + providerSurfaceDocuments, + resolvedProvider, + nameof(providerSurfaceDocuments)); + var resolvedCommandSink = ResolveBehaviour( + commandSink, + resolvedProvider, + nameof(commandSink)); + var resolvedAssetDocuments = ResolveOptionalBehaviour( + assetManifestDocuments, + resolvedProvider); + var resolvedReceiptSource = ResolveOptionalBehaviour( + receiptSource, + resolvedProvider); + var resolvedFallbackAssetProvider = ResolveOptionalBehaviour( + fallbackAssetProvider, + resolvedProvider); + + var stagingObject = new GameObject("Eve Unity Candidate Presentation"); + stagingObject.SetActive(false); + stagingObject.transform.SetParent(transform, false); + var stagingRootObject = new GameObject("Eve Unity Candidate Scene Root"); + stagingRootObject.transform.SetParent(stagingObject.transform, false); + var stagingHost = stagingObject.AddComponent(); + stagingHost.ConnectOnEnable = false; + try + { + stagingHost.Configure( + stagingRootObject.transform, + surfaceDocuments, + resolvedCommandSink, + resolvedAssetDocuments, + resolvedReceiptSource, + resolvedFallbackAssetProvider); + var presentation = stagingHost.Connect(); + return new StagedPresentation(stagingObject, stagingRootObject.transform, stagingHost, presentation); + } + catch + { + stagingHost.Disconnect(); + DestroyObject(stagingObject); + throw; + } + } + + private PresentationSwap ActivateStagedPresentation( + StagedPresentation staged, + bool previousRootWasActive) + { + var previousHost = host; + var previousRoot = sceneRoot; + var previousPresentation = LastPresentation; + var input = GetComponent(); + var previousInputHost = input?.Host; + var previousInputCamera = input?.CameraTransform; + var rig = GetComponent(); + var previousRigHost = rig?.Host; + var previousRigCamera = rig?.CameraTransform; + var previousRigPolicy = rig?.RenderPolicySource; + + try + { + staged.GameObject.SetActive(true); + if (attachInputDriver) + ConfigureInputDriver(staged.Host); + if (attachCameraRig) + ConfigureCameraRig(staged.Host); + host = staged.Host; + sceneRoot = staged.SceneRoot; + LastPresentation = staged.Presentation; + return new PresentationSwap( + this, + staged, + _ownedPresentation, + previousHost, + previousRoot, + previousPresentation, + previousRootWasActive, + input, + previousInputHost, + previousInputCamera, + rig, + previousRigHost, + previousRigCamera, + previousRigPolicy); + } + catch + { + if (input != null) + { + input.Host = previousInputHost; + input.CameraTransform = previousInputCamera; + } + if (rig != null) + { + rig.Host = previousRigHost; + rig.CameraTransform = previousRigCamera; + rig.RenderPolicySource = previousRigPolicy; + } + host = previousHost; + sceneRoot = previousRoot; + LastPresentation = previousPresentation; + throw; + } + } + + private static void DestroyObject(UnityEngine.Object value) + { + if (Application.isPlaying) + Destroy(value); + else + DestroyImmediate(value); + } + + private sealed class StagedPresentation : IDisposable + { + public StagedPresentation( + GameObject gameObject, + Transform sceneRoot, + EveUnityPlayableWorldClientHost host, + EveUnityPlayableWorldPresentation presentation) + { + GameObject = gameObject; + SceneRoot = sceneRoot; + Host = host; + Presentation = presentation; + } + + public GameObject GameObject { get; } + public Transform SceneRoot { get; } + public EveUnityPlayableWorldClientHost Host { get; } + public EveUnityPlayableWorldPresentation Presentation { get; } + + public void Dispose() + { + try + { + Host.Disconnect(); + } + catch (Exception error) + { + Debug.LogWarning($"Eve presentation disconnect failed during cleanup: {error}"); + } + finally + { + DestroyObject(GameObject); + } + } + } + + private sealed class PresentationSwap + { + private readonly EveUnityPlayableWorldClientBootstrap _owner; + private readonly StagedPresentation _candidate; + private readonly StagedPresentation? _previousOwnedPresentation; + private readonly EveUnityPlayableWorldClientHost? _previousHost; + private readonly Transform? _previousRoot; + private readonly EveUnityPlayableWorldPresentation? _previousPresentation; + private readonly bool _previousRootWasActive; + private readonly EveUnityPlayableWorldInputDriver? _input; + private readonly EveUnityPlayableWorldClientHost? _previousInputHost; + private readonly Transform? _previousInputCamera; + private readonly EveUnityPlayableWorldCameraRig? _rig; + private readonly EveUnityPlayableWorldClientHost? _previousRigHost; + private readonly Transform? _previousRigCamera; + private readonly IEveUnityCameraRenderPolicySource? _previousRigPolicy; + private bool _settled; + + public PresentationSwap( + EveUnityPlayableWorldClientBootstrap owner, + StagedPresentation candidate, + StagedPresentation? previousOwnedPresentation, + EveUnityPlayableWorldClientHost? previousHost, + Transform? previousRoot, + EveUnityPlayableWorldPresentation? previousPresentation, + bool previousRootWasActive, + EveUnityPlayableWorldInputDriver? input, + EveUnityPlayableWorldClientHost? previousInputHost, + Transform? previousInputCamera, + EveUnityPlayableWorldCameraRig? rig, + EveUnityPlayableWorldClientHost? previousRigHost, + Transform? previousRigCamera, + IEveUnityCameraRenderPolicySource? previousRigPolicy) + { + _owner = owner; + _candidate = candidate; + _previousOwnedPresentation = previousOwnedPresentation; + _previousHost = previousHost; + _previousRoot = previousRoot; + _previousPresentation = previousPresentation; + _previousRootWasActive = previousRootWasActive; + _input = input; + _previousInputHost = previousInputHost; + _previousInputCamera = previousInputCamera; + _rig = rig; + _previousRigHost = previousRigHost; + _previousRigCamera = previousRigCamera; + _previousRigPolicy = previousRigPolicy; + } + + public void Complete() + { + if (_settled) return; + _settled = true; + _owner._ownedPresentation = _candidate; + if (_previousOwnedPresentation != null && + !ReferenceEquals(_previousOwnedPresentation, _candidate)) + { + _previousOwnedPresentation.Dispose(); + return; + } + if (_previousHost == null || ReferenceEquals(_previousHost, _candidate.Host)) return; + try + { + _previousHost.Disconnect(); + } + catch (Exception error) + { + Debug.LogWarning($"Eve previous presentation disconnect failed after navigation commit: {error}"); + } + } + + public void Rollback() + { + if (_settled) return; + _settled = true; + _owner.host = _previousHost; + _owner.sceneRoot = _previousRoot; + _owner.LastPresentation = _previousPresentation; + if (_input != null) + { + _input.Host = _previousInputHost; + _input.CameraTransform = _previousInputCamera; + } + if (_rig != null) + { + _rig.Host = _previousRigHost; + _rig.CameraTransform = _previousRigCamera; + _rig.RenderPolicySource = _previousRigPolicy; + } + if (_previousRoot != null) + _previousRoot.gameObject.SetActive(_previousRootWasActive); + _candidate.GameObject.SetActive(false); + } } public EveUnityPlayableWorldPresentation Mount() @@ -191,4 +557,21 @@ namespace GameCult.Eve.UnityScene return null; } } + + public sealed class EveUnitySceneNavigationFailure + { + public EveUnitySceneNavigationFailure( + EveUnitySceneNavigationTarget target, + string diagnostic, + DateTimeOffset failedAtUtc) + { + Target = target; + Diagnostic = diagnostic; + FailedAtUtc = failedAtUtc; + } + + public EveUnitySceneNavigationTarget Target { get; } + public string Diagnostic { get; } + public DateTimeOffset FailedAtUtc { get; } + } } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs index 2dfe724..de42f6d 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using GameCult.Eve.Surface; +using GameCult.Eve.UnityScene.Fields; using UnityEngine; #nullable enable @@ -21,6 +22,7 @@ namespace GameCult.Eve.UnityScene [SerializeField] private bool renderShotTrajectories = true; private float _nextRefreshAt; + private object? _cameraOwner; public EveUnityPlayableWorldRuntime? Runtime { get; private set; } @@ -31,8 +33,16 @@ namespace GameCult.Eve.UnityScene public EveUnityPlayableWorldPresentation? LastPresentation => Runtime?.LastPresentation; public EveUnitySceneCommandReceipt? LastReceipt => Runtime?.LastReceipt; + public IEveUnityPresentedEntityRegistry? PresentedEntities => Runtime?.PresentedEntities; + + public EveInputCapabilityDocument? InputCapability => + (providerSurfaceDocuments as IEveUnityInputCapabilitySource)?.CurrentInputCapability; public long ActiveVersion => Runtime?.ActiveVersion ?? 0; + public int ConnectionEpoch { get; private set; } + public Transform? ActiveCameraTransform { get; private set; } + public IEveUnityNativeAssetProvider? NativeAssetProvider => + Runtime?.GameObjectAssetProvider as IEveUnityNativeAssetProvider; public event Action? FeedbackAvailable; public event Action? ShotAvailable; @@ -88,17 +98,57 @@ namespace GameCult.Eve.UnityScene var feedbackEffects = GetComponent(); if (feedbackEffects == null) feedbackEffects = gameObject.AddComponent(); feedbackEffects.Bind(this, Runtime.GameObjectAssetProvider); + var combatPresentation = GetComponent(); + if (combatPresentation == null) combatPresentation = gameObject.AddComponent(); + combatPresentation.Bind(this); + var aimPresentation = GetComponent(); + if (aimPresentation == null) aimPresentation = gameObject.AddComponent(); + aimPresentation.Bind(this); + var beamPresentation = GetComponent(); + if (beamPresentation == null) beamPresentation = gameObject.AddComponent(); + beamPresentation.Bind(this, Runtime.GameObjectAssetProvider); var thermal = GetComponent(); var thermalHud = GetComponent(); if (thermalHud == null) thermalHud = gameObject.AddComponent(); if (thermal == null) thermal = gameObject.AddComponent(); thermal.Bind(this, Runtime.GameObjectAssetProvider as IEveUnityNativeAssetProvider); + var fieldVolume = GetComponent(); + if (fieldVolume == null) fieldVolume = gameObject.AddComponent(); + fieldVolume.Bind(this, providerSurfaceDocuments as IEveUnityFieldsSplatsDocumentSource); + var fieldParticles = GetComponent(); + if (fieldParticles == null) fieldParticles = gameObject.AddComponent(); + fieldParticles.Bind(this, providerSurfaceDocuments as IEveUnityFieldsSplatsDocumentSource); + var actionBar = GetComponent(); + if (actionBar == null) actionBar = gameObject.AddComponent(); + actionBar.Bind(this); + var uiOverlay = GetComponent(); + if (uiOverlay == null) uiOverlay = gameObject.AddComponent(); + uiOverlay.Bind(providerSurfaceDocuments!, commandSink!); var presentation = Runtime.Connect(); + ConnectionEpoch++; _nextRefreshAt = Time.unscaledTime + Math.Max(0.01f, refreshIntervalSeconds); return presentation; } + internal bool TryClaimWorldCamera(object owner, Transform camera) + { + if (owner == null) throw new ArgumentNullException(nameof(owner)); + if (camera == null) throw new ArgumentNullException(nameof(camera)); + if (_cameraOwner != null && !ReferenceEquals(_cameraOwner, owner)) + return false; + _cameraOwner = owner; + ActiveCameraTransform = camera; + return true; + } + + internal void ReleaseWorldCamera(object owner) + { + if (!ReferenceEquals(_cameraOwner, owner)) return; + _cameraOwner = null; + ActiveCameraTransform = null; + } + public EveUnityPlayableWorldPresentation Refresh() { RefreshProviderSources(); @@ -128,6 +178,16 @@ namespace GameCult.Eve.UnityScene return RequireRuntime().SubmitMoveVectorIntent(entityId, directionX, directionY, scalarValue, issuedAt); } + public EveSurfaceCommandRequest SubmitLookDirectionIntent( + string entityId, + float directionX, + float directionY, + float directionZ, + DateTimeOffset? issuedAt = null) + { + return RequireRuntime().SubmitLookDirectionIntent(entityId, directionX, directionY, directionZ, issuedAt); + } + public EveSurfaceCommandRequest SubmitFocusIntent( string entityId, DateTimeOffset? issuedAt = null) @@ -151,8 +211,63 @@ namespace GameCult.Eve.UnityScene return RequireRuntime().SubmitActionIntent(entityId, actionId, issuedAt); } + public EveSurfaceCommandRequest SubmitAdvertisedActionIntent( + string entityId, + string actionId, + DateTimeOffset? issuedAt = null) + { + var action = EveUnityAdvertisedInputAction.Resolve(InputCapability, actionId); + return RequireRuntime().SubmitCommandIntent(action.Operation, action.BuildPayload(entityId), issuedAt); + } + + public EveSurfaceCommandRequest SubmitAdvertisedActionValueIntent( + string entityId, + string actionId, + float inputValue, + DateTimeOffset? issuedAt = null) + { + var action = EveUnityAdvertisedInputAction.Resolve( + InputCapability, + actionId, + requireAvailable: inputValue != 0f); + return RequireRuntime().SubmitCommandIntent( + action.Operation, + action.BuildPayload(entityId, inputValue), + issuedAt); + } + + public EveSurfaceCommandRequest SubmitAdvertisedActionViewDirectionIntent( + string entityId, + string actionId, + float directionX, + float directionY, + float directionZ, + DateTimeOffset? issuedAt = null) + { + var action = EveUnityAdvertisedInputAction.Resolve(InputCapability, actionId); + return RequireRuntime().SubmitCommandIntent( + action.Operation, + action.BuildViewDirectionPayload(entityId, directionX, directionY, directionZ), + issuedAt); + } + + public EveSurfaceCommandRequest SubmitAdvertisedActionScalarIntent( + string entityId, + string actionId, + double value, + DateTimeOffset? issuedAt = null) + { + var action = EveUnityAdvertisedInputAction.Resolve(InputCapability, actionId); + return RequireRuntime().SubmitCommandIntent( + action.Operation, + action.BuildScalarPayload(entityId, value), + issuedAt); + } + public void Disconnect() { + if (_cameraOwner is EveUnityPlayableWorldCameraRig rig) + rig.ReleaseRig(); if (Runtime != null) { Runtime.FeedbackAvailable -= OnFeedbackAvailable; @@ -160,6 +275,8 @@ namespace GameCult.Eve.UnityScene } Runtime?.Dispose(); Runtime = null; + _cameraOwner = null; + ActiveCameraTransform = null; } private void OnFeedbackAvailable(EveUnityFeedbackEvent value) => FeedbackAvailable?.Invoke(value); diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs index 2ba9bef..1b2e8ca 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using GameCult.Eve.Surface; using UnityEngine; @@ -6,6 +8,11 @@ using UnityEngine; namespace GameCult.Eve.UnityScene { + public interface IEveUnityInputCapabilitySource + { + EveInputCapabilityDocument CurrentInputCapability { get; } + } + public sealed class EveUnityPlayableWorldInputDriver : MonoBehaviour { [SerializeField] private EveUnityPlayableWorldClientHost? host; @@ -14,17 +21,35 @@ namespace GameCult.Eve.UnityScene [SerializeField] private string horizontalAxis = "Horizontal"; [SerializeField] private string verticalAxis = "Vertical"; [SerializeField] private string actionButton = "Fire1"; + [SerializeField] private string lookHorizontalAxis = "Mouse X"; + [SerializeField] private float defaultLookSensitivityRadians = 0.001f; [SerializeField] private float deadZone = 0.01f; [SerializeField] private float commandIntervalSeconds = 0.05f; [SerializeField] private bool submitZeroOnRelease = true; private float _nextCommandAt; private bool _wasMoving; + private float _lookYawRadians; + private bool _hasLookYaw; + private float _pendingLookHorizontal; + private string _lookOwnerKey = ""; + private readonly Dictionary _heldBindings = + new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _performedBindings = + new Dictionary(StringComparer.Ordinal); + + public Func? DigitalControlReader { get; set; } public EveUnityPlayableWorldClientHost? Host { get => host; - set => host = value; + set + { + if (ReferenceEquals(host, value)) return; + ReleaseHeldActions(); + host = value; + ResetLookState(); + } } public Transform? CameraTransform @@ -39,7 +64,7 @@ namespace GameCult.Eve.UnityScene DateTimeOffset? issuedAt = null) { var resolvedHost = ResolveHost(); - if (resolvedHost == null || resolvedHost.ActiveWorld == null || !resolvedHost.ActiveWorld.MovementEnabled) + if (resolvedHost == null || resolvedHost.ActiveWorld == null) return null; var movement = EveUnityPlayableWorldMoveVector.FromCameraRelativeInput( @@ -71,27 +96,265 @@ namespace GameCult.Eve.UnityScene issuedAt); } - public EveSurfaceCommandRequest? SubmitPrimaryAction(string actionId = "primary") + public EveSurfaceCommandRequest? SubmitPrimaryAction(string actionId = "") { var resolvedHost = ResolveHost(); if (resolvedHost?.ActiveWorld == null) return null; - return resolvedHost.SubmitActionIntent( + var resolvedActionId = string.IsNullOrWhiteSpace(actionId) + ? ResolvePrimaryActionId(resolvedHost.InputCapability) + : actionId; + if (string.IsNullOrWhiteSpace(resolvedActionId)) + return null; + + return resolvedHost.SubmitAdvertisedActionIntent( resolvedHost.ActiveWorld.PlayerEntityId, - string.IsNullOrWhiteSpace(actionId) ? "primary" : actionId); + resolvedActionId); + } + + public EveSurfaceCommandRequest? SubmitActionValue( + string actionId, + float inputValue, + DateTimeOffset? issuedAt = null) + { + var resolvedHost = ResolveHost(); + if (resolvedHost?.ActiveWorld == null || string.IsNullOrWhiteSpace(actionId)) + return null; + return resolvedHost.SubmitAdvertisedActionValueIntent( + resolvedHost.ActiveWorld.PlayerEntityId, + actionId, + inputValue, + issuedAt); + } + + public EveSurfaceCommandRequest? SubmitViewDirectionAction( + string actionId, + DateTimeOffset? issuedAt = null) + { + var resolvedHost = ResolveHost(); + if (resolvedHost?.ActiveWorld == null || string.IsNullOrWhiteSpace(actionId)) + return null; + var view = cameraTransform ?? resolvedHost.ActiveCameraTransform; + if (view == null || view.forward.sqrMagnitude <= 0.000001f) + return null; + var direction = view.forward.normalized; + return resolvedHost.SubmitAdvertisedActionViewDirectionIntent( + resolvedHost.ActiveWorld.PlayerEntityId, + actionId, + direction.x, + direction.y, + direction.z, + issuedAt); + } + + public EveSurfaceCommandRequest? SubmitLookInput( + float horizontalDelta, + DateTimeOffset? issuedAt = null) + { + var resolvedHost = ResolveHost(); + var world = resolvedHost?.ActiveWorld; + if (world == null || !string.Equals(world.LookModel, "planar-yaw.v1", StringComparison.Ordinal) || + string.IsNullOrWhiteSpace(world.LookCommand) || + Mathf.Abs(horizontalDelta) <= 0.0001f) + return null; + + var ownerKey = $"{resolvedHost!.ConnectionEpoch}\n{world.WorldRootId}\n{world.PlayerEntityId}"; + if (!_hasLookYaw || !string.Equals(_lookOwnerKey, ownerKey, StringComparison.Ordinal)) + { + _lookYawRadians = ResolveControlledYawRadians(resolvedHost!, world.PlayerEntityId); + _hasLookYaw = true; + _lookOwnerKey = ownerKey; + } + var sensitivity = Mathf.Abs(world.LookSensitivityRadians) > 0.000001f + ? world.LookSensitivityRadians + : defaultLookSensitivityRadians; + _lookYawRadians += horizontalDelta * sensitivity; + var direction = EveUnityPlayableWorldLookDirection.FromPlanarYaw(_lookYawRadians); + return resolvedHost!.SubmitLookDirectionIntent( + world.PlayerEntityId, + direction.DirectionX, + 0f, + direction.DirectionZ, + issuedAt); + } + + public void QueueLookInput(float horizontalDelta) => _pendingLookHorizontal += horizontalDelta; + + public EveSurfaceCommandRequest? SubmitPendingLookInput(DateTimeOffset? issuedAt = null) + { + var pending = _pendingLookHorizontal; + _pendingLookHorizontal = 0f; + return SubmitLookInput(pending, issuedAt); + } + + public static string ResolvePrimaryActionId(EveInputCapabilityDocument? capability) + { + if (capability == null) + return ""; + var profile = (capability.DefaultProfiles ?? Array.Empty()) + .FirstOrDefault(value => string.Equals(value.DeviceClass, "keyboard-mouse", StringComparison.Ordinal)) + ?? (capability.DefaultProfiles ?? Array.Empty()).FirstOrDefault(); + var binding = (profile?.Bindings ?? Array.Empty()) + .FirstOrDefault(value => (value.Gesture?.Controls ?? Array.Empty()) + .Any(control => string.Equals(control, "mouse.primary", StringComparison.Ordinal))) + ?? (profile?.Bindings ?? Array.Empty()).FirstOrDefault(value => value.ActionBar); + if (binding == null) + return ""; + var action = (capability.Actions ?? Array.Empty()) + .FirstOrDefault(value => string.Equals(value.ActionId, binding.ActionId, StringComparison.Ordinal)); + return action != null && string.Equals(action.Availability, "available", StringComparison.OrdinalIgnoreCase) + ? action.ActionId + : ""; } private void Update() { - if (!driveInUpdate || Time.unscaledTime < _nextCommandAt) + if (!driveInUpdate) + return; + + if (!string.IsNullOrWhiteSpace(lookHorizontalAxis)) + QueueLookInput(Input.GetAxisRaw(lookHorizontalAxis)); + if (Time.unscaledTime < _nextCommandAt) return; _nextCommandAt = Time.unscaledTime + Math.Max(0.01f, commandIntervalSeconds); SubmitMoveVectorInput(Input.GetAxisRaw(horizontalAxis), Input.GetAxisRaw(verticalAxis)); + SubmitPendingLookInput(); + SubmitChangedAdvertisedActions(); if (!string.IsNullOrWhiteSpace(actionButton) && Input.GetButtonDown(actionButton)) - SubmitPrimaryAction(actionButton); + SubmitPrimaryAction(); + } + + public void SubmitChangedAdvertisedActions() + { + SubmitChangedHeldActions(); + SubmitChangedPerformedActions(); + } + + private void SubmitChangedHeldActions() + { + var resolvedHost = ResolveHost(); + var capability = resolvedHost?.InputCapability; + if (resolvedHost?.ActiveWorld == null || capability == null) + return; + + var profile = (capability.DefaultProfiles ?? Array.Empty()) + .FirstOrDefault(value => string.Equals(value.DeviceClass, "keyboard-mouse", StringComparison.Ordinal)); + var retained = new HashSet(StringComparer.Ordinal); + foreach (var binding in profile?.Bindings ?? Array.Empty()) + { + var action = (capability.Actions ?? Array.Empty()) + .FirstOrDefault(candidate => string.Equals(candidate.ActionId, binding.ActionId, StringComparison.Ordinal)); + if (action?.InputValue == null || + !string.Equals(action.InputValue.Model, EveUnityAdvertisedInputAction.ButtonHoldValueModel, StringComparison.Ordinal) || + !TryReadDigitalGesture(binding.Gesture, out var pressed)) + continue; + + var stateKey = string.IsNullOrWhiteSpace(binding.BindingId) ? binding.ActionId : binding.BindingId; + retained.Add(stateKey); + if (!_heldBindings.TryGetValue(stateKey, out var previous)) + { + _heldBindings[stateKey] = new HeldBindingState(binding.ActionId, pressed); + if (pressed) SubmitActionValue(binding.ActionId, 1f); + continue; + } + if (previous.Pressed == pressed) continue; + _heldBindings[stateKey] = new HeldBindingState(binding.ActionId, pressed); + SubmitActionValue(binding.ActionId, pressed ? 1f : 0f); + } + + foreach (var stale in _heldBindings.Keys.Where(key => !retained.Contains(key)).ToArray()) + { + var previous = _heldBindings[stale]; + if (previous.Pressed) TrySubmitRelease(previous.ActionId); + _heldBindings.Remove(stale); + } + } + + private void SubmitChangedPerformedActions() + { + var resolvedHost = ResolveHost(); + var capability = resolvedHost?.InputCapability; + if (resolvedHost?.ActiveWorld == null || capability == null) + return; + + var profile = (capability.DefaultProfiles ?? Array.Empty()) + .FirstOrDefault(value => string.Equals(value.DeviceClass, "keyboard-mouse", StringComparison.Ordinal)); + var retained = new HashSet(StringComparer.Ordinal); + foreach (var binding in profile?.Bindings ?? Array.Empty()) + { + var action = (capability.Actions ?? Array.Empty()) + .FirstOrDefault(candidate => string.Equals(candidate.ActionId, binding.ActionId, StringComparison.Ordinal)); + if (action == null || + !string.Equals(action.Availability, "available", StringComparison.OrdinalIgnoreCase) || + string.Equals(action.InputValue?.Model, EveUnityAdvertisedInputAction.ButtonHoldValueModel, StringComparison.Ordinal) || + (!string.IsNullOrWhiteSpace(action.InputValue?.Model) && + !string.Equals(action.InputValue.Model, EveUnityAdvertisedInputAction.ViewDirectionValueModel, StringComparison.Ordinal)) || + !TryReadDigitalGesture(binding.Gesture, out var pressed)) + continue; + + var stateKey = string.IsNullOrWhiteSpace(binding.BindingId) ? binding.ActionId : binding.BindingId; + retained.Add(stateKey); + var wasPressed = _performedBindings.TryGetValue(stateKey, out var previous) && previous.Pressed; + _performedBindings[stateKey] = new PerformedBindingState(binding.ActionId, pressed); + if (!pressed || wasPressed) continue; + if (string.Equals(action.InputValue?.Model, EveUnityAdvertisedInputAction.ViewDirectionValueModel, StringComparison.Ordinal)) + SubmitViewDirectionAction(binding.ActionId); + else + resolvedHost.SubmitAdvertisedActionIntent(resolvedHost.ActiveWorld.PlayerEntityId, binding.ActionId); + } + + foreach (var stale in _performedBindings.Keys.Where(key => !retained.Contains(key)).ToArray()) + _performedBindings.Remove(stale); + } + + private bool TryReadDigitalGesture(EveInputGestureDocument? gesture, out bool pressed) + { + pressed = false; + if (gesture == null || + !(string.Equals(gesture.Kind, "direct", StringComparison.Ordinal) || + string.Equals(gesture.Kind, "chord", StringComparison.Ordinal))) + return false; + var controls = gesture.Controls ?? Array.Empty(); + if (controls.Length == 0) return false; + foreach (var control in controls) + { + var value = DigitalControlReader?.Invoke(control) ?? ReadStandardDigitalControl(control); + if (!value.HasValue) return false; + if (!value.Value) return true; + } + pressed = true; + return true; + } + + private static bool? ReadStandardDigitalControl(string control) + { + if (string.Equals(control, "mouse.primary", StringComparison.Ordinal)) return Input.GetMouseButton(0); + if (string.Equals(control, "mouse.secondary", StringComparison.Ordinal)) return Input.GetMouseButton(1); + if (string.Equals(control, "mouse.middle", StringComparison.Ordinal)) return Input.GetMouseButton(2); + const string keyboardPrefix = "keyboard."; + if (control != null && control.StartsWith(keyboardPrefix, StringComparison.Ordinal) && + Enum.TryParse(control.Substring(keyboardPrefix.Length), true, out KeyCode key)) + return Input.GetKey(key); + return null; + } + + private void OnDisable() => ReleaseHeldActions(); + + private void ReleaseHeldActions() + { + foreach (var state in _heldBindings.Values) + if (state.Pressed) TrySubmitRelease(state.ActionId); + _heldBindings.Clear(); + _performedBindings.Clear(); + } + + private void TrySubmitRelease(string actionId) + { + try { SubmitActionValue(actionId, 0f); } + catch (InvalidOperationException) { } } private EveUnityPlayableWorldClientHost? ResolveHost() @@ -102,6 +365,61 @@ namespace GameCult.Eve.UnityScene host = GetComponent(); return host; } + + private static float ResolveControlledYawRadians(EveUnityPlayableWorldClientHost host, string entityId) + { + foreach (var marker in host.SceneRoot.GetComponentsInChildren(true)) + if (string.Equals(marker.EntityId, entityId, StringComparison.Ordinal)) + return marker.transform.eulerAngles.y * Mathf.Deg2Rad; + return 0f; + } + + private void ResetLookState() + { + _lookYawRadians = 0f; + _hasLookYaw = false; + _pendingLookHorizontal = 0f; + _lookOwnerKey = ""; + } + + private readonly struct HeldBindingState + { + public HeldBindingState(string actionId, bool pressed) + { + ActionId = actionId ?? ""; + Pressed = pressed; + } + + public string ActionId { get; } + public bool Pressed { get; } + } + + private readonly struct PerformedBindingState + { + public PerformedBindingState(string actionId, bool pressed) + { + ActionId = actionId ?? ""; + Pressed = pressed; + } + + public string ActionId { get; } + public bool Pressed { get; } + } + } + + public readonly struct EveUnityPlayableWorldLookDirection + { + public EveUnityPlayableWorldLookDirection(float directionX, float directionZ) + { + DirectionX = directionX; + DirectionZ = directionZ; + } + + public float DirectionX { get; } + public float DirectionZ { get; } + + public static EveUnityPlayableWorldLookDirection FromPlanarYaw(float yawRadians) => + new EveUnityPlayableWorldLookDirection(Mathf.Sin(yawRadians), Mathf.Cos(yawRadians)); } public readonly struct EveUnityPlayableWorldMoveVector diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs index 222d9ad..f8d7182 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using GameCult.Eve.Surface; #nullable enable @@ -11,24 +12,22 @@ namespace GameCult.Eve.UnityScene private readonly EveUnitySceneProviderConnection _connection; private readonly EveUnityPlayableWorldPresenter _presenter; private readonly IEveUnitySceneCommandReceiptSource? _receiptSource; - private readonly Func? _visibleStateVersion; private readonly EveUnityFeedbackPresenter _feedback = new EveUnityFeedbackPresenter(); private readonly EveUnityShotReceiptPresenter _shots = new EveUnityShotReceiptPresenter(); private readonly List _pendingReceipts = new List(); + private long _activeStateVersion; private bool _connected; private bool _receiptConnected; public EveUnityPlayableWorldLiveClient( EveUnitySceneProviderConnection connection, EveUnityPlayableWorldPresenter presenter, - IEveUnitySceneCommandReceiptSource? receiptSource = null, - Func? visibleStateVersion = null) + IEveUnitySceneCommandReceiptSource? receiptSource = null) { _connection = connection ?? throw new ArgumentNullException(nameof(connection)); _presenter = presenter ?? throw new ArgumentNullException(nameof(presenter)); _receiptSource = receiptSource; - _visibleStateVersion = visibleStateVersion; } public EveUnitySceneProjection? ActiveProjection => _connection.ActiveProjection; @@ -39,7 +38,14 @@ namespace GameCult.Eve.UnityScene public EveUnitySceneCommandReceipt? LastReceipt { get; private set; } - public long ActiveVersion => Math.Max(_connection.ActiveVersion, _visibleStateVersion?.Invoke() ?? 0); + public long ActiveVersion => Math.Max(_connection.ActiveVersion, _activeStateVersion); + + public void AdvanceStateVersion(long version) + { + if (version <= _activeStateVersion) return; + _activeStateVersion = version; + PublishReceiptsWhoseStateIsVisible(); + } public string SourcePointer => _connection.SourcePointer; @@ -91,6 +97,16 @@ namespace GameCult.Eve.UnityScene return _connection.SubmitMoveVectorIntent(entityId, directionX, directionY, scalarValue, issuedAt); } + public EveSurfaceCommandRequest SubmitLookDirectionIntent( + string entityId, + float directionX, + float directionY, + float directionZ, + DateTimeOffset? issuedAt = null) + { + return _connection.SubmitLookDirectionIntent(entityId, directionX, directionY, directionZ, issuedAt); + } + public EveSurfaceCommandRequest SubmitFocusIntent( string entityId, DateTimeOffset? issuedAt = null) @@ -108,10 +124,18 @@ namespace GameCult.Eve.UnityScene public EveSurfaceCommandRequest SubmitActionIntent( string entityId, - EveInputActionDocument action, + string actionId, DateTimeOffset? issuedAt = null) { - return _connection.SubmitActionIntent(entityId, action, issuedAt); + return _connection.SubmitActionIntent(entityId, actionId, issuedAt); + } + + public EveSurfaceCommandRequest SubmitCommandIntent( + string commandId, + IReadOnlyDictionary? payload = null, + DateTimeOffset? issuedAt = null) + { + return _connection.SubmitCommandIntent(commandId, payload, issuedAt); } public void Disconnect() @@ -211,6 +235,43 @@ namespace GameCult.Eve.UnityScene event Action ReceiptAvailable; } + public interface IEveUnityNavigableProvider + { + Task NavigateAsync(EveUnitySceneNavigationTarget target); + + void CommitNavigation(); + + void FinalizeNavigation(); + + void RollbackNavigation(); + } + + public sealed class EveUnitySceneNavigationTarget + { + public EveUnitySceneNavigationTarget( + string verseId, + string providerId, + string surfaceId, + string surfaceKind, + IReadOnlyList? rendezvousEndpoints = null, + string authorityRuntimeId = "") + { + VerseId = verseId ?? ""; + AuthorityRuntimeId = authorityRuntimeId ?? ""; + ProviderId = providerId ?? ""; + SurfaceId = surfaceId ?? ""; + SurfaceKind = surfaceKind ?? ""; + RendezvousEndpoints = rendezvousEndpoints ?? Array.Empty(); + } + + public string VerseId { get; } + public string AuthorityRuntimeId { get; } + public string ProviderId { get; } + public string SurfaceId { get; } + public string SurfaceKind { get; } + public IReadOnlyList RendezvousEndpoints { get; } + } + public sealed class EveUnitySceneCommandReceipt { public EveUnitySceneCommandReceipt( @@ -225,7 +286,8 @@ namespace GameCult.Eve.UnityScene string surfaceId = "", string message = "", DateTimeOffset? issuedAtUtc = null, - long sourceVersion = 0) + long sourceVersion = 0, + EveUnitySceneNavigationTarget? navigation = null) { ReceiptId = receiptId ?? ""; Command = command ?? ""; @@ -239,6 +301,7 @@ namespace GameCult.Eve.UnityScene Message = message ?? ""; IssuedAtUtc = issuedAtUtc; SourceVersion = sourceVersion; + Navigation = navigation; } public string Schema { get; } @@ -265,6 +328,8 @@ namespace GameCult.Eve.UnityScene public long SourceVersion { get; } + public EveUnitySceneNavigationTarget? Navigation { get; } + public bool IsProviderOwned => !string.IsNullOrWhiteSpace(OwnerRepo) && !string.Equals(OwnerRepo, "EveUnity", StringComparison.Ordinal); public bool ShouldRefreshProviderSurface => diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs index 393a7ba..94ca473 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs @@ -1,5 +1,5 @@ using System; -using System.Linq; +using System.Collections.Generic; using GameCult.Eve.Surface; using UnityEngine; @@ -14,8 +14,9 @@ namespace GameCult.Eve.UnityScene private readonly EveUnityPlayableWorldLiveClient _client; private readonly EveUnityPlayableWorldAssetManifestDocumentSource? _assetManifestSource; private readonly IEveUnityEntitySoaViewDocumentSource? _entityViews; - private readonly IEveUnityInputCapabilityDocumentSource? _inputCapabilities; private readonly EveUnityEntitySoaPresenter _entityPresenter; + private readonly IEveUnityPresentedEntityRegistry? _presentedEntities; + private IDisposable? _ownedSceneSink; private bool _entityViewsConnected; private bool _assetManifestConnected; @@ -35,14 +36,13 @@ namespace GameCult.Eve.UnityScene AssetManifests = assetManifests ?? new EveUnityPlayableWorldAssetManifestCache(); _surfaceSource = new EveUnitySceneProviderSurfaceDocumentSource(surfaceDocuments); _entityViews = surfaceDocuments as IEveUnityEntitySoaViewDocumentSource; - _inputCapabilities = surfaceDocuments as IEveUnityInputCapabilityDocumentSource; + _presentedEntities = sceneSink as IEveUnityPresentedEntityRegistry; _entityPresenter = new EveUnityEntitySoaPresenter(sceneSink); _connection = new EveUnitySceneProviderConnection(_surfaceSource, commandSink); _client = new EveUnityPlayableWorldLiveClient( _connection, new EveUnityPlayableWorldPresenter(sceneSink, assetResolver ?? new EveUnityAssetRefResolver()), - receiptSource, - () => _entityViews?.CurrentEntityView?.FrameId ?? 0); + receiptSource); if (assetManifestDocuments != null) _assetManifestSource = new EveUnityPlayableWorldAssetManifestDocumentSource(assetManifestDocuments); @@ -51,12 +51,13 @@ namespace GameCult.Eve.UnityScene public EveUnityPlayableWorldAssetManifestCache AssetManifests { get; } public IEveUnityGameObjectAssetProvider? GameObjectAssetProvider { get; private set; } + public IEveUnityPresentedEntityRegistry? PresentedEntities => _presentedEntities; public EveUnityPlayableWorldProjection? ActiveWorld => _client.ActiveWorld; public EveUnitySceneProjection? ActiveProjection => _client.ActiveProjection; - public EveUnityPlayableWorldPresentation? LastPresentation => ComposePresentation(_client.LastPresentation); + public EveUnityPlayableWorldPresentation? LastPresentation => _client.LastPresentation; public EveUnitySceneCommandReceipt? LastReceipt => _client.LastReceipt; @@ -107,23 +108,22 @@ namespace GameCult.Eve.UnityScene new EveUnityAssetRefResolver(), assetManifests); runtime.GameObjectAssetProvider = liveAssetProvider; + runtime._ownedSceneSink = sceneSink; return runtime; } public EveUnityPlayableWorldPresentation Connect() { EnsureAssetManifestConnected(); - _client.Connect(); + var presentation = _client.Connect(); EnsureEntityViewsConnected(); - return ComposePresentation(_client.LastPresentation)!; + return presentation; } public EveUnityPlayableWorldPresentation Refresh() { EnsureAssetManifestConnected(); - EnsureEntityViewsConnected(); - _client.Refresh(); - return ComposePresentation(_client.LastPresentation)!; + return _client.Refresh(); } public EveSurfaceCommandRequest SubmitMoveIntent( @@ -146,6 +146,16 @@ namespace GameCult.Eve.UnityScene return _client.SubmitMoveVectorIntent(entityId, directionX, directionY, scalarValue, issuedAt); } + public EveSurfaceCommandRequest SubmitLookDirectionIntent( + string entityId, + float directionX, + float directionY, + float directionZ, + DateTimeOffset? issuedAt = null) + { + return _client.SubmitLookDirectionIntent(entityId, directionX, directionY, directionZ, issuedAt); + } + public EveSurfaceCommandRequest SubmitFocusIntent( string entityId, DateTimeOffset? issuedAt = null) @@ -166,14 +176,15 @@ namespace GameCult.Eve.UnityScene string actionId, DateTimeOffset? issuedAt = null) { - var capability = _inputCapabilities?.CurrentInputCapability - ?? throw new InvalidOperationException("The active Eve surface does not publish an input capability document."); - var action = (capability.Actions ?? Array.Empty()) - .FirstOrDefault(candidate => string.Equals(candidate.ActionId, actionId, StringComparison.Ordinal)) - ?? throw new InvalidOperationException($"The active Eve input capability does not advertise action '{actionId}'."); - if (!string.Equals(action.Availability, "available", StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"Eve action '{actionId}' is not currently available."); - return _client.SubmitActionIntent(entityId, action, issuedAt); + return _client.SubmitActionIntent(entityId, actionId, issuedAt); + } + + public EveSurfaceCommandRequest SubmitCommandIntent( + string commandId, + IReadOnlyDictionary? payload = null, + DateTimeOffset? issuedAt = null) + { + return _client.SubmitCommandIntent(commandId, payload, issuedAt); } public void Disconnect() @@ -198,6 +209,8 @@ namespace GameCult.Eve.UnityScene Disconnect(); _client.Dispose(); _assetManifestSource?.Dispose(); + _ownedSceneSink?.Dispose(); + _ownedSceneSink = null; } private void EnsureAssetManifestConnected() @@ -215,30 +228,18 @@ namespace GameCult.Eve.UnityScene if (_entityViewsConnected || _entityViews == null) return; _entityViews.EntityViewAvailable += OnEntityViewAvailable; _entityViewsConnected = true; - if (_entityViews.CurrentEntityView != null) - _entityPresenter.Apply(_entityViews.CurrentEntityView); } - private void OnEntityViewAvailable(EveEntitySoaViewDocument document) => _entityPresenter.Apply(document); - - private EveUnityPlayableWorldPresentation? ComposePresentation( - EveUnityPlayableWorldPresentation? surfacePresentation) + private void OnEntityViewAvailable(EveEntitySoaViewDocument document, GameCult.Mesh.ICultMeshBodyReadLease lease) { - if (surfacePresentation == null || _entityViews?.CurrentEntityView == null) - return surfacePresentation; - - return new EveUnityPlayableWorldPresentation( - surfacePresentation.WorldRootId, - surfacePresentation.PlayerEntityId, - surfacePresentation.InputProfile, - surfacePresentation.CameraRig, - surfacePresentation.UpsertedEntities, - surfacePresentation.RemovedEntities, - _entityPresenter.ActiveEntities); + _client.AdvanceStateVersion(document.FrameId); + _entityPresenter.Apply(document, lease); } } - public sealed class EveUnityLivePlayableWorldAssetProvider : IEveUnityNativeAssetProvider + public sealed class EveUnityLivePlayableWorldAssetProvider : + IEveUnityNativeAssetProvider, + IEveUnityNativeAssetMetadataProvider { private readonly EveUnityPlayableWorldAssetManifestCache _assetManifests; private readonly Func _activeWorld; @@ -285,5 +286,16 @@ namespace GameCult.Eve.UnityScene } return (_fallback as IEveUnityNativeAssetProvider)?.ResolveAsset(asset, assetType); } + + public bool TryResolveAssetMetadata( + EveUnityPlayableWorldAssetBinding asset, + out IReadOnlyDictionary metadata) + { + if (asset == null) throw new ArgumentNullException(nameof(asset)); + if (_fallback is IEveUnityNativeAssetMetadataProvider provider) + return provider.TryResolveAssetMetadata(asset, out metadata); + metadata = new Dictionary(StringComparer.Ordinal); + return false; + } } } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPresentedEntities.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPresentedEntities.cs new file mode 100644 index 0000000..cb03b14 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPresentedEntities.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityPresentedEntity + { + public EveUnityPresentedEntity( + int sourceIndex, + string entityId, + string entityKind, + string label, + string faction, + Vector3 position, + float rotationYDegrees, + Vector3 velocity, + float radius, + float scale, + uint renderGroupId, + int lod, + bool selectable, + bool controllable, + string assetRef, + IReadOnlyDictionary? scalarState = null) + { + SourceIndex = sourceIndex; + EntityId = entityId ?? ""; + EntityKind = entityKind ?? ""; + Label = label ?? ""; + Faction = faction ?? ""; + Position = position; + RotationYDegrees = rotationYDegrees; + Velocity = velocity; + Radius = radius; + Scale = scale; + RenderGroupId = renderGroupId; + Lod = lod; + Selectable = selectable; + Controllable = controllable; + AssetRef = assetRef ?? ""; + ScalarState = scalarState ?? new Dictionary(StringComparer.Ordinal); + } + + public int SourceIndex { get; } + public string EntityId { get; } + public string EntityKind { get; } + public string Label { get; } + public string Faction { get; } + public Vector3 Position { get; } + public float RotationYDegrees { get; } + public Vector3 Velocity { get; } + public float Radius { get; } + public float Scale { get; } + public uint RenderGroupId { get; } + public int Lod { get; } + public bool Selectable { get; } + public bool Controllable { get; } + public string AssetRef { get; } + public IReadOnlyDictionary ScalarState { get; } + + public bool TryGetScalar(string semantic, out float value) => + ScalarState.TryGetValue(semantic ?? "", out value); + + public EveUnityPlayableWorldEntity ToPlayableWorldEntity() => new EveUnityPlayableWorldEntity( + EntityId, + EntityId, + EntityKind, + Label, + Faction, + AssetRef, + Position.x, + Position.y, + Position.z, + RotationYDegrees, + Scale > 0f ? Scale : Radius, + Selectable, + Controllable, + "", "", "", ""); + } + + public sealed class EveUnityPresentedEntityGeneration + { + public EveUnityPresentedEntityGeneration( + string viewId, + long producerEpoch, + long sequence, + IEnumerable entities) + { + ViewId = viewId ?? ""; + ProducerEpoch = producerEpoch; + Sequence = sequence; + Entities = (entities ?? throw new ArgumentNullException(nameof(entities))).ToArray(); + } + + public string ViewId { get; } + public long ProducerEpoch { get; } + public long Sequence { get; } + public IReadOnlyList Entities { get; } + } + + public sealed class EveUnityPresentedEntityHandle + { + public EveUnityPresentedEntityHandle(EveUnityPresentedEntity entity, Transform transform) + { + Entity = entity ?? throw new ArgumentNullException(nameof(entity)); + Transform = transform != null ? transform : throw new ArgumentNullException(nameof(transform)); + } + + public EveUnityPresentedEntity Entity { get; } + public Transform Transform { get; } + } + + public interface IEveUnityPresentedEntityRegistry + { + EveUnityPresentedEntityGeneration? CurrentGeneration { get; } + bool TryGetByEntityId(string entityId, out EveUnityPresentedEntityHandle handle); + bool TryGetBySourceIndex(int sourceIndex, out EveUnityPresentedEntityHandle handle); + } + + public interface IEveUnityEntityGenerationSink + { + void ApplyGeneration(EveUnityPresentedEntityGeneration generation); + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPresentedEntities.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPresentedEntities.cs.meta new file mode 100644 index 0000000..c16abad --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPresentedEntities.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3f7e67e0c24449bab80f2dd15cf2e118 diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityRendererFeature.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityRendererFeature.cs new file mode 100644 index 0000000..562eaf8 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityRendererFeature.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.Rendering.Universal; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + internal interface IEveUnityRenderPassSource + { + int RenderOrder { get; } + bool TryEnqueuePass(Camera camera, ScriptableRenderer renderer); + } + + internal static class EveUnityRenderPassRegistry + { + private static readonly List Sources = + new List(); + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void Reset() => Sources.Clear(); + + internal static void Register(IEveUnityRenderPassSource source) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + if (Sources.Contains(source)) return; + Sources.Add(source); + Sources.Sort(static (left, right) => left.RenderOrder.CompareTo(right.RenderOrder)); + } + + internal static void Unregister(IEveUnityRenderPassSource source) => Sources.Remove(source); + + internal static void Enqueue(Camera camera, ScriptableRenderer renderer) + { + for (var index = 0; index < Sources.Count; index++) + Sources[index].TryEnqueuePass(camera, renderer); + } + } + + /// + /// URP-owned scheduling point for generic Eve world effects. + /// Add this feature to the Universal Renderer used by Eve cameras. + /// Provider surfaces supply effect programs, assets, and parameters; this feature only lowers them. + /// + public sealed class EveUnityRendererFeature : ScriptableRendererFeature + { + public override void Create() + { + } + + public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) + { + var camera = renderingData.cameraData.camera; + if (camera == null || !SupportsCamera(renderingData.cameraData.cameraType)) return; + EveUnityRenderPassRegistry.Enqueue(camera, renderer); + } + + internal static bool SupportsCamera(CameraType cameraType) => + cameraType == CameraType.Game || cameraType == CameraType.SceneView; + } +} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityRendererFeature.cs.meta similarity index 83% rename from packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs.meta rename to packages/org.gamecult.eve.unity-scene/Runtime/EveUnityRendererFeature.cs.meta index b2882e9..82fdbfc 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs.meta +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityRendererFeature.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 8e87e1be783846508efea45787f2d45c +guid: 340385338d304b5aaf069562d4e7b457 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneClientSession.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneClientSession.cs index 16efb3d..473670d 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneClientSession.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneClientSession.cs @@ -83,6 +83,26 @@ namespace GameCult.Eve.UnityScene issuedAt); } + public EveSurfaceCommandRequest CreateLookDirectionIntent( + string entityId, + float directionX, + float directionY, + float directionZ, + DateTimeOffset? issuedAt = null) + { + var playableWorld = RequirePlayableWorld(); + return CreatePlayableWorldIntent( + playableWorld.LookCommand, + new Dictionary(StringComparer.Ordinal) + { + ["entityId"] = entityId ?? "", + ["directionX"] = FormatFloat(directionX), + ["directionY"] = FormatFloat(directionY), + ["directionZ"] = FormatFloat(directionZ) + }, + issuedAt); + } + public EveSurfaceCommandRequest CreateFocusIntent( string entityId, DateTimeOffset? issuedAt = null) @@ -115,18 +135,17 @@ namespace GameCult.Eve.UnityScene public EveSurfaceCommandRequest CreateActionIntent( string entityId, - EveInputActionDocument action, + string actionId, DateTimeOffset? issuedAt = null) { - if (action == null) throw new ArgumentNullException(nameof(action)); - var payload = new Dictionary(action.Payload ?? new Dictionary(), StringComparer.Ordinal) - { - ["entityId"] = entityId ?? "", - ["actionId"] = action.ActionId ?? "" - }; + var playableWorld = RequirePlayableWorld(); return CreatePlayableWorldIntent( - action.Operation, - payload, + playableWorld.ActionCommand, + new Dictionary(StringComparer.Ordinal) + { + ["entityId"] = entityId ?? "", + ["actionId"] = actionId ?? "" + }, issuedAt); } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneLiveProviderBridge.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneLiveProviderBridge.cs index 59814ca..7ccaea0 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneLiveProviderBridge.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneLiveProviderBridge.cs @@ -239,8 +239,18 @@ namespace GameCult.Eve.UnityScene _transport.SurfaceDocumentAvailable += OnSurfaceDocumentAvailable; _transport.AssetManifestDocumentAvailable += OnAssetManifestDocumentAvailable; _transport.CommandReceiptAvailable += OnCommandReceiptAvailable; - _transport.Connect(); - _connected = true; + try + { + _transport.Connect(); + _connected = true; + } + catch + { + _transport.SurfaceDocumentAvailable -= OnSurfaceDocumentAvailable; + _transport.AssetManifestDocumentAvailable -= OnAssetManifestDocumentAvailable; + _transport.CommandReceiptAvailable -= OnCommandReceiptAvailable; + throw; + } } public void Disconnect() diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneProviderConnection.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneProviderConnection.cs index de6037c..f38c3d5 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneProviderConnection.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneProviderConnection.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using GameCult.Eve.Surface; #nullable enable @@ -221,6 +222,16 @@ namespace GameCult.Eve.UnityScene return Submit(_session.CreateMoveVectorIntent(entityId, directionX, directionY, scalarValue, issuedAt)); } + public EveSurfaceCommandRequest SubmitLookDirectionIntent( + string entityId, + float directionX, + float directionY, + float directionZ, + DateTimeOffset? issuedAt = null) + { + return Submit(_session.CreateLookDirectionIntent(entityId, directionX, directionY, directionZ, issuedAt)); + } + public EveSurfaceCommandRequest SubmitFocusIntent( string entityId, DateTimeOffset? issuedAt = null) @@ -238,10 +249,18 @@ namespace GameCult.Eve.UnityScene public EveSurfaceCommandRequest SubmitActionIntent( string entityId, - EveInputActionDocument action, + string actionId, DateTimeOffset? issuedAt = null) { - return Submit(_session.CreateActionIntent(entityId, action, issuedAt)); + return Submit(_session.CreateActionIntent(entityId, actionId, issuedAt)); + } + + public EveSurfaceCommandRequest SubmitCommandIntent( + string commandId, + IReadOnlyDictionary? payload = null, + DateTimeOffset? issuedAt = null) + { + return Submit(_session.CreatePlayableWorldIntent(commandId, payload, issuedAt)); } public void Disconnect() diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs index b0217c3..b14f549 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Linq; +using GameCult.Eve.PluginFields; using GameCult.Eve.Surface; using GameCult.Mesh; @@ -102,10 +104,16 @@ namespace GameCult.Eve.UnityScene return null; var entities = new List(); + var fieldVolumes = new List(); + var fieldParticles = new List(); foreach (var component in Flatten(worldRoot)) { if (string.Equals(component.Kind, "world.entity3d", StringComparison.Ordinal)) entities.Add(BuildPlayableEntity(component)); + else if (string.Equals(component.Kind, "field.volume3d", StringComparison.Ordinal)) + fieldVolumes.Add(BuildFieldVolume(component)); + else if (string.Equals(component.Kind, "field.particles3d", StringComparison.Ordinal)) + fieldParticles.Add(BuildFieldParticles(component)); } return new EveUnityPlayableWorldProjection( @@ -123,15 +131,75 @@ namespace GameCult.Eve.UnityScene entities, worldRoot.GetProp("entityViewPointerId"), worldRoot.GetProp("entityViewSchema"), + worldRoot.GetProp("entityBodyId"), worldRoot.GetProp("zoneRenderPointerId"), worldRoot.GetProp("zoneRenderSchema"), - worldRoot.GetProp("inputCapability"), + ParseStringList(worldRoot.GetProp("excludedRenderChannels")), worldRoot.GetProp("cameraTargetEntityId"), - ParseBoolean(worldRoot.GetProp("subjectVisible", "true")), - ParseBoolean(worldRoot.GetProp("movementEnabled", "true")), - worldRoot.GetProp("presentationMode", "world")); + ParseFloat(worldRoot.GetProp("cameraDistance"), 0f), + ParseFloat(worldRoot.GetProp("cameraVerticalFieldOfViewDegrees"), 0f), + ParseFloat(worldRoot.GetProp("cameraTargetScreenX"), 0.5f), + ParseFloat(worldRoot.GetProp("cameraTargetScreenY"), 0.5f), + ParseFloat(worldRoot.GetProp("cameraPositionDamping"), 0f), + ParseVector3(worldRoot.GetProp("ambientLightColor")), + ParseFloat(worldRoot.GetProp("ambientLightIntensity"), 1f), + ParseFloat(worldRoot.GetProp("cameraNearClipPlane"), 0f), + ParseFloat(worldRoot.GetProp("cameraFarClipPlane"), 0f), + worldRoot.GetProp("lookCommand"), + ParseFloat(worldRoot.GetProp("lookSensitivityRadians"), 0f), + worldRoot.GetProp("lookModel"), + worldRoot.GetProp("skyboxAssetRef"), + worldRoot.GetProp("reflectionAssetRef"), + ParseFloat(worldRoot.GetProp("reflectionIntensity"), 1f), + worldRoot.GetProp("postProcessProfileAssetRef"), + ParseVector3(worldRoot.GetProp("keyLightDirection")), + ParseVector3(worldRoot.GetProp("keyLightColor")), + ParseFloat(worldRoot.GetProp("keyLightIntensity"), 0f), + fieldVolumes, + worldRoot.GetProp("cameraLookAt"), + fieldParticles, + worldRoot.GetProp("cameraReconstruction"), + worldRoot.GetProp("temporalQuality", "high"), + ParseFloat(worldRoot.GetProp("temporalHistoryBlend"), 0f), + ParseFloat(worldRoot.GetProp("temporalJitterScale"), 0f), + ParseFloat(worldRoot.GetProp("temporalSharpening"), 0f), + worldRoot.GetProp("exposureMode"), + ParseFloat(worldRoot.GetProp("exposureLowPercent"), 50f), + ParseFloat(worldRoot.GetProp("exposureHighPercent"), 95f), + ParseFloat(worldRoot.GetProp("exposureMinimumEv"), 0f), + ParseFloat(worldRoot.GetProp("exposureMaximumEv"), 0f), + ParseFloat(worldRoot.GetProp("exposureKeyValue"), 1f), + worldRoot.GetProp("exposureAdaptation", "progressive"), + ParseFloat(worldRoot.GetProp("exposureSpeedUp"), 2f), + ParseFloat(worldRoot.GetProp("exposureSpeedDown"), 1f), + worldRoot.GetProp("colorGradingSpace"), + worldRoot.GetProp("assetProviderId"), + worldRoot.GetProp("assetVerseId"), + worldRoot.GetProp("assetAuthorityRuntimeId"), + ParseStringList(worldRoot.GetProp("assetRendezvousEndpoints"))); } + private static EveUnityFieldVolumeProjection BuildFieldVolume(EveSurfaceComponent component) => + new EveUnityFieldVolumeProjection( + component.Id, + component.GetProp("documentRef"), + component.GetProp("documentSchema", EveFieldsSchemas.Splats), + component.GetProp("materialAssetRef"), + component.GetProp("renderChannel", "world.transparent"), + component.GetProp("compositeMode", "premultiplied-alpha"), + component.GetProp("quality", "normal"), + component.Props); + + private static EveUnityFieldParticlesProjection BuildFieldParticles(EveSurfaceComponent component) => + new EveUnityFieldParticlesProjection( + component.Id, + component.GetProp("documentRef"), + component.GetProp("documentSchema", EveFieldsSchemas.Splats), + component.GetProp("computeProgramAssetRef"), + component.GetProp("materialAssetRef"), + component.GetProp("renderChannel", "world.transparent"), + component.Props); + private static EveUnityPlayableWorldEntity BuildPlayableEntity(EveSurfaceComponent component) { var position = ParseVector3(component.GetProp("position")); @@ -203,6 +271,16 @@ namespace GameCult.Eve.UnityScene string.Equals(value, "1", StringComparison.Ordinal); } + private static IReadOnlyList ParseStringList(string value) + { + return (value ?? "") + .Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(part => part.Trim()) + .Where(part => !string.IsNullOrWhiteSpace(part)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + private static EveUnityScenePluginProjection? BuildPluginProjection(EveSurfaceComponent component) { if (SaiVisualNovelAdapter.CanProject(component)) @@ -328,13 +406,52 @@ namespace GameCult.Eve.UnityScene IReadOnlyList entities, string entityViewPointerId = "", string entityViewSchema = "", + string entityBodyId = "", string zoneRenderPointerId = "", string zoneRenderSchema = "", - string inputCapabilityPointerId = "", + IReadOnlyList? excludedRenderChannels = null, string cameraTargetEntityId = "", - bool subjectVisible = true, - bool movementEnabled = true, - string presentationMode = "world") + float cameraDistance = 0f, + float cameraVerticalFieldOfViewDegrees = 0f, + float cameraTargetScreenX = 0.5f, + float cameraTargetScreenY = 0.5f, + float cameraPositionDamping = 0f, + (float r, float g, float b) ambientLightColor = default, + float ambientLightIntensity = 1f, + float cameraNearClipPlane = 0f, + float cameraFarClipPlane = 0f, + string lookCommand = "", + float lookSensitivityRadians = 0f, + string lookModel = "", + string skyboxAssetRef = "", + string reflectionAssetRef = "", + float reflectionIntensity = 1f, + string postProcessProfileAssetRef = "", + (float x, float y, float z) keyLightDirection = default, + (float r, float g, float b) keyLightColor = default, + float keyLightIntensity = 0f, + IReadOnlyList? fieldVolumes = null, + string cameraLookAt = "", + IReadOnlyList? fieldParticles = null, + string cameraReconstruction = "", + string temporalQuality = "high", + float temporalHistoryBlend = 0f, + float temporalJitterScale = 0f, + float temporalSharpening = 0f, + string exposureMode = "", + float exposureLowPercent = 50f, + float exposureHighPercent = 95f, + float exposureMinimumEv = 0f, + float exposureMaximumEv = 0f, + float exposureKeyValue = 1f, + string exposureAdaptation = "progressive", + float exposureSpeedUp = 2f, + float exposureSpeedDown = 1f, + string colorGradingSpace = "", + string assetProviderId = "", + string assetVerseId = "", + string assetAuthorityRuntimeId = "", + IReadOnlyList? assetRendezvousEndpoints = null) { WorldRootId = worldRootId ?? ""; StatePointerId = statePointerId ?? ""; @@ -350,15 +467,58 @@ namespace GameCult.Eve.UnityScene Entities = entities ?? Array.Empty(); EntityViewPointerId = entityViewPointerId ?? ""; EntityViewSchema = entityViewSchema ?? ""; + EntityBodyId = entityBodyId ?? ""; ZoneRenderPointerId = zoneRenderPointerId ?? ""; ZoneRenderSchema = zoneRenderSchema ?? ""; - InputCapabilityPointerId = inputCapabilityPointerId ?? ""; - CameraTargetEntityId = string.IsNullOrWhiteSpace(cameraTargetEntityId) - ? PlayerEntityId - : cameraTargetEntityId; - SubjectVisible = subjectVisible; - MovementEnabled = movementEnabled; - PresentationMode = presentationMode ?? ""; + ExcludedRenderChannels = excludedRenderChannels ?? Array.Empty(); + CameraTargetEntityId = cameraTargetEntityId ?? ""; + CameraDistance = cameraDistance; + CameraVerticalFieldOfViewDegrees = cameraVerticalFieldOfViewDegrees; + CameraTargetScreenX = cameraTargetScreenX; + CameraTargetScreenY = cameraTargetScreenY; + CameraPositionDamping = cameraPositionDamping; + AmbientLightR = ambientLightColor.r; + AmbientLightG = ambientLightColor.g; + AmbientLightB = ambientLightColor.b; + AmbientLightIntensity = ambientLightIntensity; + CameraNearClipPlane = cameraNearClipPlane; + CameraFarClipPlane = cameraFarClipPlane; + LookCommand = lookCommand ?? ""; + LookSensitivityRadians = lookSensitivityRadians; + LookModel = lookModel ?? ""; + SkyboxAssetRef = skyboxAssetRef ?? ""; + ReflectionAssetRef = reflectionAssetRef ?? ""; + ReflectionIntensity = reflectionIntensity; + PostProcessProfileAssetRef = postProcessProfileAssetRef ?? ""; + KeyLightDirectionX = keyLightDirection.x; + KeyLightDirectionY = keyLightDirection.y; + KeyLightDirectionZ = keyLightDirection.z; + KeyLightColorR = keyLightColor.r; + KeyLightColorG = keyLightColor.g; + KeyLightColorB = keyLightColor.b; + KeyLightIntensity = keyLightIntensity; + FieldVolumes = fieldVolumes ?? Array.Empty(); + FieldParticles = fieldParticles ?? Array.Empty(); + CameraLookAt = cameraLookAt ?? ""; + CameraReconstruction = cameraReconstruction ?? ""; + TemporalQuality = temporalQuality ?? ""; + TemporalHistoryBlend = temporalHistoryBlend; + TemporalJitterScale = temporalJitterScale; + TemporalSharpening = temporalSharpening; + ExposureMode = exposureMode ?? ""; + ExposureLowPercent = exposureLowPercent; + ExposureHighPercent = exposureHighPercent; + ExposureMinimumEv = exposureMinimumEv; + ExposureMaximumEv = exposureMaximumEv; + ExposureKeyValue = exposureKeyValue; + ExposureAdaptation = exposureAdaptation ?? ""; + ExposureSpeedUp = exposureSpeedUp; + ExposureSpeedDown = exposureSpeedDown; + ColorGradingSpace = colorGradingSpace ?? ""; + AssetProviderId = assetProviderId ?? ""; + AssetVerseId = assetVerseId ?? ""; + AssetAuthorityRuntimeId = assetAuthorityRuntimeId ?? ""; + AssetRendezvousEndpoints = assetRendezvousEndpoints ?? Array.Empty(); } public string WorldRootId { get; } @@ -369,41 +529,191 @@ namespace GameCult.Eve.UnityScene public string EntityViewSchema { get; } + public string EntityBodyId { get; } + public string ZoneRenderPointerId { get; } public string ZoneRenderSchema { get; } - public string InputCapabilityPointerId { get; } - public string AssetManifest { get; } + public string AssetProviderId { get; } + + public string AssetVerseId { get; } + + public string AssetAuthorityRuntimeId { get; } + + public IReadOnlyList AssetRendezvousEndpoints { get; } + public string InputProfile { get; } public string CameraRig { get; } + public string CameraTargetEntityId { get; } + + public string CameraLookAt { get; } + + public string CameraReconstruction { get; } + + public string TemporalQuality { get; } + + public float TemporalHistoryBlend { get; } + + public float TemporalJitterScale { get; } + + public float TemporalSharpening { get; } + + public string ExposureMode { get; } + + public string ColorGradingSpace { get; } + + public float ExposureLowPercent { get; } + + public float ExposureHighPercent { get; } + + public float ExposureMinimumEv { get; } + + public float ExposureMaximumEv { get; } + + public float ExposureKeyValue { get; } + + public string ExposureAdaptation { get; } + + public float ExposureSpeedUp { get; } + + public float ExposureSpeedDown { get; } + + public float CameraDistance { get; } + + public float CameraVerticalFieldOfViewDegrees { get; } + + public float CameraTargetScreenX { get; } + + public float CameraTargetScreenY { get; } + + public float CameraPositionDamping { get; } + + public float CameraNearClipPlane { get; } + + public float CameraFarClipPlane { get; } + + public float AmbientLightR { get; } + + public float AmbientLightG { get; } + + public float AmbientLightB { get; } + + public float AmbientLightIntensity { get; } + public string ViewId { get; } public string PlayerEntityId { get; } - public string CameraTargetEntityId { get; } - - public bool SubjectVisible { get; } - - public bool MovementEnabled { get; } - - public string PresentationMode { get; } - public string MovementCommand { get; } + public string LookCommand { get; } + + public float LookSensitivityRadians { get; } + + public string LookModel { get; } + + public string SkyboxAssetRef { get; } + + public string ReflectionAssetRef { get; } + + public float ReflectionIntensity { get; } + + public string PostProcessProfileAssetRef { get; } + + public float KeyLightDirectionX { get; } + + public float KeyLightDirectionY { get; } + + public float KeyLightDirectionZ { get; } + + public float KeyLightColorR { get; } + + public float KeyLightColorG { get; } + + public float KeyLightColorB { get; } + + public float KeyLightIntensity { get; } + public string FocusCommand { get; } public string TargetCommand { get; } public string ActionCommand { get; } + public IReadOnlyList ExcludedRenderChannels { get; } + public int EntityCount => Entities.Count; public IReadOnlyList Entities { get; } + + public IReadOnlyList FieldVolumes { get; } + public IReadOnlyList FieldParticles { get; } + } + + public sealed class EveUnityFieldVolumeProjection + { + public EveUnityFieldVolumeProjection( + string nodeId, + string documentRef, + string documentSchema, + string materialAssetRef, + string renderChannel, + string compositeMode, + string quality, + IReadOnlyDictionary? props = null) + { + NodeId = nodeId ?? ""; + DocumentRef = documentRef ?? ""; + DocumentSchema = documentSchema ?? ""; + MaterialAssetRef = materialAssetRef ?? ""; + RenderChannel = renderChannel ?? ""; + CompositeMode = compositeMode ?? ""; + Quality = quality ?? ""; + Props = props ?? new Dictionary(StringComparer.Ordinal); + } + + public string NodeId { get; } + public string DocumentRef { get; } + public string DocumentSchema { get; } + public string MaterialAssetRef { get; } + public string RenderChannel { get; } + public string CompositeMode { get; } + public string Quality { get; } + public IReadOnlyDictionary Props { get; } + } + + public sealed class EveUnityFieldParticlesProjection + { + public EveUnityFieldParticlesProjection( + string nodeId, + string documentRef, + string documentSchema, + string computeProgramAssetRef, + string materialAssetRef, + string renderChannel, + IReadOnlyDictionary? props = null) + { + NodeId = nodeId ?? ""; + DocumentRef = documentRef ?? ""; + DocumentSchema = documentSchema ?? ""; + ComputeProgramAssetRef = computeProgramAssetRef ?? ""; + MaterialAssetRef = materialAssetRef ?? ""; + RenderChannel = renderChannel ?? ""; + Props = props ?? new Dictionary(StringComparer.Ordinal); + } + + public string NodeId { get; } + public string DocumentRef { get; } + public string DocumentSchema { get; } + public string ComputeProgramAssetRef { get; } + public string MaterialAssetRef { get; } + public string RenderChannel { get; } + public IReadOnlyDictionary Props { get; } } public sealed class EveUnityPlayableWorldEntity diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotReceiptPresenter.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotReceiptPresenter.cs index 5f853ce..9bd38fe 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotReceiptPresenter.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotReceiptPresenter.cs @@ -38,6 +38,9 @@ namespace GameCult.Eve.UnityScene ItemKey = Get(node, "itemKey"); ImpactKind = Get(node, "impactKind"); PresentationIntensity = Math.Max(0, Double(node, "presentationIntensity", 1)); + AppliedDamage = Math.Max(0, Double(node, "appliedDamage", 0)); + ShieldAbsorbedDamage = Math.Max(0, Double(node, "shieldAbsorbedDamage", 0)); + LockQuality = Math.Max(0, Math.Min(1, Double(node, "lockQuality", 0))); } public string ShotId { get; } @@ -54,6 +57,9 @@ namespace GameCult.Eve.UnityScene public string ItemKey { get; } public string ImpactKind { get; } public double PresentationIntensity { get; } + public double AppliedDamage { get; } + public double ShieldAbsorbedDamage { get; } + public double LockQuality { get; } private static string Get(EveUnitySceneNode node, string key) => node.Props.TryGetValue(key, out var value) ? value ?? "" : ""; diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotTrajectoryRenderer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotTrajectoryRenderer.cs index ecee9ca..4e2ca7f 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotTrajectoryRenderer.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotTrajectoryRenderer.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using UnityEngine; #nullable enable @@ -20,6 +21,7 @@ namespace GameCult.Eve.UnityScene private Material? _material; public int ActiveTrajectoryCount => _active.Count; + public int ActiveFallbackTrajectoryCount => _active.Count(trajectory => trajectory.Line != null); public void Bind(EveUnityPlayableWorldClientHost host, IEveUnityGameObjectAssetProvider? assetProvider = null) { @@ -43,19 +45,10 @@ namespace GameCult.Eve.UnityScene if (receipt == null) throw new ArgumentNullException(nameof(receipt)); var shot = new GameObject("Eve shot " + receipt.ShotId); shot.transform.SetParent(transform, false); - var line = shot.AddComponent(); - line.useWorldSpace = true; - line.positionCount = 2; - var intensityScale = Mathf.Clamp(Mathf.Sqrt((float)receipt.PresentationIntensity), 0.5f, 4f); - line.startWidth = Math.Max(0.01f, width * intensityScale); - line.endWidth = Math.Max(0.01f, width * 0.5f * intensityScale); - line.material = SharedMaterial(); - line.startColor = line.endColor = receipt.Hit ? hitColor : missColor; - var origin = Vector(receipt.Origin); - line.SetPosition(0, origin); var endpoint = Vector(receipt.Endpoint); var effectPrefab = ResolveEffectPrefab(receipt); + LineRenderer? line = null; if (effectPrefab != null) { var effect = Instantiate(effectPrefab, shot.transform); @@ -71,10 +64,23 @@ namespace GameCult.Eve.UnityScene lightning.StartAnimation(); } } + else + { + line = shot.AddComponent(); + line.useWorldSpace = true; + line.positionCount = 2; + var intensityScale = Mathf.Clamp(Mathf.Sqrt((float)receipt.PresentationIntensity), 0.5f, 4f); + line.startWidth = Math.Max(0.01f, width * intensityScale); + line.endWidth = Math.Max(0.01f, width * 0.5f * intensityScale); + line.material = SharedMaterial(); + line.startColor = line.endColor = receipt.Hit ? hitColor : missColor; + line.SetPosition(0, origin); + } var impactPrefab = ResolveRolePrefab("effect.impact." + receipt.ImpactKind); var travels = string.Equals(receipt.PresentationKind, "bolt", StringComparison.Ordinal) || string.Equals(receipt.PresentationKind, "guided", StringComparison.Ordinal); - line.SetPosition(1, travels ? origin : endpoint); + if (line != null) + line.SetPosition(1, travels ? origin : endpoint); _active.Add(new ActiveTrajectory( shot, line, @@ -93,14 +99,14 @@ namespace GameCult.Eve.UnityScene { var trajectory = _active[index]; var progress = Mathf.Clamp01((now - trajectory.StartedAt) / trajectory.Duration); - if (trajectory.Travels) + if (trajectory.Travels && trajectory.Line != null) trajectory.Line.SetPosition(1, Vector3.Lerp(trajectory.Origin, trajectory.Endpoint, progress)); if (progress >= 1 && !trajectory.ImpactPresented) { trajectory.ImpactPresented = true; if (trajectory.ImpactPrefab != null) { - var impact = Instantiate(trajectory.ImpactPrefab, transform); + var impact = Instantiate(trajectory.ImpactPrefab, trajectory.Root.transform); impact.transform.position = trajectory.Endpoint; } } @@ -146,7 +152,7 @@ namespace GameCult.Eve.UnityScene private sealed class ActiveTrajectory { - public ActiveTrajectory(GameObject root, LineRenderer line, Vector3 origin, Vector3 endpoint, float startedAt, float duration, bool travels, GameObject? impactPrefab) + public ActiveTrajectory(GameObject root, LineRenderer? line, Vector3 origin, Vector3 endpoint, float startedAt, float duration, bool travels, GameObject? impactPrefab) { Root = root; Line = line; @@ -159,7 +165,7 @@ namespace GameCult.Eve.UnityScene } public GameObject Root { get; } - public LineRenderer Line { get; } + public LineRenderer? Line { get; } public Vector3 Origin { get; } public Vector3 Endpoint { get; } public float StartedAt { get; } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityThermalHudSink.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityThermalHudSink.cs index ea9919a..1ea683d 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityThermalHudSink.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityThermalHudSink.cs @@ -30,14 +30,7 @@ namespace GameCult.Eve.UnityScene AttachToDocument(); return; } - var panel = ScriptableObject.CreateInstance(); - panel.name = "Eve thermal HUD panel"; - panel.scaleMode = PanelScaleMode.ScaleWithScreenSize; - panel.referenceResolution = new Vector2Int(1920, 1080); - panel.match = 0.5f; - _document = gameObject.AddComponent(); - _document.panelSettings = panel; - _document.sortingOrder = 100; + _document = EveUnityUiDocumentRuntime.Ensure(gameObject); _root = new VisualElement(); Build(_root); AttachToDocument(); diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiDocumentRuntime.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiDocumentRuntime.cs new file mode 100644 index 0000000..fb4481b --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiDocumentRuntime.cs @@ -0,0 +1,39 @@ +using System; +using UnityEngine; +using UnityEngine.UIElements; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + internal static class EveUnityUiDocumentRuntime + { + private const string ThemeResource = "EveUnity/UnityDefaultRuntimeTheme"; + + public static UIDocument Ensure(GameObject host) + { + var document = host.GetComponent(); + if (document != null) return document; + + var panel = host.transform.parent?.GetComponentInParent()?.panelSettings; + if (panel == null) + { + var theme = Resources.Load(ThemeResource); + if (theme == null) + throw new InvalidOperationException($"EveUnity runtime theme resource '{ThemeResource}' is missing."); + + panel = ScriptableObject.CreateInstance(); + panel.name = "Eve playable-world HUD panel"; + panel.scaleMode = PanelScaleMode.ScaleWithScreenSize; + panel.referenceResolution = new Vector2Int(1920, 1080); + panel.match = 0.5f; + panel.themeStyleSheet = theme; + } + + document = host.AddComponent(); + document.panelSettings = panel; + document.sortingOrder = 100; + return document; + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiDocumentRuntime.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiDocumentRuntime.cs.meta new file mode 100644 index 0000000..977f4f6 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiDocumentRuntime.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 894b54214cb34993a7984f73cb45349f diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiToolkitOverlay.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiToolkitOverlay.cs new file mode 100644 index 0000000..efd74a1 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiToolkitOverlay.cs @@ -0,0 +1,78 @@ +using System; +using GameCult.Eve.UnityUIToolkit; +using UnityEngine; +using UnityEngine.UIElements; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityUiToolkitOverlay : MonoBehaviour + { + private IEveUnitySceneProviderSurfaceDocumentSource? _documents; + private IEveUnitySceneCommandSink? _commands; + private UIDocument? _document; + private EveUiToolkitSurfacePresenter? _presenter; + private VisualElement? _mountedRoot; + + public VisualElement? Root => _mountedRoot; + + public void Bind(MonoBehaviour documentSource, MonoBehaviour commandSink) + { + if (documentSource is not IEveUnitySceneProviderSurfaceDocumentSource documents) + throw new ArgumentException( + $"{nameof(documentSource)} must implement {nameof(IEveUnitySceneProviderSurfaceDocumentSource)}.", + nameof(documentSource)); + if (commandSink is not IEveUnitySceneCommandSink commands) + throw new ArgumentException( + $"{nameof(commandSink)} must implement {nameof(IEveUnitySceneCommandSink)}.", + nameof(commandSink)); + + Unsubscribe(); + _documents = documents; + _commands = commands; + _documents.DocumentAvailable += OnDocumentAvailable; + EnsureDocument(); + Present(_documents.CurrentDocument); + } + + private void OnDestroy() => Unsubscribe(); + + private void OnDocumentAvailable(EveUnitySceneProviderSurfaceDocument document) => Present(document); + + private void Present(EveUnitySceneProviderSurfaceDocument document) + { + if (document == null || _commands == null) return; + _presenter ??= new EveUiToolkitSurfacePresenter(); + var replaced = _presenter.Present(document.SurfaceDocument, _commands.Submit); + if (replaced && _presenter.Root != null) + { + _mountedRoot?.RemoveFromHierarchy(); + _mountedRoot = _presenter.Root; + _mountedRoot.AddToClassList("eve-playable-world-ui-overlay"); + } + AttachToDocument(); + } + + private void EnsureDocument() + { + _document = EveUnityUiDocumentRuntime.Ensure(gameObject); + } + + private void AttachToDocument() + { + EnsureDocument(); + var documentRoot = _document?.rootVisualElement; + if (_mountedRoot != null && _mountedRoot.parent == null && documentRoot != null) + documentRoot.Add(_mountedRoot); + } + + private void Unsubscribe() + { + if (_documents != null) + _documents.DocumentAvailable -= OnDocumentAvailable; + _documents = null; + _commands = null; + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiToolkitOverlay.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiToolkitOverlay.cs.meta new file mode 100644 index 0000000..672f84b --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiToolkitOverlay.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 843283a94b8a41bab85db52bd84aa59a diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveFieldsSplatBuffer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveFieldsSplatBuffer.cs index ddd6abb..dc94d57 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveFieldsSplatBuffer.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveFieldsSplatBuffer.cs @@ -11,8 +11,9 @@ namespace GameCult.Eve.UnityScene.Fields public const string SplatCountPropertyName = "_EveFieldsSplatCount"; public const string ViewportToClipPropertyName = "_EveFieldsViewportToClip"; public const string ChannelFilterPropertyName = "_EveFieldsChannelFilter"; + public const string SimulationTimePropertyName = "_EveFieldsSimulationTimeSeconds"; - private const int SplatStrideBytes = 80; + private const int SplatStrideBytes = 96; private EveFieldsGpuSplat[] splats = Array.Empty(); private GraphicsBuffer buffer; private int capacity; @@ -54,6 +55,7 @@ namespace GameCult.Eve.UnityScene.Fields RotationChannelFalloff = new Vector4((float)Read(source.RotationCos, index, 1), (float)Read(source.RotationSin, index, 0), channel, Read(source.Falloff, index, EveFieldsSplatFalloffs.Smooth)), LayerSource = new Vector4(layer, Read(source.SourceKind, index, EveFieldsSplatSourceKinds.Constant), (float)Read(source.AnimationSpeed, index, 0), (float)Read(source.SourceFlags, index, 0)), SourceFrequencyPhase = new Vector4((float)Read(source.FrequencyX, index, 1), (float)Read(source.FrequencyY, index, 1), (float)Read(source.PhaseX, index, 0), (float)Read(source.PhaseY, index, 0)), + FalloffParameters = new Vector4((float)Read(source.FalloffScale, index, 1), (float)Read(source.FalloffExponent, index, 1), 0, 0), Value = new Vector4((float)Read(source.ValueR, index, 0), (float)Read(source.ValueG, index, 0), (float)Read(source.ValueB, index, 0), (float)Read(source.ValueA, index, 1)) }; } @@ -99,6 +101,7 @@ namespace GameCult.Eve.UnityScene.Fields public Vector4 RotationChannelFalloff; public Vector4 LayerSource; public Vector4 SourceFrequencyPhase; + public Vector4 FalloffParameters; public Vector4 Value; } } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveFieldsSplatRasterizer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveFieldsSplatRasterizer.cs index b8638a5..c87f90b 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveFieldsSplatRasterizer.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveFieldsSplatRasterizer.cs @@ -26,6 +26,7 @@ namespace GameCult.Eve.UnityScene.Fields private static readonly int SplatCountId = Shader.PropertyToID(EveFieldsSplatBuffer.SplatCountPropertyName); private static readonly int ViewportToClipId = Shader.PropertyToID(EveFieldsSplatBuffer.ViewportToClipPropertyName); private static readonly int ChannelFilterId = Shader.PropertyToID(EveFieldsSplatBuffer.ChannelFilterPropertyName); + private static readonly int SimulationTimeId = Shader.PropertyToID(EveFieldsSplatBuffer.SimulationTimePropertyName); public RenderTexture TargetTexture => targetTexture != null ? targetTexture : ownedTargetTexture; public int LastDrawnCount { get; private set; } @@ -48,6 +49,7 @@ namespace GameCult.Eve.UnityScene.Fields commandBuffer.SetGlobalBuffer(SplatBufferId, splatBuffer.Buffer); commandBuffer.SetGlobalInt(SplatCountId, splatBuffer.Count); commandBuffer.SetGlobalInt(ChannelFilterId, channelFilter); + commandBuffer.SetGlobalFloat(SimulationTimeId, (float)document.SimulationTimeSeconds); commandBuffer.SetGlobalMatrix(ViewportToClipId, BuildViewportToClip(document.Viewport)); commandBuffer.DrawProcedural(Matrix4x4.identity, material, Mathf.Max(0, materialPass), MeshTopology.Triangles, 6, splatBuffer.Count); Graphics.ExecuteCommandBuffer(commandBuffer); diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveFieldsSplatsCore.hlsl b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveFieldsSplatsCore.hlsl index a7ec7a9..316316d 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveFieldsSplatsCore.hlsl +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveFieldsSplatsCore.hlsl @@ -7,6 +7,7 @@ struct EveFieldsSplat float4 rotationChannelFalloff; float4 layerSource; float4 sourceFrequencyPhase; + float4 falloffParameters; float4 value; }; @@ -14,31 +15,105 @@ StructuredBuffer _EveFieldsSplats; float4x4 _EveFieldsViewportToClip; int _EveFieldsSplatCount; int _EveFieldsChannelFilter; +float _EveFieldsSimulationTimeSeconds; float _ValueScale; -float hash21(float2 p) +// Ashima Arts webgl-noise, translated by Keijiro Takahashi. +// Copyright (C) 2011 Ashima Arts. Distributed under the MIT License. +float3 EveFieldsMod289(float3 x) { - p = frac(p * float2(123.34, 456.21)); - p += dot(p, p + 45.32); - return frac(p.x * p.y); + return x - floor(x / 289.0) * 289.0; } -float valueNoise(float2 p) +float4 EveFieldsMod289(float4 x) { - float2 i = floor(p); - float2 f = frac(p); - float a = hash21(i); - float b = hash21(i + float2(1, 0)); - float c = hash21(i + float2(0, 1)); - float d = hash21(i + float2(1, 1)); - float2 u = f * f * (3 - 2 * f); - return lerp(lerp(a, b, u.x), lerp(c, d, u.x), u.y) * 2 - 1; + return x - floor(x / 289.0) * 289.0; +} + +float4 EveFieldsPermute(float4 x) +{ + return EveFieldsMod289((x * 34.0 + 1.0) * x); +} + +float4 EveFieldsTaylorInvSqrt(float4 r) +{ + return 1.79284291400159 - r * 0.85373472095314; +} + +float EveFieldsSimplexNoise3D(float3 v) +{ + const float2 C = float2(1.0 / 6.0, 1.0 / 3.0); + float3 i = floor(v + dot(v, C.yyy)); + float3 x0 = v - i + dot(i, C.xxx); + float3 g = step(x0.yzx, x0.xyz); + float3 l = 1.0 - g; + float3 i1 = min(g.xyz, l.zxy); + float3 i2 = max(g.xyz, l.zxy); + float3 x1 = x0 - i1 + C.xxx; + float3 x2 = x0 - i2 + C.yyy; + float3 x3 = x0 - 0.5; + i = EveFieldsMod289(i); + float4 p = EveFieldsPermute(EveFieldsPermute(EveFieldsPermute( + i.z + float4(0.0, i1.z, i2.z, 1.0)) + + i.y + float4(0.0, i1.y, i2.y, 1.0)) + + i.x + float4(0.0, i1.x, i2.x, 1.0)); + float4 j = p - 49.0 * floor(p / 49.0); + float4 x_ = floor(j / 7.0); + float4 y_ = floor(j - 7.0 * x_); + float4 x = (x_ * 2.0 + 0.5) / 7.0 - 1.0; + float4 y = (y_ * 2.0 + 0.5) / 7.0 - 1.0; + float4 h = 1.0 - abs(x) - abs(y); + float4 b0 = float4(x.xy, y.xy); + float4 b1 = float4(x.zw, y.zw); + float4 s0 = floor(b0) * 2.0 + 1.0; + float4 s1 = floor(b1) * 2.0 + 1.0; + float4 sh = -step(h, 0.0); + float4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; + float4 a1 = b1.xzyw + s1.xzyw * sh.zzww; + float3 g0 = float3(a0.xy, h.x); + float3 g1 = float3(a0.zw, h.y); + float3 g2 = float3(a1.xy, h.z); + float3 g3 = float3(a1.zw, h.w); + float4 norm = EveFieldsTaylorInvSqrt(float4( + dot(g0, g0), dot(g1, g1), dot(g2, g2), dot(g3, g3))); + g0 *= norm.x; + g1 *= norm.y; + g2 *= norm.z; + g3 *= norm.w; + float4 m = max(0.6 - float4( + dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); + m *= m; + m *= m; + return 42.0 * dot(m, float4( + dot(x0, g0), dot(x1, g1), dot(x2, g2), dot(x3, g3))); +} + +float EveFieldsCellDistanceB(float2 p) +{ + p = frac(p) - 0.5; + return (length(p) * 1.5 + 0.25) * + max(abs(p.x) * 0.866 + p.y * 0.5, -p.y); +} + +float EveFieldsAnimatedCellNoiseB(float2 p, float time) +{ + float2 o = sin(float2(1.93, 0.0) + time) * 0.166; + float a = EveFieldsCellDistanceB(p + float2(o.x, 0)); + float b = EveFieldsCellDistanceB(p + float2(0, 0.5 + o.y)); + p = mul(p + 0.5, -float2x2(0.5, -0.866, 0.866, 0.5)); + float c = EveFieldsCellDistanceB(p + float2(o.x, 0)); + float d = EveFieldsCellDistanceB(p + float2(0, 0.5 + o.y)); + p = mul(p + 0.5, -float2x2(0.5, -0.866, 0.866, 0.5)); + float e = EveFieldsCellDistanceB(p + float2(o.x, 0)); + float f = EveFieldsCellDistanceB(p + float2(0, 0.5 + o.y)); + return min(min(min(a, b), min(c, d)), min(e, f)) * 2.0; } struct Varyings { float4 position : SV_POSITION; float2 localUv : TEXCOORD0; + float2 fieldWorld : TEXCOORD2; nointerpolation uint instanceId : TEXCOORD1; }; @@ -54,16 +129,22 @@ Varyings Vert(uint vertexId : SV_VertexID, uint instanceId : SV_InstanceID) Varyings output; output.position = mul(_EveFieldsViewportToClip, float4(world, 0, 1)); output.localUv = local; + output.fieldWorld = world; output.instanceId = instanceId; return output; } -float ResolveFalloff(float2 localUv, int falloff) +float ResolveFalloff(float2 localUv, int falloff, float scale, float exponent) { float distance01 = saturate(length(localUv)); if (falloff == 0) return 1; if (falloff == 1) return saturate(1 - distance01); if (falloff == 3) return smoothstep(0, 1, distance01); + if (falloff == 4) + { + float pulseDistance = distance01 * max(0, scale); + return pow(saturate(1 - pulseDistance * pulseDistance), max(0.0001, exponent)); + } return 1 - smoothstep(0, 1, distance01); } @@ -72,16 +153,39 @@ float4 Frag(Varyings input) : SV_Target EveFieldsSplat splat = _EveFieldsSplats[input.instanceId]; int channel = (int)round(splat.rotationChannelFalloff.z); if (_EveFieldsChannelFilter >= 0 && channel != _EveFieldsChannelFilter) discard; - float alpha = ResolveFalloff(input.localUv, (int)round(splat.rotationChannelFalloff.w)); + float alpha = ResolveFalloff( + input.localUv, + (int)round(splat.rotationChannelFalloff.w), + splat.falloffParameters.x, + splat.falloffParameters.y); clip(alpha - 0.0001); int sourceKind = (int)round(splat.layerSource.y); float source = 1; if (sourceKind == 1 || sourceKind == 2) { - float timeOffset = sourceKind == 2 ? _Time.y * splat.layerSource.z : 0; - source = valueNoise(input.localUv * splat.sourceFrequencyPhase.xy + splat.sourceFrequencyPhase.zw + timeOffset); - if (splat.layerSource.w != 0) source = abs(source); + float timeCoordinate = sourceKind == 2 + ? _EveFieldsSimulationTimeSeconds * splat.layerSource.z + : 0; + source = EveFieldsSimplexNoise3D(float3( + input.fieldWorld * splat.sourceFrequencyPhase.xy + splat.sourceFrequencyPhase.zw, + timeCoordinate)); } + else if (sourceKind == 3) + { + source = EveFieldsAnimatedCellNoiseB( + input.fieldWorld * splat.sourceFrequencyPhase.xy + splat.sourceFrequencyPhase.zw, + _EveFieldsSimulationTimeSeconds * splat.layerSource.z); + } + else if (sourceKind == 4) + { + float distance01 = length(input.localUv); + float radialExponent = max(0.0001, splat.sourceFrequencyPhase.y); + source = cos( + pow(distance01, radialExponent) * splat.sourceFrequencyPhase.x + + splat.sourceFrequencyPhase.z + + _EveFieldsSimulationTimeSeconds * splat.layerSource.z); + } + if (((int)round(splat.layerSource.w) & 1) != 0) source = abs(source); return splat.value * (alpha * source * _ValueScale); } #endif diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsDocumentSource.cs b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsDocumentSource.cs new file mode 100644 index 0000000..e40a392 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsDocumentSource.cs @@ -0,0 +1,10 @@ +using System; +using GameCult.Eve.PluginFields; + +namespace GameCult.Eve.UnityScene.Fields +{ + public interface IEveUnityFieldsSplatsDocumentSource + { + event Action FieldsSplatsAvailable; + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsDocumentSource.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsDocumentSource.cs.meta new file mode 100644 index 0000000..608135d --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsDocumentSource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 01897652f3f84ae0be3a99c9f9837d9d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsParticleRenderer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsParticleRenderer.cs new file mode 100644 index 0000000..803bfd1 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsParticleRenderer.cs @@ -0,0 +1,678 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using GameCult.Eve.PluginFields; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.Rendering.RenderGraphModule; +using UnityEngine.Rendering.Universal; + +#nullable enable + +namespace GameCult.Eve.UnityScene.Fields +{ + public sealed class EveUnityFieldsParticleRenderer : MonoBehaviour, IEveUnityRenderPassSource + { + private EveUnityPlayableWorldClientHost? _host; + private IEveUnityFieldsSplatsDocumentSource? _source; + private EveFieldsSplatsDocument? _document; + private EveFieldsSplatLayerRenderer? _layers; + private GameObject? _layerRoot; + private readonly List _targets = new List(); + private ComputeShader? _computeSource; + private ComputeShader? _compute; + private Material? _materialSource; + private Material? _material; + private IReadOnlyDictionary? _computeMetadata; + private IReadOnlyDictionary? _materialMetadata; + private readonly Dictionary _assetTextures = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _materialAssetTextures = new Dictionary(StringComparer.Ordinal); + private ComputeBuffer? _particles; + private ComputeBuffer? _quadPoints; + private EveUnityFieldsParticlePass? _renderPass; + private string _activeNodeId = ""; + private long _rasterizedFrameId = -1; + private Vector2 _rasterizedCenter = new Vector2(float.NaN, float.NaN); + + public long PresentedFrameId { get; private set; } = -1; + public int ParticleCount { get; private set; } + public int DispatchCount { get; private set; } + public int DrawCount { get; private set; } + public Vector2 LastGridCenter { get; private set; } + public bool ProgramReady => _compute != null && _material != null && _particles != null && _quadPoints != null; + public static RenderPassEvent CompositionRenderPassEvent => RenderPassEvent.BeforeRenderingPostProcessing; + int IEveUnityRenderPassSource.RenderOrder => 200; + + public void Bind(EveUnityPlayableWorldClientHost host, IEveUnityFieldsSplatsDocumentSource? source) + { + if (host == null) throw new ArgumentNullException(nameof(host)); + if (_source != null) _source.FieldsSplatsAvailable -= OnFieldsSplatsAvailable; + _host = host; + _source = source; + if (_source != null) _source.FieldsSplatsAvailable += OnFieldsSplatsAvailable; + enabled = _source != null; + } + + private void OnEnable() => EveUnityRenderPassRegistry.Register(this); + + private void OnDisable() + { + EveUnityRenderPassRegistry.Unregister(this); + ReleaseState(); + } + + private void OnDestroy() + { + if (_source != null) _source.FieldsSplatsAvailable -= OnFieldsSplatsAvailable; + _source = null; + _host = null; + } + + private void OnFieldsSplatsAvailable(EveFieldsSplatsDocument document) + { + if (document == null || document.FrameId < PresentedFrameId) return; + _document = document; + PresentedFrameId = document.FrameId; + } + + bool IEveUnityRenderPassSource.TryEnqueuePass(Camera camera, ScriptableRenderer renderer) + { + if (_host == null || _document == null || camera == null || + _host.ActiveCameraTransform != camera.transform) + return false; + var field = ActiveField(); + if (field == null || !PrepareFrame(field, camera, _document)) return false; + _renderPass ??= new EveUnityFieldsParticlePass(this); + renderer.EnqueuePass(_renderPass); + return true; + } + + private EveUnityFieldParticlesProjection? ActiveField() => + _host?.ActiveWorld?.FieldParticles.FirstOrDefault(field => + string.Equals(field.DocumentSchema, EveFieldsSchemas.Splats, StringComparison.Ordinal)); + + private bool PrepareFrame( + EveUnityFieldParticlesProjection field, + Camera camera, + EveFieldsSplatsDocument document) + { + if (!EnsurePrograms(field) || !EnsureBuffers(field)) return false; + EnsureTargets(field); + EnsureLayerRenderer(); + if (_layers == null) return false; + + var baseWidth = PositiveInt(field, "textureWidth", 512); + var baseHeight = PositiveInt(field, "textureHeight", 512); + var snapLayer = Prop(field, "viewportSnapLayer"); + var snapTarget = _targets.FirstOrDefault(target => + string.Equals(target.LayerKey, snapLayer, StringComparison.Ordinal)); + var snapWidth = snapTarget == null + ? 0 + : Mathf.Max(1, Mathf.RoundToInt(baseWidth * Mathf.Max(0.01f, snapTarget.WidthScale))); + var snapHeight = snapTarget == null + ? 0 + : Mathf.Max(1, Mathf.RoundToInt(baseHeight * Mathf.Max(0.01f, snapTarget.HeightScale))); + if (!EveUnityFieldsViewportFrame.TryResolve( + document, + field.Props, + new Vector2(camera.transform.position.x, camera.transform.position.z), + snapWidth, + snapHeight, + out var projected, + out var center)) return false; + var viewportWidth = (float)(projected.Viewport.MaxX - projected.Viewport.MinX); + var viewportHeight = (float)(projected.Viewport.MaxY - projected.Viewport.MinY); + + if (_rasterizedFrameId != document.FrameId || _rasterizedCenter != center) + { + _layers.Render(projected, _targets, baseWidth, baseHeight); + _rasterizedFrameId = document.FrameId; + _rasterizedCenter = center; + } + + ApplyProgramBindings(field, document, center, viewportWidth, viewportHeight); + var kernel = ProgramKernel("update"); + if (kernel < 0) return false; + var threadGroupSize = PositiveInt(field, "threadGroupSize", 1); + var groups = Mathf.CeilToInt((float)ParticleCount / threadGroupSize); + if (groups <= 0) return false; + _compute!.Dispatch(kernel, groups, 1, 1); + DispatchCount++; + LastGridCenter = center; + return true; + } + + private bool EnsurePrograms(EveUnityFieldParticlesProjection field) + { + var provider = _host?.NativeAssetProvider; + if (provider == null || provider is not IEveUnityNativeAssetMetadataProvider metadataProvider || + string.IsNullOrWhiteSpace(field.ComputeProgramAssetRef) || + string.IsNullOrWhiteSpace(field.MaterialAssetRef)) + return false; + + var computeBinding = new EveUnityPlayableWorldAssetBinding( + field.ComputeProgramAssetRef, "", "provider-asset-ref"); + var materialBinding = new EveUnityPlayableWorldAssetBinding( + field.MaterialAssetRef, "", "provider-asset-ref"); + var compute = provider.ResolveAsset(computeBinding, typeof(ComputeShader)) as ComputeShader; + var material = provider.ResolveAsset(materialBinding, typeof(Material)) as Material; + if (compute == null || material == null || material.shader == null || !material.shader.isSupported || + !metadataProvider.TryResolveAssetMetadata(computeBinding, out var computeMetadata) || + !metadataProvider.TryResolveAssetMetadata(materialBinding, out var materialMetadata) || + !TryValidateProgramMetadata(computeMetadata, materialMetadata, out _) || + !TryValidateAdvertisedContract(field, compute, material, computeMetadata, materialMetadata)) + return false; + + var resolvedTextures = new Dictionary(StringComparer.Ordinal); + foreach (var binding in ParseBindings(Prop(field, "assetTextureBindings"))) + { + var texture = provider.ResolveAsset( + new EveUnityPlayableWorldAssetBinding(binding.Key, "", "provider-asset-ref"), + typeof(Texture)) as Texture; + if (texture == null) return false; + resolvedTextures[binding.Value] = texture; + } + var resolvedMaterialTextures = new Dictionary(StringComparer.Ordinal); + foreach (var binding in ParseBindings(Prop(field, "materialAssetTextureBindings"))) + { + var texture = provider.ResolveAsset( + new EveUnityPlayableWorldAssetBinding(binding.Key, "", "provider-asset-ref"), + typeof(Texture)) as Texture; + if (texture == null) return false; + resolvedMaterialTextures[binding.Value] = texture; + } + + if (ReferenceEquals(compute, _computeSource) && ReferenceEquals(material, _materialSource) && + string.Equals(field.NodeId, _activeNodeId, StringComparison.Ordinal) && ProgramReady && + resolvedTextures.Count == _assetTextures.Count && resolvedTextures.All(binding => + _assetTextures.TryGetValue(binding.Key, out var current) && ReferenceEquals(current, binding.Value)) && + resolvedMaterialTextures.Count == _materialAssetTextures.Count && resolvedMaterialTextures.All(binding => + _materialAssetTextures.TryGetValue(binding.Key, out var current) && ReferenceEquals(current, binding.Value))) + return true; + + ReleaseProgramsAndBuffers(); + _computeSource = compute; + _materialSource = material; + _computeMetadata = computeMetadata; + _materialMetadata = materialMetadata; + _activeNodeId = field.NodeId; + _compute = Instantiate(compute); + _material = new Material(material) { hideFlags = HideFlags.DontSave }; + _assetTextures.Clear(); + foreach (var binding in resolvedTextures) _assetTextures[binding.Key] = binding.Value; + _materialAssetTextures.Clear(); + foreach (var binding in resolvedMaterialTextures) + { + _materialAssetTextures[binding.Key] = binding.Value; + SetMaterialTexture(binding.Key, binding.Value); + } + foreach (var feature in Prop(field, "features").Split(';')) + { + var logical = feature.Trim(); + if (!string.IsNullOrWhiteSpace(logical) && + TryComputeProgramValue($"unity.particles.feature.{logical}.keyword", out var keyword) && + !string.IsNullOrWhiteSpace(keyword)) + _compute.EnableKeyword(keyword); + } + return true; + } + + public static bool TryValidateProgramMetadata( + IReadOnlyDictionary? compute, + IReadOnlyDictionary? material, + out string error) + { + if (compute == null || material == null) + return Fail("Field-particle programs require compute and material metadata.", out error); + var computeKeys = new[] + { + "unity.particles.kernel.update", + "unity.particles.bufferPort.particles", + "unity.particles.texturePort.surfaceHeight", + "unity.particles.texturePort.tint", + "unity.particles.texturePort.hue", + "unity.particles.vectorPort.viewportTransform", + "unity.particles.vectorPort.timeVector", + "unity.particles.floatPort.time", + "unity.particles.floatPort.period", + "unity.particles.floatPort.spacing", + "unity.particles.intPort.span" + }; + if (computeKeys.Any(key => !compute.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))) + return Fail("Field-particle compute metadata is missing a required logical port.", out error); + var materialKeys = new[] + { + "unity.particles.pass.render", + "unity.particles.bufferPort.particles", + "unity.particles.bufferPort.quadPoints" + }; + if (materialKeys.Any(key => !material.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value)) || + !int.TryParse(material["unity.particles.pass.render"], NumberStyles.Integer, + CultureInfo.InvariantCulture, out var pass) || pass < 0) + return Fail("Field-particle material metadata is missing a valid render pass or buffer port.", out error); + error = ""; + return true; + } + + private static bool TryValidateAdvertisedContract( + EveUnityFieldParticlesProjection field, + ComputeShader compute, + Material material, + IReadOnlyDictionary computeMetadata, + IReadOnlyDictionary materialMetadata) + { + var kernelName = computeMetadata["unity.particles.kernel.update"]; + if (!compute.HasKernel(kernelName)) return false; + var kernel = compute.FindKernel(kernelName); + compute.GetKernelThreadGroupSizes(kernel, out var nativeX, out var nativeY, out var nativeZ); + if (!int.TryParse(Prop(field, "threadGroupSize"), NumberStyles.Integer, + CultureInfo.InvariantCulture, out var advertisedGroupSize) || + advertisedGroupSize <= 0 || nativeX != advertisedGroupSize || nativeY != 1 || nativeZ != 1) + return false; + + if (!int.TryParse(materialMetadata["unity.particles.pass.render"], NumberStyles.Integer, + CultureInfo.InvariantCulture, out var pass) || pass < 0 || pass >= material.passCount) + return false; + + foreach (var binding in ParseBindingPairs(Prop(field, "layerBindings"))) + if (!HasPort(computeMetadata, "texturePort", binding.Value)) return false; + foreach (var binding in ParseBindingPairs(Prop(field, "assetTextureBindings"))) + if (!HasPort(computeMetadata, "texturePort", binding.Value)) return false; + if (!TryValidateMaterialPresentationContract(field, materialMetadata, out _)) return false; + foreach (var parameter in ParseBindingPairs(Prop(field, "floatParameters"))) + if (!HasPort(computeMetadata, "floatPort", parameter.Key) || + !TryFloat(parameter.Value, out _)) return false; + var floatParameters = ParseBindings(Prop(field, "floatParameters")); + if (!floatParameters.TryGetValue("spacing", out var spacingValue) || + !TryFloat(spacingValue, out var spacing) || + !TryFloat(Prop(field, "cellWorldSize"), out var cellWorldSize) || + Mathf.Abs(spacing - cellWorldSize) > 0.0001f || + PositiveInt(field, "gravityTexelsPerCell", 0) != + PositiveInt(field, "viewportSnapTexels", 0)) return false; + foreach (var binding in ParseBindingPairs(Prop(field, "documentFloatBindings"))) + { + var pieces = binding.Value.Split(','); + if (pieces.Length != 3 || !HasPort(computeMetadata, "floatPort", pieces[0].Trim()) || + !TryFloat(pieces[1], out _) || !TryFloat(pieces[2], out _)) return false; + } + + var timeVectorPort = Prop(field, "documentTimeVectorPort"); + if (!string.IsNullOrWhiteSpace(timeVectorPort) && + !HasPort(computeMetadata, "vectorPort", timeVectorPort)) return false; + foreach (var feature in Prop(field, "features").Split(';')) + { + var logical = feature.Trim(); + if (!string.IsNullOrWhiteSpace(logical) && + (!computeMetadata.TryGetValue($"unity.particles.feature.{logical}.keyword", out var keyword) || + string.IsNullOrWhiteSpace(keyword))) return false; + } + + return PositiveInt(field, "span", 0) > 0 && + PositiveInt(field, "particleStrideBytes", 0) > 0 && + PositiveInt(field, "textureWidth", 0) > 0 && + PositiveInt(field, "textureHeight", 0) > 0; + } + + public static bool TryValidateMaterialPresentationContract( + EveUnityFieldParticlesProjection field, + IReadOnlyDictionary? materialMetadata, + out string error) + { + if (field == null || materialMetadata == null) + return Fail("Field-particle material presentation contract is missing.", out error); + var textures = ParseBindings(Prop(field, "materialAssetTextureBindings")); + foreach (var binding in textures) + if (!HasPort(materialMetadata, "texturePort", binding.Value)) + return Fail("Field-particle material texture binding has no native logical port.", out error); + foreach (var binding in ParseBindingPairs(Prop(field, "materialViewportTextureScaleBindings"))) + { + if (!HasPort(materialMetadata, "vectorPort", binding.Key) || + !textures.Values.Contains(binding.Value)) + return Fail("Field-particle viewport texture-scale binding is incomplete.", out error); + } + var framePort = Prop(field, "materialRenderFrameIndexPort"); + if (!string.IsNullOrWhiteSpace(framePort) && !HasPort(materialMetadata, "intPort", framePort)) + return Fail("Field-particle render-frame binding has no native logical port.", out error); + error = ""; + return true; + } + + private static bool HasPort( + IReadOnlyDictionary metadata, + string kind, + string logical) => + metadata.TryGetValue($"unity.particles.{kind}.{logical}", out var native) && + !string.IsNullOrWhiteSpace(native); + + private bool EnsureBuffers(EveUnityFieldParticlesProjection field) + { + var span = PositiveInt(field, "span", 0); + var stride = PositiveInt(field, "particleStrideBytes", 0); + if (span <= 0 || span > 4096 || stride <= 0 || stride > 1024) return false; + var count64 = (long)span * span; + if (count64 <= 0 || count64 > 16_777_216) return false; + var count = (int)count64; + if (_particles != null && ParticleCount == count) return true; + + _particles?.Release(); + _quadPoints?.Release(); + _particles = new ComputeBuffer(count, stride, ComputeBufferType.Structured); + _quadPoints = new ComputeBuffer(6, sizeof(float) * 3, ComputeBufferType.Structured); + _quadPoints.SetData(new[] + { + new Vector3(-0.5f, 0.5f), new Vector3(0.5f, 0.5f), new Vector3(0.5f, -0.5f), + new Vector3(0.5f, -0.5f), new Vector3(-0.5f, -0.5f), new Vector3(-0.5f, 0.5f) + }); + ParticleCount = count; + return true; + } + + private void ApplyProgramBindings( + EveUnityFieldParticlesProjection field, + EveFieldsSplatsDocument document, + Vector2 center, + float viewportWidth, + float viewportHeight) + { + var kernel = ProgramKernel("update"); + if (kernel < 0) return; + SetComputeBuffer(kernel, "particles", _particles!); + SetMaterialBuffer("particles", _particles!); + SetMaterialBuffer("quadPoints", _quadPoints!); + + foreach (var binding in ParseBindings(Prop(field, "layerBindings"))) + { + var target = _targets.FirstOrDefault(value => string.Equals(value.LayerKey, binding.Key, StringComparison.Ordinal)); + if (target?.TargetTexture != null) SetComputeTexture(kernel, binding.Value, target.TargetTexture); + } + foreach (var binding in _assetTextures) + SetComputeTexture(kernel, binding.Key, binding.Value); + SetComputeVector("viewportTransform", new Vector4(center.x, center.y, viewportWidth, viewportHeight)); + + var time = (float)document.SimulationTimeSeconds; + var timeVectorPort = Prop(field, "documentTimeVectorPort"); + if (!string.IsNullOrWhiteSpace(timeVectorPort)) + SetComputeVector(timeVectorPort, new Vector4(time / 20f, time, time * 2f, time * 3f)); + if (TryEvaluateDocumentFloatBindings( + document, + Prop(field, "documentFloatBindings"), + out var documentFloats)) + foreach (var binding in documentFloats) + SetComputeFloat(binding.Key, binding.Value); + foreach (var parameter in ParseBindings(Prop(field, "floatParameters"))) + if (TryFloat(parameter.Value, out var value)) SetComputeFloat(parameter.Key, value); + SetComputeInt("span", PositiveInt(field, "span", 0)); + } + + private void RenderParticles( + UnsafeCommandBuffer commands, + Camera camera, + TextureHandle colorTarget, + TextureHandle depthTarget) + { + if (_host == null || _host.ActiveCameraTransform != camera.transform || !ProgramReady) return; + var field = ActiveField(); + if (field == null) return; + foreach (var binding in ParseBindings(Prop(field, "materialViewportTextureScaleBindings"))) + { + if (!_materialAssetTextures.TryGetValue(binding.Value, out var texture) || + texture.width <= 0 || texture.height <= 0) return; + SetMaterialVector(binding.Key, new Vector4( + (float)Math.Max(1, camera.pixelWidth) / texture.width, + (float)Math.Max(1, camera.pixelHeight) / texture.height, + 0f, + 0f)); + } + var framePort = Prop(field, "materialRenderFrameIndexPort"); + if (!string.IsNullOrWhiteSpace(framePort)) SetMaterialInt(framePort, Time.frameCount); + if (depthTarget.IsValid()) commands.SetRenderTarget(colorTarget, depthTarget); + else commands.SetRenderTarget(colorTarget); + commands.SetViewport(camera.pixelRect); + commands.DrawProcedural(Matrix4x4.identity, _material!, MaterialPass("render"), + MeshTopology.Triangles, 6, ParticleCount); + DrawCount++; + } + + private void EnsureTargets(EveUnityFieldParticlesProjection field) + { + var bindings = ParseBindings(Prop(field, "layerBindings")); + var descriptors = ParseBindings(Prop(field, "layerTargetDescriptors")); + if (string.Equals(_activeNodeId, field.NodeId, StringComparison.Ordinal) && + _targets.Count == bindings.Count && _targets.All(target => bindings.ContainsKey(target.LayerKey))) + return; + _targets.Clear(); + foreach (var binding in bindings) + { + var target = new EveFieldsSplatLayerTarget { LayerKey = binding.Key }; + if (descriptors.TryGetValue(binding.Key, out var descriptor)) + EveUnityFieldsVolumeRenderer.TryApplyLayerTargetDescriptor(target, descriptor); + _targets.Add(target); + } + } + + private void EnsureLayerRenderer() + { + if (_layers != null) return; + _layerRoot = new GameObject("Eve Fields Particle Layers") { hideFlags = HideFlags.DontSave }; + _layerRoot.transform.SetParent(transform, false); + _layers = _layerRoot.AddComponent(); + } + + private sealed class EveUnityFieldsParticlePass : ScriptableRenderPass + { + private readonly EveUnityFieldsParticleRenderer _owner; + private sealed class PassData + { + internal Camera Camera = null!; + internal EveUnityFieldsParticleRenderer Owner = null!; + internal TextureHandle ColorTarget; + internal TextureHandle DepthTarget; + } + + internal EveUnityFieldsParticlePass(EveUnityFieldsParticleRenderer owner) + { + _owner = owner; + renderPassEvent = CompositionRenderPassEvent; + ConfigureInput(ScriptableRenderPassInput.Depth); + } + + public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData) + { + var cameraData = frameData.Get(); + if (cameraData.camera == null || _owner._host == null || + _owner._host.ActiveCameraTransform != cameraData.camera.transform) + return; + var resources = frameData.Get(); + if (!resources.activeColorTexture.IsValid()) return; + using var builder = renderGraph.AddUnsafePass("Eve Fields Particles", out var data); + data.Camera = cameraData.camera; + data.Owner = _owner; + data.ColorTarget = resources.activeColorTexture; + data.DepthTarget = resources.activeDepthTexture; + builder.UseTexture(data.ColorTarget, AccessFlags.Write); + if (data.DepthTarget.IsValid()) builder.UseTexture(data.DepthTarget, AccessFlags.Read); + builder.AllowPassCulling(false); + builder.SetRenderFunc(static (PassData pass, UnsafeGraphContext context) => + pass.Owner.RenderParticles(context.cmd, pass.Camera, pass.ColorTarget, pass.DepthTarget)); + } + } + + private int ProgramKernel(string logical) + { + if (_compute == null || !TryComputeProgramValue($"unity.particles.kernel.{logical}", out var name) || + string.IsNullOrWhiteSpace(name) || !_compute.HasKernel(name)) + return -1; + return _compute.FindKernel(name); + } + + private int MaterialPass(string logical) => + TryMaterialProgramValue($"unity.particles.pass.{logical}", out var value) && + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pass) && pass >= 0 + ? pass + : -1; + + private void SetComputeTexture(int kernel, string logical, Texture texture) + { + if (TryComputeProgramValue($"unity.particles.texturePort.{logical}", out var property)) + _compute!.SetTexture(kernel, property, texture); + } + + private void SetComputeBuffer(int kernel, string logical, ComputeBuffer buffer) + { + if (TryComputeProgramValue($"unity.particles.bufferPort.{logical}", out var property)) + _compute!.SetBuffer(kernel, property, buffer); + } + + private void SetMaterialBuffer(string logical, ComputeBuffer buffer) + { + if (TryMaterialProgramValue($"unity.particles.bufferPort.{logical}", out var property)) + _material!.SetBuffer(property, buffer); + } + + private void SetMaterialTexture(string logical, Texture texture) + { + if (TryMaterialProgramValue($"unity.particles.texturePort.{logical}", out var property)) + _material!.SetTexture(property, texture); + } + + private void SetMaterialVector(string logical, Vector4 value) + { + if (TryMaterialProgramValue($"unity.particles.vectorPort.{logical}", out var property)) + _material!.SetVector(property, value); + } + + private void SetMaterialInt(string logical, int value) + { + if (TryMaterialProgramValue($"unity.particles.intPort.{logical}", out var property)) + _material!.SetInt(property, value); + } + + private void SetComputeFloat(string logical, float value) + { + if (TryComputeProgramValue($"unity.particles.floatPort.{logical}", out var property)) + _compute!.SetFloat(property, value); + } + + private void SetComputeInt(string logical, int value) + { + if (TryComputeProgramValue($"unity.particles.intPort.{logical}", out var property)) + _compute!.SetInt(property, value); + } + + private void SetComputeVector(string logical, Vector4 value) + { + if (TryComputeProgramValue($"unity.particles.vectorPort.{logical}", out var property)) + _compute!.SetVector(property, value); + } + + private bool TryComputeProgramValue(string key, out string value) + { + value = ""; + return _computeMetadata != null && _computeMetadata.TryGetValue(key, out value!); + } + + private bool TryMaterialProgramValue(string key, out string value) + { + value = ""; + return _materialMetadata != null && _materialMetadata.TryGetValue(key, out value!); + } + + private void ReleaseState() + { + ReleaseProgramsAndBuffers(); + if (_layerRoot != null) Destroy(_layerRoot); + _layerRoot = null; + _layers = null; + _targets.Clear(); + _document = null; + PresentedFrameId = -1; + DispatchCount = 0; + DrawCount = 0; + LastGridCenter = default; + _rasterizedFrameId = -1; + _rasterizedCenter = new Vector2(float.NaN, float.NaN); + } + + private void ReleaseProgramsAndBuffers() + { + _particles?.Release(); + _quadPoints?.Release(); + _particles = null; + _quadPoints = null; + ParticleCount = 0; + if (_compute != null) Destroy(_compute); + if (_material != null) Destroy(_material); + _compute = null; + _material = null; + _computeSource = null; + _materialSource = null; + _computeMetadata = null; + _materialMetadata = null; + _assetTextures.Clear(); + _materialAssetTextures.Clear(); + _activeNodeId = ""; + } + + private static bool Fail(string message, out string error) + { + error = message; + return false; + } + + private static Dictionary ParseBindings(string value) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var pair in ParseBindingPairs(value)) result[pair.Key] = pair.Value; + return result; + } + + private static IReadOnlyList> ParseBindingPairs(string value) + { + var result = new List>(); + foreach (var pair in (value ?? "").Split(';')) + { + var separator = pair.IndexOf('='); + if (separator <= 0 || separator >= pair.Length - 1) continue; + result.Add(new KeyValuePair( + pair.Substring(0, separator).Trim(), + pair.Substring(separator + 1).Trim())); + } + return result; + } + + public static bool TryEvaluateDocumentFloatBindings( + EveFieldsSplatsDocument document, + string bindings, + out IReadOnlyList> values) + { + var result = new List>(); + foreach (var binding in ParseBindingPairs(bindings)) + { + if (!EveUnityFieldsVolumeRenderer.TryEvaluateDocumentFloatBinding( + document, binding.Key, binding.Value, out var port, out var value)) + { + values = Array.Empty>(); + return false; + } + result.Add(new KeyValuePair(port, value)); + } + values = result; + return true; + } + + private static string Prop(EveUnityFieldParticlesProjection field, string key) => + field.Props.TryGetValue(key, out var value) ? value ?? "" : ""; + + private static int PositiveInt(EveUnityFieldParticlesProjection field, string key, int fallback) => + int.TryParse(Prop(field, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) && value > 0 + ? value + : fallback; + + private static float PositiveFloat(EveUnityFieldParticlesProjection field, string key, float fallback) => + TryFloat(Prop(field, key), out var value) && value > 0f ? value : fallback; + + private static bool TryFloat(string value, out float result) => + float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out result) && float.IsFinite(result); + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsParticleRenderer.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsParticleRenderer.cs.meta new file mode 100644 index 0000000..0e14dd5 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsParticleRenderer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e403315156af1eb4e9cebaa4cec1bcb8 \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsViewportFrame.cs b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsViewportFrame.cs new file mode 100644 index 0000000..608a02c --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsViewportFrame.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using GameCult.Eve.PluginFields; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene.Fields +{ + public static class EveUnityFieldsViewportFrame + { + public static bool TryResolve( + EveFieldsSplatsDocument source, + IReadOnlyDictionary props, + Vector2 cameraPosition, + int snapTextureWidth, + int snapTextureHeight, + out EveFieldsSplatsDocument projected, + out Vector2 center) + { + projected = source; + center = default; + if (source == null || source.Viewport == null) return false; + + var width = (float)(source.Viewport.MaxX - source.Viewport.MinX); + var height = (float)(source.Viewport.MaxY - source.Viewport.MinY); + if (!float.IsFinite(width) || !float.IsFinite(height) || width <= 0f || height <= 0f) + return false; + + center = new Vector2( + (float)((source.Viewport.MinX + source.Viewport.MaxX) * 0.5), + (float)((source.Viewport.MinY + source.Viewport.MaxY) * 0.5)); + if (!string.Equals(Prop(props, "viewportAnchor"), "active-camera.xz", StringComparison.Ordinal)) + return true; + + var span = PositiveInt(props, "span"); + var cellWorldSize = PositiveFloat(props, "cellWorldSize"); + var snapTexels = PositiveInt(props, "viewportSnapTexels"); + if (!TryValidateSpatialLattice( + width, height, snapTextureWidth, snapTextureHeight, + span, cellWorldSize, snapTexels)) + return false; + + center.x = SnapCameraCoordinate(cameraPosition.x, width, snapTextureWidth, snapTexels); + center.y = SnapCameraCoordinate(cameraPosition.y, height, snapTextureHeight, snapTexels); + projected = Project(source, center, width, height); + return true; + } + + public static float SnapCameraCoordinate( + float coordinate, + float viewportWidth, + int snapTargetWidth, + float snapTexels) + { + if (!float.IsFinite(coordinate) || !float.IsFinite(viewportWidth) || + !float.IsFinite(snapTexels) || viewportWidth <= 0f || + snapTargetWidth <= 0 || snapTexels <= 0f) + throw new ArgumentOutOfRangeException(nameof(viewportWidth)); + var snapLength = viewportWidth * snapTexels / snapTargetWidth; + return (int)(coordinate / snapLength) * snapLength; + } + + public static bool TryValidateSpatialLattice( + float viewportWidth, + float viewportHeight, + int gravityTextureWidth, + int gravityTextureHeight, + int span, + float cellWorldSize, + int gravityTexelsPerCell) + { + if (!float.IsFinite(viewportWidth) || !float.IsFinite(viewportHeight) || + !float.IsFinite(cellWorldSize) || viewportWidth <= 0f || viewportHeight <= 0f || + gravityTextureWidth <= 0 || gravityTextureHeight <= 0 || span <= 0 || + cellWorldSize <= 0f || gravityTexelsPerCell <= 0) + return false; + var expectedExtent = span * cellWorldSize; + var expectedCellFromX = viewportWidth * gravityTexelsPerCell / gravityTextureWidth; + var expectedCellFromY = viewportHeight * gravityTexelsPerCell / gravityTextureHeight; + const float tolerance = 0.0001f; + return Mathf.Abs(viewportWidth - expectedExtent) <= tolerance && + Mathf.Abs(viewportHeight - expectedExtent) <= tolerance && + Mathf.Abs(expectedCellFromX - cellWorldSize) <= tolerance && + Mathf.Abs(expectedCellFromY - cellWorldSize) <= tolerance; + } + + private static EveFieldsSplatsDocument Project( + EveFieldsSplatsDocument source, + Vector2 center, + float width, + float height) => + new EveFieldsSplatsDocument + { + Schema = source.Schema, + FrameId = source.FrameId, + PublishedAtUtc = source.PublishedAtUtc, + SimulationTimeSeconds = source.SimulationTimeSeconds, + RunId = source.RunId, + ZoneIndex = source.ZoneIndex, + ZoneName = source.ZoneName, + Viewport = new EveFieldsViewport + { + MinX = center.x - width * 0.5, + MinY = center.y - height * 0.5, + MaxX = center.x + width * 0.5, + MaxY = center.y + height * 0.5 + }, + Layers = source.Layers, + Splats = source.Splats + }; + + private static string Prop(IReadOnlyDictionary props, string key) => + props != null && props.TryGetValue(key, out var value) ? value ?? "" : ""; + + private static int PositiveInt(IReadOnlyDictionary props, string key) => + int.TryParse(Prop(props, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) && value > 0 + ? value + : 0; + + private static float PositiveFloat(IReadOnlyDictionary props, string key) => + float.TryParse(Prop(props, key), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) && + float.IsFinite(value) && value > 0f + ? value + : 0f; + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsViewportFrame.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsViewportFrame.cs.meta new file mode 100644 index 0000000..9ad030b --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsViewportFrame.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e9db43b5b96144a3a0df57a791b53547 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsVolumeRenderer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsVolumeRenderer.cs new file mode 100644 index 0000000..24597aa --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsVolumeRenderer.cs @@ -0,0 +1,746 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using GameCult.Eve.PluginFields; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.Rendering.RenderGraphModule; +using UnityEngine.Rendering.Universal; + +#nullable enable + +namespace GameCult.Eve.UnityScene.Fields +{ + public sealed class EveUnityFieldsVolumeRenderer : MonoBehaviour, IEveUnityRenderPassSource + { + private EveUnityPlayableWorldClientHost? _host; + private IEveUnityFieldsSplatsDocumentSource? _source; + private EveFieldsSplatsDocument? _document; + private EveFieldsSplatLayerRenderer? _layers; + private readonly List _targets = new List(); + private Material? _material; + private Shader? _sourceShader; + private IReadOnlyDictionary? _programMetadata; + private readonly Dictionary _assetTexturesByPort = + new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _cameraRenderStates = + new Dictionary(); + private EveUnityFieldsVolumePass? _renderPass; + private string _activeNodeId = ""; + private long _rasterizedFrameId = -1; + private Vector2 _rasterizedCenter = new Vector2(float.NaN, float.NaN); + + public long PresentedFrameId { get; private set; } = -1; + public int PresentedLayerCount => _targets.Count(target => target.TargetTexture != null); + public long CompositeCount { get; private set; } + public bool UsesTemporalHistory => HasTemporalProgram(); + public Vector2 LastGridCenter { get; private set; } + public static RenderPassEvent CompositionRenderPassEvent => RenderPassEvent.BeforeRenderingPostProcessing; + int IEveUnityRenderPassSource.RenderOrder => 100; + + public void Bind(EveUnityPlayableWorldClientHost host, IEveUnityFieldsSplatsDocumentSource? source) + { + if (host == null) throw new ArgumentNullException(nameof(host)); + if (_source != null) _source.FieldsSplatsAvailable -= OnFieldsSplatsAvailable; + _host = host; + _source = source; + if (_source != null) _source.FieldsSplatsAvailable += OnFieldsSplatsAvailable; + enabled = _source != null; + } + + private void OnEnable() => EveUnityRenderPassRegistry.Register(this); + + private void OnDisable() + { + EveUnityRenderPassRegistry.Unregister(this); + ReleaseRenderState(); + } + + private void OnDestroy() + { + if (_source != null) _source.FieldsSplatsAvailable -= OnFieldsSplatsAvailable; + _source = null; + _host = null; + } + + private void OnFieldsSplatsAvailable(EveFieldsSplatsDocument document) + { + if (document == null || document.FrameId < PresentedFrameId) return; + _document = document; + PresentedFrameId = document.FrameId; + } + + private bool TryRenderLayers( + EveFieldsSplatsDocument document, + Camera camera, + out EveFieldsSplatsDocument projected) + { + projected = document; + var field = ActiveField(); + if (field == null) return false; + EnsureLayerRenderer(); + EnsureTargets(field); + var width = PositiveInt(field, "textureWidth", 512); + var height = PositiveInt(field, "textureHeight", 512); + var snapLayer = Prop(field, "viewportSnapLayer"); + var snapTarget = _targets.FirstOrDefault(target => + string.Equals(target.LayerKey, snapLayer, StringComparison.Ordinal)); + var snapWidth = snapTarget == null + ? 0 + : Mathf.Max(1, Mathf.RoundToInt(width * Mathf.Max(0.01f, snapTarget.WidthScale))); + var snapHeight = snapTarget == null + ? 0 + : Mathf.Max(1, Mathf.RoundToInt(height * Mathf.Max(0.01f, snapTarget.HeightScale))); + if (!EveUnityFieldsViewportFrame.TryResolve( + document, + field.Props, + new Vector2(camera.transform.position.x, camera.transform.position.z), + snapWidth, + snapHeight, + out projected, + out var center)) return false; + if (_rasterizedFrameId != document.FrameId || _rasterizedCenter != center) + { + _layers!.Render(projected, _targets, width, height); + _rasterizedFrameId = document.FrameId; + _rasterizedCenter = center; + } + LastGridCenter = center; + if (!EnsureMaterial(field)) return false; + ApplyFieldBindings(field, projected); + return true; + } + + bool IEveUnityRenderPassSource.TryEnqueuePass(Camera camera, ScriptableRenderer renderer) + { + if (_host == null || _document == null || camera == null || + !CanRenderCamera(_host, camera)) + return false; + _renderPass ??= new EveUnityFieldsVolumePass(this); + renderer.EnqueuePass(_renderPass); + return true; + } + + internal static bool CanRenderCamera(EveUnityPlayableWorldClientHost host, Camera camera) => + camera.cameraType == CameraType.SceneView || host.ActiveCameraTransform == camera.transform; + + private void RenderVolume( + UnsafeCommandBuffer commands, + Camera camera, + TextureHandle colorTarget, + TextureHandle depthTarget) + { + if (_host == null || _document == null || camera == null || + !CanRenderCamera(_host, camera)) + return; + var field = ActiveField(); + if (field == null || !TryRenderLayers(_document, camera, out _)) return; + camera.depthTextureMode |= DepthTextureMode.Depth; + var state = CameraState(camera); + EnsureVolumeTextures(camera, field, state); + if (state.RaymarchTexture == null || !ApplyViewportTextureScaleBindings(field, camera)) return; + ApplyQuality(field, state.HistoryTextureIndex < 0); + + var gpuProjection = GL.GetGPUProjectionMatrix(camera.projectionMatrix, true); + var nonRenderTargetProjection = GL.GetGPUProjectionMatrix(camera.projectionMatrix, false); + var viewProjection = gpuProjection * camera.worldToCameraMatrix; + SetMatrixPort("cameraInverseViewProjection", viewProjection.inverse); + SetMatrixPort("cameraToWorld", CameraTransformToWorld(camera)); + SetVectorPort("cameraProjectionExtents", ProjectionExtents(camera)); + SetFloatPort("raymarchOffset", Halton(Time.frameCount & 1023, 3)); + + var raymarchPass = ProgramPass("raymarch"); + var compositePass = ProgramPass("composite"); + var temporalPass = ProgramPass("temporal"); + var useTemporalHistory = temporalPass >= 0; + var historyWriteIndex = -1; + RenderTexture finalCloudTexture = state.RaymarchTexture; + + if (useTemporalHistory) + { + historyWriteIndex = state.HistoryTextureIndex < 0 ? 0 : (state.HistoryTextureIndex + 1) % state.HistoryTextures.Length; + var historyReadTexture = state.HistoryTextureIndex < 0 + ? state.RaymarchTexture + : state.HistoryTextures[state.HistoryTextureIndex]; + finalCloudTexture = state.HistoryTextures[historyWriteIndex]!; + SetTexturePort("currentSample", state.RaymarchTexture); + SetTexturePort("history", historyReadTexture!); + SetMatrixPort( + "previousViewProjection", + state.HistoryTextureIndex < 0 + ? viewProjection + : ResolvePreviousViewProjection( + _programMetadata, + state.PreviousViewProjection, + gpuProjection, + nonRenderTargetProjection, + state.PreviousView)); + SetFloatPort("resetHistory", state.HistoryTextureIndex < 0 ? 1f : 0f); + } + SetTexturePort("cloud", finalCloudTexture); + + commands.SetRenderTarget(state.RaymarchTexture); + commands.SetViewport(new Rect(0f, 0f, state.RaymarchTexture.width, state.RaymarchTexture.height)); + commands.ClearRenderTarget(false, true, Color.clear); + commands.DrawProcedural(Matrix4x4.identity, _material!, raymarchPass, MeshTopology.Triangles, 3, 1); + if (useTemporalHistory) + { + commands.SetRenderTarget(finalCloudTexture); + commands.SetViewport(new Rect(0f, 0f, finalCloudTexture.width, finalCloudTexture.height)); + commands.ClearRenderTarget(false, true, Color.clear); + commands.DrawProcedural(Matrix4x4.identity, _material!, temporalPass, MeshTopology.Triangles, 3, 1); + } + if (depthTarget.IsValid()) + commands.SetRenderTarget(colorTarget, depthTarget); + else + commands.SetRenderTarget(colorTarget); + commands.SetViewport(camera.pixelRect); + commands.DrawProcedural(Matrix4x4.identity, _material!, compositePass, MeshTopology.Triangles, 3, 1); + if (useTemporalHistory) + { + state.HistoryTextureIndex = historyWriteIndex; + state.PreviousViewProjection = viewProjection; + state.PreviousView = camera.worldToCameraMatrix; + } + CompositeCount++; + } + + private EveUnityFieldVolumeProjection? ActiveField() => + _host?.ActiveWorld?.FieldVolumes.FirstOrDefault(field => + string.Equals(field.DocumentSchema, EveFieldsSchemas.Splats, StringComparison.Ordinal)); + + private bool EnsureMaterial(EveUnityFieldVolumeProjection field) + { + var provider = _host?.NativeAssetProvider; + if (provider == null || string.IsNullOrWhiteSpace(field.MaterialAssetRef)) return false; + var shader = provider.ResolveAsset( + new EveUnityPlayableWorldAssetBinding(field.MaterialAssetRef, "", "provider-asset-ref"), + typeof(Shader)) as Shader; + if (shader == null || !shader.isSupported) return false; + var shaderBinding = new EveUnityPlayableWorldAssetBinding(field.MaterialAssetRef, "", "provider-asset-ref"); + if (provider is not IEveUnityNativeAssetMetadataProvider metadataProvider || + !metadataProvider.TryResolveAssetMetadata(shaderBinding, out var metadata)) + return false; + _programMetadata = metadata; + if (!TryValidateProgramMetadata(metadata, out _)) + return false; + if (_material != null && ReferenceEquals(shader, _sourceShader) && + string.Equals(_activeNodeId, field.NodeId, StringComparison.Ordinal)) return true; + if (_material != null) Destroy(_material); + _sourceShader = shader; + _activeNodeId = field.NodeId; + _material = new Material(shader) { hideFlags = HideFlags.DontSave }; + _assetTexturesByPort.Clear(); + ResetTemporalHistory(); + foreach (var parameter in ParseBindings(Prop(field, "floatParameters"))) + { + if (TryFloat(parameter.Value, out var value)) SetFloatPort(parameter.Key, value); + } + foreach (var parameter in ParseBindings(Prop(field, "vectorParameters"))) + if (TryVector(parameter.Value, out var vector)) SetVectorPort(parameter.Key, vector); + foreach (var binding in ParseBindings(Prop(field, "assetTextureBindings"))) + { + var texture = provider.ResolveAsset( + new EveUnityPlayableWorldAssetBinding(binding.Key, "", "provider-asset-ref"), + typeof(Texture)) as Texture; + if (texture != null) + { + _assetTexturesByPort[binding.Value] = texture; + SetTexturePort(binding.Value, texture); + } + } + foreach (var feature in Prop(field, "features").Split(';')) + { + var logicalFeature = feature.Trim(); + if (!string.IsNullOrWhiteSpace(logicalFeature) && + TryProgramValue($"unity.volume.feature.{logicalFeature}.keyword", out var featureKeyword) && + !string.IsNullOrWhiteSpace(featureKeyword)) + _material.EnableKeyword(featureKeyword); + } + return true; + } + + private void ApplyQuality(EveUnityFieldVolumeProjection field, bool bootstrap) + { + if (_material == null || _programMetadata == null) return; + foreach (var binding in _programMetadata) + { + if (binding.Key.StartsWith("unity.volume.quality.", StringComparison.Ordinal) && + binding.Key.EndsWith(".keyword", StringComparison.Ordinal) && + !string.IsNullOrWhiteSpace(binding.Value)) + _material.DisableKeyword(binding.Value); + } + var quality = ResolveQuality(_programMetadata, Prop(field, "quality"), bootstrap); + if (TryProgramValue($"unity.volume.quality.{quality}.keyword", out var keyword) && + !string.IsNullOrWhiteSpace(keyword)) + _material.EnableKeyword(keyword); + } + + public static string ResolveQuality( + IReadOnlyDictionary? metadata, + string steadyQuality, + bool bootstrap) + { + if (bootstrap && metadata != null && + metadata.TryGetValue("unity.volume.quality.bootstrap", out var bootstrapQuality) && + !string.IsNullOrWhiteSpace(bootstrapQuality)) + return bootstrapQuality.Trim().ToLowerInvariant(); + return (steadyQuality ?? "").Trim().ToLowerInvariant(); + } + + private void ApplyFieldBindings(EveUnityFieldVolumeProjection field, EveFieldsSplatsDocument document) + { + if (_material == null) return; + var bindings = ParseBindings(Prop(field, "layerBindings")); + foreach (var binding in bindings) + { + var target = _targets.FirstOrDefault(value => string.Equals(value.LayerKey, binding.Key, StringComparison.Ordinal)); + if (target?.TargetTexture != null) SetTexturePort(binding.Value, target.TargetTexture); + } + var viewport = document.Viewport; + var width = viewport.MaxX - viewport.MinX; + var height = viewport.MaxY - viewport.MinY; + SetVectorPort("viewportTransform", new Vector4( + (float)((viewport.MinX + viewport.MaxX) * 0.5), + (float)((viewport.MinY + viewport.MaxY) * 0.5), + (float)width, + (float)height)); + foreach (var binding in ParseBindings(Prop(field, "documentFloatBindings"))) + { + if (TryEvaluateDocumentFloatBinding( + document, + binding.Key, + binding.Value, + out var port, + out var value)) + SetFloatPort(port, value); + } + } + + public static bool TryEvaluateDocumentFloatBinding( + EveFieldsSplatsDocument? document, + string source, + string descriptor, + out string port, + out float value) + { + port = ""; + value = 0f; + if (document == null) return false; + var values = (descriptor ?? "").Split(','); + if (values.Length != 3 || + string.IsNullOrWhiteSpace(values[0]) || + !TryFloat(values[1].Trim(), out var scale) || + !TryFloat(values[2].Trim(), out var offset)) + return false; + + double sourceValue; + switch ((source ?? "").Trim()) + { + case "simulationTimeSeconds": + sourceValue = document.SimulationTimeSeconds; + break; + default: + return false; + } + + port = values[0].Trim(); + value = (float)(sourceValue * scale + offset); + return float.IsFinite(value); + } + + private void EnsureTargets(EveUnityFieldVolumeProjection field) + { + var bindings = ParseBindings(Prop(field, "layerBindings")); + var descriptors = ParseBindings(Prop(field, "layerTargetDescriptors")); + if (string.Equals(_activeNodeId, field.NodeId, StringComparison.Ordinal) && + _targets.Count == bindings.Count && + _targets.All(target => bindings.ContainsKey(target.LayerKey))) + { + foreach (var target in _targets) + ApplyLayerTargetDescriptor(target, descriptors); + return; + } + _targets.Clear(); + foreach (var binding in bindings) + { + var target = new EveFieldsSplatLayerTarget { LayerKey = binding.Key }; + ApplyLayerTargetDescriptor(target, descriptors); + _targets.Add(target); + } + } + + private static void ApplyLayerTargetDescriptor( + EveFieldsSplatLayerTarget target, + IReadOnlyDictionary descriptors) + { + target.WidthScale = 1f; + target.HeightScale = 1f; + target.UseMipMaps = false; + target.FilterMode = FilterMode.Bilinear; + if (descriptors.TryGetValue(target.LayerKey, out var descriptor)) + TryApplyLayerTargetDescriptor(target, descriptor); + } + + public static bool TryApplyLayerTargetDescriptor(EveFieldsSplatLayerTarget? target, string descriptor) + { + if (target == null) return false; + var values = (descriptor ?? "").Split(','); + if (values.Length != 4 || + !TryFloat(values[0].Trim(), out var widthScale) || widthScale <= 0 || + !TryFloat(values[1].Trim(), out var heightScale) || heightScale <= 0 || + !bool.TryParse(values[2].Trim(), out var useMipMaps) || + !Enum.TryParse(values[3].Trim(), true, out FilterMode filterMode)) + return false; + target.WidthScale = widthScale; + target.HeightScale = heightScale; + target.UseMipMaps = useMipMaps; + target.FilterMode = filterMode; + return true; + } + + private void EnsureLayerRenderer() + { + if (_layers != null) return; + _layers = GetComponent(); + if (_layers == null) _layers = gameObject.AddComponent(); + } + + private CameraRenderState CameraState(Camera camera) + { + var cameraId = camera.GetInstanceID(); + if (_cameraRenderStates.TryGetValue(cameraId, out var state)) return state; + state = new CameraRenderState(); + _cameraRenderStates.Add(cameraId, state); + return state; + } + + private void EnsureVolumeTextures( + Camera camera, + EveUnityFieldVolumeProjection field, + CameraRenderState state) + { + var downsample = Mathf.Clamp(NonNegativeInt(field, "downsample", 1), 0, 3); + var width = Mathf.Max(1, camera.pixelWidth >> downsample); + var height = Mathf.Max(1, camera.pixelHeight >> downsample); + var resetHistory = EnsureRenderTexture( + ref state.RaymarchTexture, + width, + height, + $"Eve Fields Volume Raymarch {camera.GetInstanceID()}"); + if (HasTemporalProgram()) + { + resetHistory |= EnsureRenderTexture(ref state.HistoryTextures[0], width, height, "Eve Fields Volume History A"); + resetHistory |= EnsureRenderTexture(ref state.HistoryTextures[1], width, height, "Eve Fields Volume History B"); + } + else + { + ReleaseRenderTexture(ref state.HistoryTextures[0]); + ReleaseRenderTexture(ref state.HistoryTextures[1]); + resetHistory = true; + } + if (resetHistory) ResetTemporalHistory(state); + } + + private void ReleaseRenderState() + { + foreach (var state in _cameraRenderStates.Values) + { + ReleaseRenderTexture(ref state.RaymarchTexture); + ReleaseRenderTexture(ref state.HistoryTextures[0]); + ReleaseRenderTexture(ref state.HistoryTextures[1]); + } + _cameraRenderStates.Clear(); + if (_material != null) { Destroy(_material); _material = null; } + _sourceShader = null; + _programMetadata = null; + _assetTexturesByPort.Clear(); + _activeNodeId = ""; + _rasterizedFrameId = -1; + _rasterizedCenter = new Vector2(float.NaN, float.NaN); + LastGridCenter = default; + PresentedFrameId = -1; + CompositeCount = 0; + ResetTemporalHistory(); + } + + private sealed class EveUnityFieldsVolumePass : ScriptableRenderPass + { + private readonly EveUnityFieldsVolumeRenderer _owner; + + private sealed class PassData + { + internal Camera Camera = null!; + internal EveUnityFieldsVolumeRenderer Owner = null!; + internal TextureHandle ColorTarget; + internal TextureHandle DepthTarget; + } + + internal EveUnityFieldsVolumePass(EveUnityFieldsVolumeRenderer owner) + { + _owner = owner; + renderPassEvent = CompositionRenderPassEvent; + ConfigureInput(ScriptableRenderPassInput.Depth); + } + + public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData) + { + var cameraData = frameData.Get(); + if (cameraData.camera == null || _owner._host == null || + !CanRenderCamera(_owner._host, cameraData.camera)) + return; + + var resourceData = frameData.Get(); + var colorTarget = resourceData.activeColorTexture; + if (!colorTarget.IsValid()) return; + + using var builder = renderGraph.AddUnsafePass("Eve Fields Volume", out var passData); + passData.Camera = cameraData.camera; + passData.Owner = _owner; + passData.ColorTarget = colorTarget; + passData.DepthTarget = resourceData.activeDepthTexture; + + builder.UseTexture(passData.ColorTarget, AccessFlags.Write); + if (passData.DepthTarget.IsValid()) + builder.UseTexture(passData.DepthTarget, AccessFlags.Read); + builder.AllowPassCulling(false); + builder.SetRenderFunc(static (PassData data, UnsafeGraphContext context) => + data.Owner.RenderVolume(context.cmd, data.Camera, data.ColorTarget, data.DepthTarget)); + } + } + + private void ResetTemporalHistory() + { + foreach (var state in _cameraRenderStates.Values) ResetTemporalHistory(state); + } + + private static void ResetTemporalHistory(CameraRenderState state) + { + state.HistoryTextureIndex = -1; + state.PreviousViewProjection = Matrix4x4.identity; + state.PreviousView = Matrix4x4.identity; + } + + private sealed class CameraRenderState + { + internal RenderTexture? RaymarchTexture; + internal readonly RenderTexture?[] HistoryTextures = new RenderTexture?[2]; + internal int HistoryTextureIndex = -1; + internal Matrix4x4 PreviousViewProjection = Matrix4x4.identity; + internal Matrix4x4 PreviousView = Matrix4x4.identity; + } + + public static Matrix4x4 ResolvePreviousViewProjection( + IReadOnlyDictionary? metadata, + Matrix4x4 previousViewProjection, + Matrix4x4 currentGpuProjection, + Matrix4x4 currentNonRenderTargetProjection, + Matrix4x4 previousView) + { + if (metadata == null || + !metadata.TryGetValue("unity.volume.matrixSemantic.previousViewProjection", out var semantic)) + return previousViewProjection; + if (string.Equals(semantic, "current-projection.previous-view.v1", StringComparison.Ordinal)) + return currentGpuProjection * previousView; + if (string.Equals(semantic, "non-render-target-projection.previous-view.v1", StringComparison.Ordinal)) + return currentNonRenderTargetProjection * previousView; + return previousViewProjection; + } + + private bool HasTemporalProgram() => ProgramPass("temporal") >= 0; + + private bool ApplyViewportTextureScaleBindings(EveUnityFieldVolumeProjection field, Camera camera) + { + foreach (var binding in ParseBindings(Prop(field, "viewportTextureScaleBindings"))) + { + if (string.IsNullOrWhiteSpace(ProgramPort("vector", binding.Key)) || + string.IsNullOrWhiteSpace(ProgramPort("texture", binding.Value)) || + !_assetTexturesByPort.TryGetValue(binding.Value, out var texture) || + !TryComputeViewportTextureScale(camera.pixelWidth, camera.pixelHeight, texture, out var scale)) + return false; + SetVectorPort(binding.Key, scale); + } + return true; + } + + public static bool TryComputeViewportTextureScale( + int viewportWidth, + int viewportHeight, + Texture? texture, + out Vector4 scale) + { + scale = default; + if (texture == null || texture.width <= 0 || texture.height <= 0) return false; + scale = new Vector4( + (float)Math.Max(1, viewportWidth) / texture.width, + (float)Math.Max(1, viewportHeight) / texture.height, + 0f, + 0f); + return true; + } + + private static bool EnsureRenderTexture(ref RenderTexture? texture, int width, int height, string name) + { + if (texture != null && texture.width == width && texture.height == height) return false; + ReleaseRenderTexture(ref texture); + texture = new RenderTexture(width, height, 0, RenderTextureFormat.ARGBFloat) + { + name = name, + filterMode = FilterMode.Bilinear, + wrapMode = TextureWrapMode.Clamp + }; + texture.Create(); + return true; + } + + private static void ReleaseRenderTexture(ref RenderTexture? texture) + { + if (texture == null) return; + texture.Release(); + Destroy(texture); + texture = null; + } + + public static bool TryValidateProgramMetadata( + IReadOnlyDictionary? metadata, + out string error) + { + error = ""; + if (metadata == null) return Fail("Native volume program metadata is missing.", out error); + if (!ValidPass(metadata, "raymarch")) return Fail("Native volume raymarch pass is missing.", out error); + if (!ValidPass(metadata, "composite")) return Fail("Native volume composite pass is missing.", out error); + if (!ValidPort(metadata, "texture", "cloud") || + !ValidPort(metadata, "matrix", "cameraInverseViewProjection") || + !ValidPort(metadata, "vector", "cameraProjectionExtents") || + !ValidPort(metadata, "vector", "viewportTransform") || + !ValidPort(metadata, "float", "raymarchOffset")) + return Fail("Native volume program is missing a required raymarch or composite port.", out error); + + if (!metadata.ContainsKey("unity.volume.pass.temporal")) return true; + if (!ValidPass(metadata, "temporal")) return Fail("Native volume temporal pass is invalid.", out error); + if (!ValidPort(metadata, "texture", "currentSample") || + !ValidPort(metadata, "texture", "history") || + !ValidPort(metadata, "matrix", "previousViewProjection") || + !ValidPort(metadata, "float", "resetHistory")) + return Fail("Native volume temporal pass is missing a required history port.", out error); + if (metadata.TryGetValue("unity.volume.matrixSemantic.previousViewProjection", out var semantic) && + !string.Equals(semantic, "previous-view-projection.v1", StringComparison.Ordinal) && + !string.Equals(semantic, "current-projection.previous-view.v1", StringComparison.Ordinal) && + !string.Equals(semantic, "non-render-target-projection.previous-view.v1", StringComparison.Ordinal)) + return Fail("Native volume temporal pass has an unsupported previous-view-projection semantic.", out error); + if (metadata.TryGetValue("unity.volume.quality.bootstrap", out var bootstrapQuality) && + (!metadata.TryGetValue( + $"unity.volume.quality.{bootstrapQuality.Trim().ToLowerInvariant()}.keyword", + out var bootstrapKeyword) || string.IsNullOrWhiteSpace(bootstrapKeyword))) + return Fail("Native volume temporal pass has no keyword for its bootstrap quality.", out error); + return true; + } + + private static bool ValidPass(IReadOnlyDictionary metadata, string pass) => + metadata.TryGetValue($"unity.volume.pass.{pass}", out var value) && + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && parsed >= 0; + + private static bool ValidPort(IReadOnlyDictionary metadata, string kind, string port) => + metadata.TryGetValue($"unity.volume.{kind}Port.{port}", out var value) && + !string.IsNullOrWhiteSpace(value); + + private static bool Fail(string message, out string error) + { + error = message; + return false; + } + + private void SetMatrixPort(string port, Matrix4x4 value) + { + var property = ProgramPort("matrix", port); + if (!string.IsNullOrWhiteSpace(property)) _material!.SetMatrix(property, value); + } + + private void SetVectorPort(string port, Vector4 value) + { + var property = ProgramPort("vector", port); + if (!string.IsNullOrWhiteSpace(property)) _material!.SetVector(property, value); + } + + private void SetFloatPort(string port, float value) + { + var property = ProgramPort("float", port); + if (!string.IsNullOrWhiteSpace(property)) _material!.SetFloat(property, value); + } + + private void SetTexturePort(string port, Texture value) + { + var property = ProgramPort("texture", port); + if (!string.IsNullOrWhiteSpace(property)) _material!.SetTexture(property, value); + } + + private string ProgramPort(string kind, string port) => + TryProgramValue($"unity.volume.{kind}Port.{port}", out var value) ? value : ""; + + private int ProgramPass(string pass) => + TryProgramValue($"unity.volume.pass.{pass}", out var value) && + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && parsed >= 0 + ? parsed + : -1; + + private bool TryProgramValue(string key, out string value) + { + value = ""; + return _programMetadata != null && _programMetadata.TryGetValue(key, out value!); + } + + private static Vector4 ProjectionExtents(Camera camera) + { + var y = camera.orthographic ? camera.orthographicSize : Mathf.Tan(0.5f * Mathf.Deg2Rad * camera.fieldOfView); + return new Vector4(y * camera.aspect, y, 0f, 0f); + } + + private static Matrix4x4 CameraTransformToWorld(Camera camera) => + camera.transform.localToWorldMatrix; + + private static float Halton(int index, int radix) + { + var result = 0f; + var fraction = 1f / radix; + while (index > 0) { result += (index % radix) * fraction; index /= radix; fraction /= radix; } + return result; + } + + private static Dictionary ParseBindings(string value) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var pair in (value ?? "").Split(';')) + { + var separator = pair.IndexOf('='); + if (separator <= 0 || separator >= pair.Length - 1) continue; + result[pair.Substring(0, separator).Trim()] = pair.Substring(separator + 1).Trim(); + } + return result; + } + + private static string Prop(EveUnityFieldVolumeProjection field, string key) => + field.Props.TryGetValue(key, out var value) ? value ?? "" : ""; + + private static int PositiveInt(EveUnityFieldVolumeProjection field, string key, int fallback) => + int.TryParse(Prop(field, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) && value > 0 ? value : fallback; + + private static int NonNegativeInt(EveUnityFieldVolumeProjection field, string key, int fallback) => + int.TryParse(Prop(field, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) && value >= 0 ? value : fallback; + + private static bool TryFloat(string value, out float result) => + float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out result) && !float.IsNaN(result) && !float.IsInfinity(result); + + private static bool TryVector(string value, out Vector4 result) + { + result = default; + var values = (value ?? "").Split(','); + if (values.Length < 2 || values.Length > 4) return false; + var parsed = new float[4]; + for (var index = 0; index < values.Length; index++) + if (!TryFloat(values[index], out parsed[index])) return false; + result = new Vector4(parsed[0], parsed[1], parsed[2], parsed[3]); + return true; + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsVolumeRenderer.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsVolumeRenderer.cs.meta new file mode 100644 index 0000000..bf9a46e --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsVolumeRenderer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9c5d41ca3a3a4335b1b82104bba3e20a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/GameCult.Eve.UnityScene.asmdef b/packages/org.gamecult.eve.unity-scene/Runtime/GameCult.Eve.UnityScene.asmdef index 784339a..6f91767 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/GameCult.Eve.UnityScene.asmdef +++ b/packages/org.gamecult.eve.unity-scene/Runtime/GameCult.Eve.UnityScene.asmdef @@ -4,7 +4,10 @@ "references": [ "GameCult.Eve.Surface", "GameCult.Eve.PluginFields", - "Unity.Mathematics" + "GameCult.Eve.UnityUIToolkit", + "Unity.Mathematics", + "Unity.RenderPipelines.Core.Runtime", + "Unity.RenderPipelines.Universal.Runtime" ], "includePlatforms": [], "excludePlatforms": [], diff --git a/packages/org.gamecult.eve.surface/Runtime.meta b/packages/org.gamecult.eve.unity-scene/Runtime/Resources.meta similarity index 77% rename from packages/org.gamecult.eve.surface/Runtime.meta rename to packages/org.gamecult.eve.unity-scene/Runtime/Resources.meta index f150917..fb9f65a 100644 --- a/packages/org.gamecult.eve.surface/Runtime.meta +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Resources.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 2164f6fea1216214d895e7dc99c46ecf +guid: 63a640b4e19e5764eb030d665a2b7c00 folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj.meta b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity.meta similarity index 67% rename from packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj.meta rename to packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity.meta index 751910b..d1f9c0e 100644 --- a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj.meta +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity.meta @@ -1,5 +1,6 @@ fileFormatVersion: 2 -guid: ce2d4913d64690c44ae68935014fd79d +guid: 6435ff06293834c49aa631e74b51a552 +folderAsset: yes DefaultImporter: externalObjects: {} userData: diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposure.compute b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposure.compute new file mode 100644 index 0000000..f1e1f27 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposure.compute @@ -0,0 +1,98 @@ +#pragma kernel ClearHistogram +#pragma kernel BuildHistogram +#pragma kernel CalculateFixedExposure +#pragma kernel CalculateProgressiveExposure + +RWStructuredBuffer _Histogram; +Texture2D _Source; +SamplerState sampler_LinearClamp; +Texture2D _PreviousExposure; +RWTexture2D _DestinationExposure; + +float4 _HistogramScaleOffsetResolution; +float4 _ExposureFilterAndBrightness; +float4 _ExposureAdaptation; + +groupshared uint GroupHistogram[128]; + +[numthreads(64, 1, 1)] +void ClearHistogram(uint threadId : SV_DispatchThreadID) +{ + if (threadId < 128) _Histogram[threadId] = 0; +} + +[numthreads(16, 16, 1)] +void BuildHistogram(uint2 dispatchId : SV_DispatchThreadID, uint2 groupId : SV_GroupThreadID) +{ + uint localId = groupId.y * 16 + groupId.x; + if (localId < 128) GroupHistogram[localId] = 0; + GroupMemoryBarrierWithGroupSync(); + + float2 pixel = float2(dispatchId) * 2.0; + if (pixel.x < _HistogramScaleOffsetResolution.z && pixel.y < _HistogramScaleOffsetResolution.w) + { + float2 uv = pixel / _HistogramScaleOffsetResolution.zw; + float2 distanceFromCenter = abs(uv - 0.5.xx); + float vignette = saturate(1.0 - dot(distanceFromCenter, distanceFromCenter)); + uint weight = (uint)(64.0 * vignette * vignette); + float3 color = _Source.SampleLevel(sampler_LinearClamp, uv, 0).rgb; + float luminance = max(1e-6, dot(color, float3(0.2126729, 0.7151522, 0.0721750))); + float normalizedLogLuminance = saturate( + log2(luminance) * _HistogramScaleOffsetResolution.x + _HistogramScaleOffsetResolution.y); + uint bin = (uint)(normalizedLogLuminance * 127.0); + InterlockedAdd(GroupHistogram[bin], weight); + } + + GroupMemoryBarrierWithGroupSync(); + if (localId < 128) InterlockedAdd(_Histogram[localId], GroupHistogram[localId]); +} + +float LuminanceForBin(uint bin) +{ + float normalized = (float)bin / 128.0; + return exp2((normalized - _HistogramScaleOffsetResolution.y) / _HistogramScaleOffsetResolution.x); +} + +float CalculateExposure() +{ + float total = 0.0; + for (uint index = 0; index < 128; index++) total += (float)_Histogram[index]; + + float discardDark = total * _ExposureFilterAndBrightness.x; + float includeThrough = total * _ExposureFilterAndBrightness.y; + float weightedLuminance = 0.0; + float includedWeight = 0.0; + for (uint index = 0; index < 128; index++) + { + float count = (float)_Histogram[index]; + float darkOffset = min(discardDark, count); + count -= darkOffset; + discardDark -= darkOffset; + includeThrough -= darkOffset; + count = min(includeThrough, count); + includeThrough -= count; + weightedLuminance += LuminanceForBin(index) * count; + includedWeight += count; + } + + float average = weightedLuminance / max(includedWeight, 1e-6); + average = clamp(average, _ExposureFilterAndBrightness.z, _ExposureFilterAndBrightness.w); + return _ExposureAdaptation.z / max(average, 1e-6); +} + +[numthreads(1, 1, 1)] +void CalculateFixedExposure(uint3 id : SV_DispatchThreadID) +{ + _DestinationExposure[uint2(0, 0)] = CalculateExposure(); +} + +[numthreads(1, 1, 1)] +void CalculateProgressiveExposure(uint3 id : SV_DispatchThreadID) +{ + float nextExposure = CalculateExposure(); + float previousExposure = _PreviousExposure[uint2(0, 0)]; + float delta = nextExposure - previousExposure; + float speed = delta > 0.0 ? _ExposureAdaptation.x : _ExposureAdaptation.y; + _DestinationExposure[uint2(0, 0)] = previousExposure + + delta * (1.0 - exp2(-_ExposureAdaptation.w * speed)); +} diff --git a/packages/org.gamecult.eve.surface/GameCult.Eve.Contracts.csproj.meta b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposure.compute.meta similarity index 54% rename from packages/org.gamecult.eve.surface/GameCult.Eve.Contracts.csproj.meta rename to packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposure.compute.meta index 3b5cba0..3fe7574 100644 --- a/packages/org.gamecult.eve.surface/GameCult.Eve.Contracts.csproj.meta +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposure.compute.meta @@ -1,7 +1,8 @@ fileFormatVersion: 2 -guid: 8a186b6d206ffa847aa276e984162512 -DefaultImporter: +guid: c8c45489b6ba44558342ebc5381c12d1 +ComputeShaderImporter: externalObjects: {} + currentAPIMask: 4 userData: assetBundleName: assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposureMultiply.shader b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposureMultiply.shader new file mode 100644 index 0000000..d54ded1 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposureMultiply.shader @@ -0,0 +1,39 @@ +Shader "Hidden/EveUnity/AdaptiveExposureMultiply" +{ + SubShader + { + Pass + { + Cull Off ZWrite Off ZTest Always + Blend DstColor Zero + ColorMask RGB + + HLSLPROGRAM + #pragma target 4.5 + #pragma vertex Vert + #pragma fragment Frag + + Texture2D _ExposureTexture; + + struct Varyings + { + float4 position : SV_POSITION; + }; + + Varyings Vert(uint vertexId : SV_VertexID) + { + float2 uv = float2((vertexId << 1) & 2, vertexId & 2); + Varyings output; + output.position = float4(uv * 2.0 - 1.0, 0.0, 1.0); + return output; + } + + float4 Frag(Varyings input) : SV_Target + { + float exposure = _ExposureTexture.Load(int3(0, 0, 0)); + return exposure.xxxx; + } + ENDHLSL + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposureMultiply.shader.meta b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposureMultiply.shader.meta new file mode 100644 index 0000000..daf9128 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposureMultiply.shader.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: 48315c6017f548e7a4b46698b3d7229e +ShaderImporter: + externalObjects: {} + defaultTextures: [] + nonModifiableTextures: [] + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/UnityDefaultRuntimeTheme.tss b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/UnityDefaultRuntimeTheme.tss new file mode 100644 index 0000000..51924cb --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/UnityDefaultRuntimeTheme.tss @@ -0,0 +1 @@ +@import url("unity-theme://default"); diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/UnityDefaultRuntimeTheme.tss.meta b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/UnityDefaultRuntimeTheme.tss.meta new file mode 100644 index 0000000..b26f42d --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/UnityDefaultRuntimeTheme.tss.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: 0f30b970a2764f51ba064b032906259e +ScriptedImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 2 + userData: + assetBundleName: + assetBundleVariant: + script: {fileID: 12388, guid: 0000000000000000e000000000000000, type: 0} + disableValidation: 0 + unsupportedSelectorAction: 0 diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs deleted file mode 100644 index d4b2189..0000000 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System; -using GameCult.Eve.Surface; -using NUnit.Framework; - -namespace GameCult.Eve.UnityScene.Tests -{ - public sealed class EveCultMeshSurfaceSelectionTests - { - [Test] - public void Select_MatchesDeveloperIntentWithoutPhysicalTopology() - { - var provider = Provider( - new EveAdvertisedSurface( - "aetheria.pilot", - EveSurfaceDocument.SchemaId, - "eve:surface:aetheria.pilot", - "cultmesh-record", - "active", - "interactive-world", - new EveWorldInteractionAdvertisement( - "world3d", - Array.Empty(), - "provider-command", - "eve:commands", - EveCommandReceiptDocument.SchemaId, - "eve:receipts", - "eve:assets", - new[] { "unity-scene", "web" }, - "provider"))); - - var selected = EveCultMeshSurfaceSelection.Select( - new[] { provider }, - new EveSurfaceRequest - { - ProviderId = "aetheria.daemon", - SurfaceKind = "interactive-world", - LoweringTarget = "unity-scene" - }); - - Assert.That(selected.Provider, Is.SameAs(provider)); - Assert.That(selected.Surface.SurfaceId, Is.EqualTo("aetheria.pilot")); - Assert.That(selected.Surface.RecordRef, Is.EqualTo("eve:surface:aetheria.pilot")); - } - - [Test] - public void Select_RejectsUnsupportedLoweringTarget() - { - var provider = Provider(new EveAdvertisedSurface( - "aetheria.pilot", - EveSurfaceDocument.SchemaId, - "eve:surface:aetheria.pilot", - "cultmesh-record", - "active", - "interactive-world", - new EveWorldInteractionAdvertisement( - "world3d", Array.Empty(), "", "", "", "", "", - new[] { "unity-scene" }, "provider"))); - - Assert.Throws(() => EveCultMeshSurfaceSelection.Select( - new[] { provider }, - new EveSurfaceRequest { LoweringTarget = "flutter" })); - } - - [Test] - public void ResolvePlugins_KeepsIndependentRequiredAndOptionalNestedCapabilities() - { - var surface = new EveAdvertisedSurface( - "sai.stage", EveSurfaceDocument.SchemaId, "eve:surface:sai", "cultmesh-record", "active", "interface", - requiresPlugins: new[] - { - new EvePluginRequirement("sai.vn", "^0.1.0", "required", new[] { "vn.stage", "story.choose" }, Array.Empty()), - new EvePluginRequirement("norn.graph", "^0.1.0", "optional-nested", Array.Empty(), new[] { "embed.norn" }), - new EvePluginRequirement("tex.math", "^0.1.0", "optional-nested", Array.Empty(), new[] { "embed.tex" }) - }); - var sai = Plugin("sai.vn", new[] { "vn.stage" }, new[] { "story.choose" }); - var norn = Plugin("norn.graph", new[] { "embed.norn" }, Array.Empty()); - - var selected = EveCultMeshSurfaceSelection.ResolvePlugins(surface, new[] { sai, norn }); - - Assert.That(selected, Is.EqualTo(new[] { sai, norn })); - } - - [Test] - public void ResolvePlugins_RejectsMissingRequiredCapability() - { - var surface = new EveAdvertisedSurface( - "sai.stage", EveSurfaceDocument.SchemaId, "eve:surface:sai", "cultmesh-record", "active", "interface", - requiresPlugins: new[] - { - new EvePluginRequirement("sai.vn", "^0.1.0", "required", new[] { "story.choose" }, Array.Empty()) - }); - - Assert.Throws(() => - EveCultMeshSurfaceSelection.ResolvePlugins( - surface, - new[] { Plugin("sai.vn", new[] { "vn.stage" }, Array.Empty()) })); - } - - private static EveProviderAdvertisementDocument Provider(EveAdvertisedSurface surface) => - new EveProviderAdvertisementDocument( - "aetheria.daemon", - "aetheria", - "aetheria", - "Aetheria", - "game", - "aetheria.daemon", - "2026-07-12T00:00:00Z", - new EveProviderFreshness("fresh", "2026-07-12T00:00:00Z", 5000), - Array.Empty(), - Array.Empty(), - new[] { surface }, - Array.Empty()); - - private static EvePluginAdvertisementDocument Plugin( - string pluginId, - string[] components, - string[] commands) => - new EvePluginAdvertisementDocument( - pluginId, - pluginId.Split('.')[0], - "0.1.0", - "cultmesh://plugins/" + pluginId, - new EvePluginRuntimeAdvertisement( - "sidecar", - "gamecult.eve.plugin_abi.v1", - new[] { "cultmesh" }, - new[] { "plugin" }, - new EvePluginSidecarAdvertisement( - "daemon", "cultmesh", "request", "response", Array.Empty(), "command", "receipt", "plugin")), - Array.Empty(), - components, - commands, - Array.Empty()); - } -} diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs.meta b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs.meta deleted file mode 100644 index c22c711..0000000 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveCultMeshSurfaceSelectionTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: c578fb24db4b41fc88ea08bde4c42dd4 diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveFieldsUnitySceneProjectionTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveFieldsUnitySceneProjectionTests.cs index f3e130f..33130a0 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveFieldsUnitySceneProjectionTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveFieldsUnitySceneProjectionTests.cs @@ -2,15 +2,129 @@ using System; using System.IO; using System.Linq; using System.Collections.Generic; +using System.Reflection; using GameCult.Eve.PluginFields; using GameCult.Eve.UnityScene.Fields; using NUnit.Framework; using UnityEngine; +using UnityEngine.Rendering.Universal; namespace GameCult.Eve.UnityScene.Tests { public sealed class EveFieldsUnitySceneProjectionTests { + [Test] + public void VolumeCameraToWorldPreservesUnityShaderPositiveZForward() + { + var cameraObject = new GameObject("eve-volume-camera-matrix"); + try + { + var camera = cameraObject.AddComponent(); + camera.transform.SetPositionAndRotation( + new Vector3(12f, -3f, 8f), + Quaternion.Euler(17f, 43f, -9f)); + var method = typeof(EveUnityFieldsVolumeRenderer).GetMethod( + "CameraTransformToWorld", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.That(method, Is.Not.Null); + var matrix = (Matrix4x4)method!.Invoke(null, new object[] { camera }); + Assert.That(Vector3.Dot( + matrix.MultiplyVector(Vector3.forward).normalized, + camera.transform.forward), Is.EqualTo(1f).Within(0.00001f)); + Assert.That(Vector3.Distance( + matrix.MultiplyPoint3x4(Vector3.zero), + camera.transform.position), Is.LessThan(0.00001f)); + } + finally + { + UnityEngine.Object.DestroyImmediate(cameraObject); + } + } + + [Test] + public void AdaptiveExposureMatchesFossilCompensationAndProgressiveDirection() + { + var exposureMethod = typeof(EveUnityAdaptiveExposureRenderer).GetMethod( + "ExposureForAverageLuminance", + BindingFlags.NonPublic | BindingFlags.Static); + var adaptationMethod = typeof(EveUnityAdaptiveExposureRenderer).GetMethod( + "AdaptExposure", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.That(exposureMethod, Is.Not.Null); + Assert.That(adaptationMethod, Is.Not.Null); + var brightBound = Mathf.Pow(2f, 0.3f); + var exposure = (float)exposureMethod!.Invoke(null, new object[] { brightBound, 0.5f }); + Assert.That(exposure, Is.EqualTo(0.5f / brightBound).Within(0.000001f)); + + var darkening = (float)adaptationMethod!.Invoke( + null, + new object[] { 1f, 0.25f, 0.5f, 2f, 1f }); + var brightening = (float)adaptationMethod.Invoke( + null, + new object[] { 1f, 4f, 0.5f, 2f, 1f }); + Assert.That(darkening, Is.EqualTo(0.625f).Within(0.000001f)); + Assert.That(brightening, Is.EqualTo(1f + 3f * (1f - Mathf.Pow(2f, -0.5f))).Within(0.000001f)); + } + + [Test] + public void AdaptiveExposureGpuHistogramClampsBrightFrameToAdvertisedMaximumEv() + { + Assert.That(SystemInfo.supportsComputeShaders, Is.True); + var compute = Resources.Load("EveUnity/AdaptiveExposure"); + Assert.That(compute, Is.Not.Null); + var source = new Texture2D(32, 32, TextureFormat.RGBAFloat, false, true); + var pixels = Enumerable.Repeat(new Color(16f, 16f, 16f, 1f), 32 * 32).ToArray(); + source.SetPixels(pixels); + source.Apply(false, false); + using var histogram = new ComputeBuffer(128, sizeof(uint), ComputeBufferType.Structured); + var exposure = new RenderTexture(1, 1, 0, RenderTextureFormat.RFloat, RenderTextureReadWrite.Linear) + { + enableRandomWrite = true + }; + exposure.Create(); + try + { + var clear = compute!.FindKernel("ClearHistogram"); + compute.SetBuffer(clear, "_Histogram", histogram); + compute.Dispatch(clear, 2, 1, 1); + + var build = compute.FindKernel("BuildHistogram"); + compute.SetBuffer(build, "_Histogram", histogram); + compute.SetTexture(build, "_Source", source); + compute.SetVector("_HistogramScaleOffsetResolution", new Vector4(1f / 18f, 0.5f, 32f, 32f)); + compute.Dispatch(build, 1, 1, 1); + + var calculate = compute.FindKernel("CalculateFixedExposure"); + compute.SetBuffer(calculate, "_Histogram", histogram); + compute.SetVector("_ExposureFilterAndBrightness", new Vector4( + 0.4737294f, + 0.99f, + Mathf.Pow(2f, -3f), + Mathf.Pow(2f, 0.3f))); + compute.SetVector("_ExposureAdaptation", new Vector4(1f, 2f, 0.5f, 1f / 60f)); + compute.SetTexture(calculate, "_DestinationExposure", exposure); + compute.Dispatch(calculate, 1, 1, 1); + + var previous = RenderTexture.active; + RenderTexture.active = exposure; + var readback = new Texture2D(1, 1, TextureFormat.RFloat, false, true); + readback.ReadPixels(new Rect(0, 0, 1, 1), 0, 0); + readback.Apply(); + RenderTexture.active = previous; + var actual = readback.GetPixel(0, 0).r; + UnityEngine.Object.DestroyImmediate(readback); + Assert.That(actual, Is.EqualTo(0.5f / Mathf.Pow(2f, 0.3f)).Within(0.001f)); + } + finally + { + exposure.Release(); + UnityEngine.Object.DestroyImmediate(exposure); + UnityEngine.Object.DestroyImmediate(source); + } + } + [Test] public void RasterizerConsumesPluginOwnedDocumentContract() { @@ -23,6 +137,425 @@ namespace GameCult.Eve.UnityScene.Tests assembly.Name?.Contains("Aetheria", StringComparison.OrdinalIgnoreCase) == true), Is.False); } + [Test] + public void CanonicalFieldsPackageOwnsTheCultMeshWireDocument() + { + Assert.That(typeof(EveFieldsSplatsDocument).GetCustomAttributes(false) + .Any(attribute => attribute.GetType().Name == "CultDocumentAttribute"), Is.True); + Assert.That(new EveFieldsSplatsDocument().Schema, Is.EqualTo(EveFieldsSchemas.Splats)); + Assert.That(typeof(EveFieldsSplatsDocument).Assembly.GetReferencedAssemblies() + .Any(assembly => assembly.Name?.Contains("Aetheria", StringComparison.OrdinalIgnoreCase) == true), Is.False); + } + + [Test] + public void VolumeRendererConsumesCanonicalFieldsWithoutProviderCode() + { + Assert.That(typeof(EveUnityFieldsVolumeRenderer).Assembly.GetReferencedAssemblies() + .Any(assembly => assembly.Name?.Contains("Aetheria", StringComparison.OrdinalIgnoreCase) == true), Is.False); + Assert.That(typeof(IEveUnityFieldsSplatsDocumentSource).GetEvent("FieldsSplatsAvailable"), Is.Not.Null); + } + + [Test] + public void UrpRendererFeatureOwnsGenericWorldEffectScheduling() + { + Assert.That(typeof(EveUnityRendererFeature).IsSubclassOf(typeof(ScriptableRendererFeature)), Is.True); + var supportsCamera = typeof(EveUnityRendererFeature).GetMethod( + "SupportsCamera", + BindingFlags.NonPublic | BindingFlags.Static); + Assert.That(supportsCamera, Is.Not.Null); + Assert.That(supportsCamera!.Invoke(null, new object[] { CameraType.Game }), Is.True); + Assert.That(supportsCamera.Invoke(null, new object[] { CameraType.SceneView }), Is.True); + Assert.That(supportsCamera.Invoke(null, new object[] { CameraType.Preview }), Is.False); + AssertRenderOrder(typeof(EveUnityFieldsVolumeRenderer), 100); + AssertRenderOrder(typeof(EveUnityFieldsParticleRenderer), 200); + AssertRenderOrder(typeof(EveUnityAdaptiveExposureRenderer), 300); + } + + [Test] + public void VolumeRendererCompositesBeforeProviderPostProcessing() + { + Assert.That( + EveUnityFieldsVolumeRenderer.CompositionRenderPassEvent, + Is.EqualTo(RenderPassEvent.BeforeRenderingPostProcessing)); + } + + private static void AssertRenderOrder(Type sourceType, int expected) + { + var schedulingInterface = sourceType.GetInterfaces().Single(type => + string.Equals(type.Name, "IEveUnityRenderPassSource", StringComparison.Ordinal)); + var property = schedulingInterface.GetProperty("RenderOrder"); + Assert.That(property, Is.Not.Null); + var gameObject = new GameObject("eve-render-pass-order-test"); + try + { + var component = gameObject.AddComponent(sourceType); + Assert.That(property!.GetValue(component), Is.EqualTo(expected)); + } + finally + { + UnityEngine.Object.DestroyImmediate(gameObject); + } + } + + [Test] + public void ParticleRendererExecutesProviderProgramWithoutProviderCode() + { + Assert.That(typeof(EveUnityFieldsParticleRenderer).Assembly.GetReferencedAssemblies() + .Any(assembly => assembly.Name?.Contains("Aetheria", StringComparison.OrdinalIgnoreCase) == true), Is.False); + Assert.That(EveUnityFieldsParticleRenderer.CompositionRenderPassEvent, + Is.EqualTo(RenderPassEvent.BeforeRenderingPostProcessing)); + } + + [TestCase(11f, 6f)] + [TestCase(-11f, -6f)] + [TestCase(5.999f, 0f)] + public void FieldViewportFrameSnapsToAnIntegralGravityPixelMultiple(float coordinate, float expected) + { + Assert.That(EveUnityFieldsViewportFrame.SnapCameraCoordinate( + coordinate, + 1536f, + 2048, + 8f), Is.EqualTo(expected).Within(0.0001f)); + } + + [Test] + public void FieldViewportFrameAcceptsAlignedGravityAndParticleLattices() + { + Assert.That(EveUnityFieldsViewportFrame.TryValidateSpatialLattice( + 1536f, 1536f, 2048, 2048, 256, 6f, 8), Is.True); + } + + [Test] + public void FieldViewportFrameRejectsAliasedGravityAndParticleLattices() + { + Assert.That(EveUnityFieldsViewportFrame.TryValidateSpatialLattice( + 2000f, 2000f, 2048, 2048, 256, 6f, 8), Is.False); + } + + [Test] + public void FogAndParticlesResolveTheSameSnappedWorldFrame() + { + var document = new EveFieldsSplatsDocument + { + FrameId = 7, + Viewport = new EveFieldsViewport + { + MinX = -768, + MinY = -768, + MaxX = 768, + MaxY = 768 + } + }; + var contract = new Dictionary(StringComparer.Ordinal) + { + ["viewportAnchor"] = "active-camera.xz", + ["span"] = "256", + ["cellWorldSize"] = "6", + ["viewportSnapTexels"] = "8" + }; + + Assert.That(EveUnityFieldsViewportFrame.TryResolve( + document, contract, new Vector2(11f, -11f), 2048, 2048, + out var fog, out var fogCenter), Is.True); + Assert.That(EveUnityFieldsViewportFrame.TryResolve( + document, contract, new Vector2(11f, -11f), 2048, 2048, + out var particles, out var particleCenter), Is.True); + + Assert.That(fogCenter, Is.EqualTo(new Vector2(6f, -6f))); + Assert.That(particleCenter, Is.EqualTo(fogCenter)); + Assert.That(particles.Viewport.MinX, Is.EqualTo(fog.Viewport.MinX)); + Assert.That(particles.Viewport.MinY, Is.EqualTo(fog.Viewport.MinY)); + Assert.That(particles.Viewport.MaxX, Is.EqualTo(fog.Viewport.MaxX)); + Assert.That(particles.Viewport.MaxY, Is.EqualTo(fog.Viewport.MaxY)); + } + + [Test] + public void ParticleRendererFailsClosedWithoutCompleteNativeAbi() + { + var compute = RequiredParticleComputeMetadata(); + var material = RequiredParticleMaterialMetadata(); + Assert.That(EveUnityFieldsParticleRenderer.TryValidateProgramMetadata(compute, material, out var error), + Is.True, error); + + compute.Remove("unity.particles.intPort.span"); + + Assert.That(EveUnityFieldsParticleRenderer.TryValidateProgramMetadata(compute, material, out error), + Is.False); + Assert.That(error, Does.Contain("logical port")); + } + + [Test] + public void ParticleMaterialAcceptsAdvertisedTemporalDitherInputs() + { + var field = new EveUnityFieldParticlesProjection( + "particles", "document", EveFieldsSchemas.Splats, "compute", "material", "world.transparent", + new Dictionary(StringComparer.Ordinal) + { + ["materialAssetTextureBindings"] = "texture.noise=dither", + ["materialViewportTextureScaleBindings"] = "ditherCoordinates=dither", + ["materialRenderFrameIndexPort"] = "frameIndex" + }); + var metadata = RequiredParticleMaterialMetadata(); + metadata["unity.particles.texturePort.dither"] = "_DitheringTex"; + metadata["unity.particles.vectorPort.ditherCoordinates"] = "_DitheringCoords"; + metadata["unity.particles.intPort.frameIndex"] = "_FrameNumber"; + + Assert.That(EveUnityFieldsParticleRenderer.TryValidateMaterialPresentationContract( + field, metadata, out var error), Is.True, error); + } + + [Test] + public void ParticleMaterialRejectsPartialTemporalDitherInputs() + { + var field = new EveUnityFieldParticlesProjection( + "particles", "document", EveFieldsSchemas.Splats, "compute", "material", "world.transparent", + new Dictionary(StringComparer.Ordinal) + { + ["materialAssetTextureBindings"] = "texture.noise=dither", + ["materialViewportTextureScaleBindings"] = "ditherCoordinates=dither", + ["materialRenderFrameIndexPort"] = "frameIndex" + }); + var metadata = RequiredParticleMaterialMetadata(); + metadata["unity.particles.texturePort.dither"] = "_DitheringTex"; + metadata["unity.particles.vectorPort.ditherCoordinates"] = "_DitheringCoords"; + + Assert.That(EveUnityFieldsParticleRenderer.TryValidateMaterialPresentationContract( + field, metadata, out var error), Is.False); + Assert.That(error, Does.Contain("render-frame")); + } + + [Test] + public void ParticleRendererPreservesRepeatedDocumentSourcesForDistinctPorts() + { + var document = new EveFieldsSplatsDocument { SimulationTimeSeconds = 12.5 }; + + Assert.That(EveUnityFieldsParticleRenderer.TryEvaluateDocumentFloatBindings( + document, + "simulationTimeSeconds=time,1,0;simulationTimeSeconds=flowScroll,0.025,0", + out var bindings), Is.True); + Assert.That(bindings.Count, Is.EqualTo(2)); + Assert.That(bindings[0].Key, Is.EqualTo("time")); + Assert.That(bindings[0].Value, Is.EqualTo(12.5f)); + Assert.That(bindings[1].Key, Is.EqualTo("flowScroll")); + Assert.That(bindings[1].Value, Is.EqualTo(0.3125f)); + } + + [Test] + public void VolumeProgramAcceptsPortableTwoPassLifecycle() + { + var metadata = RequiredVolumeProgramMetadata(); + + Assert.That(EveUnityFieldsVolumeRenderer.TryValidateProgramMetadata(metadata, out var error), Is.True, error); + } + + [Test] + public void VolumeProgramAcceptsCompleteTemporalLifecycle() + { + var metadata = RequiredVolumeProgramMetadata(); + metadata["unity.volume.pass.temporal"] = "1"; + metadata["unity.volume.texturePort.currentSample"] = "_CurrentVolume"; + metadata["unity.volume.texturePort.history"] = "_HistoryVolume"; + metadata["unity.volume.matrixPort.previousViewProjection"] = "_PreviousViewProjection"; + metadata["unity.volume.floatPort.resetHistory"] = "_ResetHistory"; + + Assert.That(EveUnityFieldsVolumeRenderer.TryValidateProgramMetadata(metadata, out var error), Is.True, error); + } + + [Test] + public void VolumeRendererUsesCurrentProjectionWithPreviousViewWhenAdvertised() + { + var metadata = RequiredVolumeProgramMetadata(); + metadata["unity.volume.matrixSemantic.previousViewProjection"] = + "current-projection.previous-view.v1"; + var previousViewProjection = Matrix4x4.Translate(new Vector3(1f, 2f, 3f)); + var currentProjection = Matrix4x4.Scale(new Vector3(2f, 3f, 4f)); + var previousView = Matrix4x4.Rotate(Quaternion.Euler(10f, 20f, 30f)); + + var resolved = EveUnityFieldsVolumeRenderer.ResolvePreviousViewProjection( + metadata, + previousViewProjection, + currentProjection, + Matrix4x4.identity, + previousView); + + Assert.That(resolved, Is.EqualTo(currentProjection * previousView)); + Assert.That(resolved, Is.Not.EqualTo(previousViewProjection)); + } + + [Test] + public void VolumeRendererDefaultsToTruePreviousViewProjection() + { + var previousViewProjection = Matrix4x4.Translate(new Vector3(1f, 2f, 3f)); + + var resolved = EveUnityFieldsVolumeRenderer.ResolvePreviousViewProjection( + RequiredVolumeProgramMetadata(), + previousViewProjection, + Matrix4x4.Scale(Vector3.one * 2f), + Matrix4x4.identity, + Matrix4x4.Rotate(Quaternion.Euler(10f, 20f, 30f))); + + Assert.That(resolved, Is.EqualTo(previousViewProjection)); + } + + [Test] + public void VolumeRendererUsesNonRenderTargetProjectionWhenAdvertised() + { + var metadata = RequiredVolumeProgramMetadata(); + metadata["unity.volume.matrixSemantic.previousViewProjection"] = + "non-render-target-projection.previous-view.v1"; + var previousViewProjection = Matrix4x4.Translate(new Vector3(1f, 2f, 3f)); + var renderTargetProjection = Matrix4x4.Scale(new Vector3(2f, -3f, 4f)); + var nonRenderTargetProjection = Matrix4x4.Scale(new Vector3(2f, 3f, 4f)); + var previousView = Matrix4x4.Rotate(Quaternion.Euler(10f, 20f, 30f)); + + var resolved = EveUnityFieldsVolumeRenderer.ResolvePreviousViewProjection( + metadata, + previousViewProjection, + renderTargetProjection, + nonRenderTargetProjection, + previousView); + + Assert.That(resolved, Is.EqualTo(nonRenderTargetProjection * previousView)); + Assert.That(resolved, Is.Not.EqualTo(renderTargetProjection * previousView)); + } + + [Test] + public void VolumeProgramRejectsUnknownPreviousViewProjectionSemantic() + { + var metadata = RequiredVolumeProgramMetadata(); + metadata["unity.volume.pass.temporal"] = "1"; + metadata["unity.volume.texturePort.currentSample"] = "_CurrentVolume"; + metadata["unity.volume.texturePort.history"] = "_HistoryVolume"; + metadata["unity.volume.matrixPort.previousViewProjection"] = "_PreviousViewProjection"; + metadata["unity.volume.floatPort.resetHistory"] = "_ResetHistory"; + metadata["unity.volume.matrixSemantic.previousViewProjection"] = "made-up"; + + Assert.That(EveUnityFieldsVolumeRenderer.TryValidateProgramMetadata(metadata, out var error), Is.False); + Assert.That(error, Does.Contain("previous-view-projection semantic")); + } + + [Test] + public void VolumeRendererUsesAdvertisedBootstrapQualityOnlyForEmptyHistory() + { + var metadata = RequiredVolumeProgramMetadata(); + metadata["unity.volume.quality.bootstrap"] = "ultra"; + metadata["unity.volume.quality.ultra.keyword"] = "ULTRA_QUALITY"; + + Assert.That(EveUnityFieldsVolumeRenderer.ResolveQuality(metadata, "high", true), Is.EqualTo("ultra")); + Assert.That(EveUnityFieldsVolumeRenderer.ResolveQuality(metadata, "high", false), Is.EqualTo("high")); + } + + [Test] + public void VolumeProgramRejectsBootstrapQualityWithoutAdvertisedKeyword() + { + var metadata = RequiredVolumeProgramMetadata(); + metadata["unity.volume.pass.temporal"] = "1"; + metadata["unity.volume.texturePort.currentSample"] = "_CurrentVolume"; + metadata["unity.volume.texturePort.history"] = "_HistoryVolume"; + metadata["unity.volume.matrixPort.previousViewProjection"] = "_PreviousViewProjection"; + metadata["unity.volume.floatPort.resetHistory"] = "_ResetHistory"; + metadata["unity.volume.quality.bootstrap"] = "ultra"; + + Assert.That(EveUnityFieldsVolumeRenderer.TryValidateProgramMetadata(metadata, out var error), Is.False); + Assert.That(error, Does.Contain("bootstrap quality")); + } + + [Test] + public void VolumeProgramRejectsPartialTemporalLifecycle() + { + var metadata = RequiredVolumeProgramMetadata(); + metadata["unity.volume.pass.temporal"] = "1"; + metadata["unity.volume.texturePort.currentSample"] = "_CurrentVolume"; + + Assert.That(EveUnityFieldsVolumeRenderer.TryValidateProgramMetadata(metadata, out var error), Is.False); + Assert.That(error, Does.Contain("history port")); + } + + [Test] + public void VolumeRendererDerivesViewportTextureScaleFromNativeDimensions() + { + var texture = new Texture2D(128, 64, TextureFormat.RGBA32, false); + try + { + Assert.That(EveUnityFieldsVolumeRenderer.TryComputeViewportTextureScale( + 640, + 360, + texture, + out var scale), Is.True); + Assert.That(scale, Is.EqualTo(new Vector4(5f, 5.625f, 0f, 0f))); + } + finally + { + UnityEngine.Object.DestroyImmediate(texture); + } + } + + [Test] + public void VolumeRendererAppliesProviderAdvertisedLayerTargetShape() + { + var target = new EveFieldsSplatLayerTarget { LayerKey = "fog.tint" }; + + Assert.That( + EveUnityFieldsVolumeRenderer.TryApplyLayerTargetDescriptor( + target, + "0.5,0.25,true,trilinear"), + Is.True); + Assert.That(target.WidthScale, Is.EqualTo(0.5f)); + Assert.That(target.HeightScale, Is.EqualTo(0.25f)); + Assert.That(target.UseMipMaps, Is.True); + Assert.That(target.FilterMode, Is.EqualTo(FilterMode.Trilinear)); + } + + [Test] + public void VolumeRendererRejectsMalformedLayerTargetShape() + { + var target = new EveFieldsSplatLayerTarget + { + WidthScale = 2f, + HeightScale = 2f, + UseMipMaps = true, + FilterMode = FilterMode.Trilinear + }; + + Assert.That( + EveUnityFieldsVolumeRenderer.TryApplyLayerTargetDescriptor(target, "0,1,true,bilinear"), + Is.False); + Assert.That(target.WidthScale, Is.EqualTo(2f)); + Assert.That(target.HeightScale, Is.EqualTo(2f)); + Assert.That(target.UseMipMaps, Is.True); + Assert.That(target.FilterMode, Is.EqualTo(FilterMode.Trilinear)); + } + + [Test] + public void VolumeRendererBindsAuthoritativeDocumentTimeToLogicalPort() + { + var document = new EveFieldsSplatsDocument { SimulationTimeSeconds = 12.5 }; + + Assert.That( + EveUnityFieldsVolumeRenderer.TryEvaluateDocumentFloatBinding( + document, + "simulationTimeSeconds", + "flowScroll,0.025,-0.1", + out var port, + out var value), + Is.True); + Assert.That(port, Is.EqualTo("flowScroll")); + Assert.That(value, Is.EqualTo(0.2125f).Within(0.00001f)); + } + + [Test] + public void VolumeRendererRejectsUnknownDocumentFloatSource() + { + Assert.That( + EveUnityFieldsVolumeRenderer.TryEvaluateDocumentFloatBinding( + new EveFieldsSplatsDocument(), + "unityTime", + "flowScroll,1,0", + out _, + out _), + Is.False); + } + [Test] public void PackageContainsGenericFieldsShader() { @@ -30,6 +563,30 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(File.Exists("Packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveFieldsSplatsCore.hlsl"), Is.True); } + private static Dictionary RequiredParticleComputeMetadata() => + new Dictionary(StringComparer.Ordinal) + { + ["unity.particles.kernel.update"] = "UpdateParticles", + ["unity.particles.bufferPort.particles"] = "particles", + ["unity.particles.texturePort.surfaceHeight"] = "_SurfaceHeight", + ["unity.particles.texturePort.tint"] = "_Tint", + ["unity.particles.texturePort.hue"] = "_Hue", + ["unity.particles.vectorPort.viewportTransform"] = "_GridTransform", + ["unity.particles.vectorPort.timeVector"] = "_Time", + ["unity.particles.floatPort.time"] = "time", + ["unity.particles.floatPort.period"] = "period", + ["unity.particles.floatPort.spacing"] = "spacing", + ["unity.particles.intPort.span"] = "span" + }; + + private static Dictionary RequiredParticleMaterialMetadata() => + new Dictionary(StringComparer.Ordinal) + { + ["unity.particles.pass.render"] = "0", + ["unity.particles.bufferPort.particles"] = "particles", + ["unity.particles.bufferPort.quadPoints"] = "quadPoints" + }; + [Test] public void LayerRendererConsumesPluginContractsWithoutProviderBindings() { @@ -49,7 +606,7 @@ namespace GameCult.Eve.UnityScene.Tests var previous = RenderTexture.active; RenderTexture.active = output; - var readback = new Texture2D(64, 64, TextureFormat.RGBAFloat, false, true); + var readback = new Texture2D(64, 64, TextureFormat.RGBA32, false, true); readback.ReadPixels(new Rect(0, 0, 64, 64), 0, 0); readback.Apply(); RenderTexture.active = previous; @@ -62,6 +619,185 @@ namespace GameCult.Eve.UnityScene.Tests UnityEngine.Object.DestroyImmediate(host); } + [Test] + public void RasterizerLowersPortablePowerPulseParameters() + { + var document = new EveFieldsSplatsDocument + { + Viewport = new EveFieldsViewport { MinX = -1, MinY = -1, MaxX = 1, MaxY = 1 }, + Splats = new EveFieldsSplatSoa + { + Count = 1, + CenterX = new[] { 0d }, + CenterY = new[] { 0d }, + HalfExtentX = new[] { 1d }, + HalfExtentY = new[] { 1d }, + RotationCos = new[] { 1d }, + RotationSin = new[] { 0d }, + Channel = new[] { 0 }, + Falloff = new[] { EveFieldsSplatFalloffs.PowerPulse }, + FalloffScale = new[] { 2d }, + FalloffExponent = new[] { 1d }, + ValueR = new[] { 1d }, + ValueA = new[] { 1d }, + LayerIndex = new[] { 0 }, + SourceKind = new[] { EveFieldsSplatSourceKinds.Constant } + } + }; + var host = new GameObject("eve-fields-power-pulse"); + var rasterizer = host.AddComponent(); + var output = rasterizer.Render(document, 128, 128); + var previous = RenderTexture.active; + RenderTexture.active = output; + var readback = new Texture2D(128, 128, TextureFormat.RGBAFloat, false, true); + readback.ReadPixels(new Rect(0, 0, 128, 128), 0, 0); + readback.Apply(); + RenderTexture.active = previous; + + Assert.That(readback.GetPixel(64, 64).r, Is.GreaterThan(0.95f)); + Assert.That(readback.GetPixel(80, 64).r, Is.InRange(0.65f, 0.85f)); + Assert.That(readback.GetPixel(96, 64).r, Is.LessThan(0.02f)); + + UnityEngine.Object.DestroyImmediate(readback); + UnityEngine.Object.DestroyImmediate(host); + } + + [Test] + public void RasterizerAnchorsAnimatedSimplexToFieldWorldAndPublishedSimulationTime() + { + var first = ProceduralDocument( + 0, 100, 50, EveFieldsSplatSourceKinds.AnimatedSimplexNoise, 2); + var shifted = ProceduralDocument( + 25, 125, 75, EveFieldsSplatSourceKinds.AnimatedSimplexNoise, 2); + var later = ProceduralDocument( + 0, 100, 50, EveFieldsSplatSourceKinds.AnimatedSimplexNoise, 7); + + var firstValue = RenderFieldPixel(first, 64, 64); + var shiftedValue = RenderFieldPixel(shifted, 32, 64); + var laterValue = RenderFieldPixel(later, 64, 64); + + Assert.That(shiftedValue, Is.EqualTo(firstValue).Within(0.015f), + "the same field-world coordinate must not swim when the viewport moves"); + Assert.That(Mathf.Abs(laterValue - firstValue), Is.GreaterThan(0.02f), + "animation must use the document's simulation time rather than Unity wall time"); + } + + [Test] + public void RasterizerLowersPortableAnimatedCellNoiseB() + { + var first = ProceduralDocument( + 0, 100, 50, EveFieldsSplatSourceKinds.AnimatedCellNoiseB, 2); + var later = ProceduralDocument( + 0, 100, 50, EveFieldsSplatSourceKinds.AnimatedCellNoiseB, 11); + + var firstValue = RenderFieldPixel(first, 57, 71); + var laterValue = RenderFieldPixel(later, 57, 71); + + Assert.That(firstValue, Is.GreaterThanOrEqualTo(0)); + Assert.That(Mathf.Abs(laterValue - firstValue), Is.GreaterThan(0.005f)); + } + + [Test] + public void RasterizerLowersPortableAnimatedRadialCosine() + { + var initial = RadialCosineDocument(0); + var halfTurnLater = RadialCosineDocument(Math.PI); + + var center = RenderFieldPixel(initial, 64, 64); + var radial = RenderFieldPixel(initial, 80, 64); + var laterCenter = RenderFieldPixel(halfTurnLater, 64, 64); + var localX = (80.5f / 128f) * 2f - 1f; + var localY = (64.5f / 128f) * 2f - 1f; + var expected = Mathf.Cos(Mathf.Pow(Mathf.Sqrt(localX * localX + localY * localY), 1.25f) * Mathf.PI * 2f); + + Assert.That(center, Is.GreaterThan(0.98f)); + Assert.That(radial, Is.EqualTo(expected).Within(0.04f)); + Assert.That(laterCenter, Is.LessThan(-0.98f), + "radial phase animation must use provider-published simulation time"); + } + + private static EveFieldsSplatsDocument RadialCosineDocument(double simulationTime) + { + return new EveFieldsSplatsDocument + { + SimulationTimeSeconds = simulationTime, + Viewport = new EveFieldsViewport { MinX = -1, MinY = -1, MaxX = 1, MaxY = 1 }, + Splats = new EveFieldsSplatSoa + { + Count = 1, + CenterX = new[] { 0d }, + CenterY = new[] { 0d }, + HalfExtentX = new[] { 1d }, + HalfExtentY = new[] { 1d }, + RotationCos = new[] { 1d }, + RotationSin = new[] { 0d }, + Channel = new[] { 0 }, + Falloff = new[] { EveFieldsSplatFalloffs.Solid }, + ValueR = new[] { 1d }, + ValueA = new[] { 1d }, + LayerIndex = new[] { 0 }, + SourceKind = new[] { EveFieldsSplatSourceKinds.AnimatedRadialCosine }, + FrequencyX = new[] { Math.PI * 2d }, + FrequencyY = new[] { 1.25d }, + PhaseX = new[] { 0d }, + AnimationSpeed = new[] { 1d } + } + }; + } + + private static EveFieldsSplatsDocument ProceduralDocument( + double min, + double max, + double center, + int sourceKind, + double simulationTime) + { + return new EveFieldsSplatsDocument + { + SimulationTimeSeconds = simulationTime, + Viewport = new EveFieldsViewport { MinX = min, MinY = 0, MaxX = max, MaxY = 100 }, + Splats = new EveFieldsSplatSoa + { + Count = 1, + CenterX = new[] { center }, + CenterY = new[] { 50d }, + HalfExtentX = new[] { 50d }, + HalfExtentY = new[] { 50d }, + RotationCos = new[] { 1d }, + RotationSin = new[] { 0d }, + Channel = new[] { 0 }, + Falloff = new[] { EveFieldsSplatFalloffs.Solid }, + ValueR = new[] { 1d }, + ValueA = new[] { 1d }, + LayerIndex = new[] { 0 }, + SourceKind = new[] { sourceKind }, + FrequencyX = new[] { 0.037d }, + FrequencyY = new[] { 0.041d }, + PhaseX = new[] { 0.17d }, + PhaseY = new[] { -0.23d }, + AnimationSpeed = new[] { 0.31d }, + SourceFlags = new[] { (double)EveFieldsSplatSourceFlags.AbsoluteValue } + } + }; + } + + private static float RenderFieldPixel(EveFieldsSplatsDocument document, int x, int y) + { + var host = new GameObject("eve-fields-procedural-capture"); + var rasterizer = host.AddComponent(); + var output = rasterizer.Render(document, 128, 128); + var previous = RenderTexture.active; + RenderTexture.active = output; + var readback = new Texture2D(128, 128, TextureFormat.RGBAFloat, false, true); + readback.ReadPixels(new Rect(0, 0, 128, 128), 0, 0); + readback.Apply(); + RenderTexture.active = previous; + var value = readback.GetPixel(x, y).r; + UnityEngine.Object.DestroyImmediate(readback); + UnityEngine.Object.DestroyImmediate(host); + return value; + } + private sealed class CaptureDocument : IEveFieldsSplatsDocument { public string Schema => EveFieldsSchemas.Splats; @@ -72,6 +808,18 @@ namespace GameCult.Eve.UnityScene.Tests public IEveFieldsSplatSoa Splats { get; } = new CaptureSplats(); } + private static Dictionary RequiredVolumeProgramMetadata() => + new Dictionary(StringComparer.Ordinal) + { + ["unity.volume.pass.raymarch"] = "0", + ["unity.volume.pass.composite"] = "2", + ["unity.volume.texturePort.cloud"] = "_Cloud", + ["unity.volume.matrixPort.cameraInverseViewProjection"] = "_CameraInverseViewProjection", + ["unity.volume.vectorPort.cameraProjectionExtents"] = "_CameraProjectionExtents", + ["unity.volume.vectorPort.viewportTransform"] = "_ViewportTransform", + ["unity.volume.floatPort.raymarchOffset"] = "_RaymarchOffset" + }; + private sealed class CaptureViewport : IEveFieldsViewport { public double MinX => -1; @@ -107,6 +855,8 @@ namespace GameCult.Eve.UnityScene.Tests public IReadOnlyList PhaseY => Zero; public IReadOnlyList AnimationSpeed => Zero; public IReadOnlyList SourceFlags => Zero; + public IReadOnlyList FalloffScale => One; + public IReadOnlyList FalloffExponent => One; } } } diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAdvertisedInputActionTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAdvertisedInputActionTests.cs new file mode 100644 index 0000000..f4d4190 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAdvertisedInputActionTests.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using GameCult.Eve.Surface; +using NUnit.Framework; + +#nullable enable + +namespace GameCult.Eve.UnityScene.Tests +{ + public sealed class EveUnityAdvertisedInputActionTests + { + [Test] + public void HeldValueOverridesAdvertisedPayloadWithoutMutatingCapability() + { + var sourcePayload = new Dictionary(StringComparer.Ordinal) + { + ["scalarValue"] = "0" + }; + var action = EveUnityAdvertisedInputAction.Resolve(new EveInputCapabilityDocument + { + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "pilot.scoop", + Operation = "aetheria.daemon.commands.SetTractorPower", + Availability = "available", + Payload = sourcePayload, + InputValue = new EveInputValueDocument + { + Model = EveUnityAdvertisedInputAction.ButtonHoldValueModel, + PayloadKey = "scalarValue" + } + } + } + }, "pilot.scoop"); + + var pressed = action.BuildPayload("entity.4", 1f); + var released = action.BuildPayload("entity.4", 0f); + + Assert.That(action.IsButtonHold, Is.True); + Assert.That(pressed["scalarValue"], Is.EqualTo("1")); + Assert.That(released["scalarValue"], Is.EqualTo("0")); + Assert.That(pressed["entityId"], Is.EqualTo("entity.4")); + Assert.That(pressed["actionId"], Is.EqualTo("pilot.scoop")); + Assert.That(sourcePayload["scalarValue"], Is.EqualTo("0")); + } + + [Test] + public void ValueFailsClosedWhenActionDoesNotAdvertiseValueContract() + { + var action = EveUnityAdvertisedInputAction.Resolve(new EveInputCapabilityDocument + { + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "pilot.dock", + Operation = "aetheria.daemon.commands.DockNearest", + Availability = "available" + } + } + }, "pilot.dock"); + + Assert.Throws(() => action.BuildPayload("entity.4", 1f)); + } + + [Test] + public void UnavailableActionCanOnlyBeResolvedForReleaseCleanup() + { + var capability = new EveInputCapabilityDocument + { + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "pilot.scoop", + Operation = "provider.commands.SetPower", + Availability = "unavailable", + InputValue = new EveInputValueDocument + { + Model = EveUnityAdvertisedInputAction.ButtonHoldValueModel, + PayloadKey = "active" + } + } + } + }; + + Assert.Throws(() => + EveUnityAdvertisedInputAction.Resolve(capability, "pilot.scoop")); + Assert.DoesNotThrow(() => + EveUnityAdvertisedInputAction.Resolve(capability, "pilot.scoop", requireAvailable: false) + .BuildPayload("entity.4", 0)); + } + + [Test] + public void ViewDirectionUsesAdvertisedKeysAndNormalizesWithoutMutatingCapability() + { + var sourcePayload = new Dictionary(StringComparer.Ordinal) + { + ["mode"] = "reticle" + }; + var action = EveUnityAdvertisedInputAction.Resolve(new EveInputCapabilityDocument + { + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "pilot.target-reticle", + Operation = "provider.commands.TargetReticle", + Availability = "available", + Payload = sourcePayload, + InputValue = new EveInputValueDocument + { + Model = EveUnityAdvertisedInputAction.ViewDirectionValueModel, + PayloadKeys = new[] { "rayX", "rayY", "rayZ" } + } + } + } + }, "pilot.target-reticle"); + + var payload = action.BuildViewDirectionPayload("entity.4", 3f, 4f, 0f); + + Assert.That(action.IsViewDirection, Is.True); + Assert.That(payload["entityId"], Is.EqualTo("entity.4")); + Assert.That(payload["actionId"], Is.EqualTo("pilot.target-reticle")); + Assert.That(payload["mode"], Is.EqualTo("reticle")); + Assert.That(float.Parse(payload["rayX"], System.Globalization.CultureInfo.InvariantCulture), Is.EqualTo(0.6f).Within(0.000001f)); + Assert.That(float.Parse(payload["rayY"], System.Globalization.CultureInfo.InvariantCulture), Is.EqualTo(0.8f).Within(0.000001f)); + Assert.That(float.Parse(payload["rayZ"], System.Globalization.CultureInfo.InvariantCulture), Is.EqualTo(0f).Within(0.000001f)); + Assert.That(sourcePayload.ContainsKey("rayX"), Is.False); + Assert.Throws(() => action.BuildPayload("entity.4", 1f)); + } + + [Test] + public void ViewDirectionFailsClosedForMalformedKeysAndZeroVector() + { + var action = EveUnityAdvertisedInputAction.Resolve(new EveInputCapabilityDocument + { + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "pilot.target-reticle", + Operation = "provider.commands.TargetReticle", + Availability = "available", + InputValue = new EveInputValueDocument + { + Model = EveUnityAdvertisedInputAction.ViewDirectionValueModel, + PayloadKeys = new[] { "direction", "direction", "direction" } + } + } + } + }, "pilot.target-reticle"); + + Assert.Throws(() => action.BuildViewDirectionPayload("entity.4", 0f, 0f, 1f)); + action = EveUnityAdvertisedInputAction.Resolve(new EveInputCapabilityDocument + { + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "pilot.target-reticle", + Operation = "provider.commands.TargetReticle", + Availability = "available", + InputValue = new EveInputValueDocument + { + Model = EveUnityAdvertisedInputAction.ViewDirectionValueModel, + PayloadKeys = new[] { "x", "y", "z" } + } + } + } + }, "pilot.target-reticle"); + Assert.Throws(() => action.BuildViewDirectionPayload("entity.4", 0f, 0f, 0f)); + } + + [Test] + public void ScalarUsesAdvertisedStateAndRangeWithoutMutatingCapability() + { + var sourcePayload = new Dictionary(StringComparer.Ordinal) + { + ["scalarValue"] = "300", + ["equipmentIndex"] = "2" + }; + var action = EveUnityAdvertisedInputAction.Resolve(new EveInputCapabilityDocument + { + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "equipment.2.temperature", + Operation = "provider.commands.SetTemperature", + Availability = "available", + Payload = sourcePayload, + InputValue = new EveInputValueDocument + { + Model = EveUnityAdvertisedInputAction.ScalarValueModel, + PayloadKey = "scalarValue", + CurrentValue = 300, + MinimumValue = 100, + MaximumValue = 600, + StepValue = 5, + Unit = "kelvin" + } + } + } + }, "equipment.2.temperature"); + + var payload = action.BuildScalarPayload("entity.4", 425.5); + + Assert.That(action.IsScalar, Is.True); + Assert.That(action.CurrentValue, Is.EqualTo(300)); + Assert.That(action.Unit, Is.EqualTo("kelvin")); + Assert.That(payload["scalarValue"], Is.EqualTo("425.5")); + Assert.That(payload["equipmentIndex"], Is.EqualTo("2")); + Assert.That(sourcePayload["scalarValue"], Is.EqualTo("300")); + Assert.Throws(() => action.BuildScalarPayload("entity.4", 99)); + Assert.Throws(() => action.BuildScalarPayload("entity.4", 601)); + Assert.Throws(() => action.BuildScalarPayload("entity.4", double.NaN)); + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAdvertisedInputActionTests.cs.meta b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAdvertisedInputActionTests.cs.meta new file mode 100644 index 0000000..5f325ab --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAdvertisedInputActionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a60138c42bf44c2ba38309b21a886c93 diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAimPresentationTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAimPresentationTests.cs new file mode 100644 index 0000000..fbff51e --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAimPresentationTests.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene.Tests +{ + public sealed class EveUnityAimPresentationTests + { + [Test] + public void ProjectionPreservesProviderAimConvergenceSemantic() + { + var projection = new EveUnitySceneProjection( + "provider", "surface", "world", "commands", "receipt", "provider", null, + Node("root", "surface", Node("aim", "aim.presentation", new Dictionary + { + ["controlledEntityId"] = "run.zone.0.entity.4", + ["convergenceTargetEntityId"] = "run.zone.0.entity.9", + ["viewDotRole"] = "aim.marker.view-direction", + ["minimumConvergenceDistance"] = "50", + ["viewDotRadius"] = "0.8" + }))); + + var aim = EveUnityAimPresentation.Find(projection); + + Assert.That(aim, Is.Not.Null); + Assert.That(aim!.ControlledEntityId, Is.EqualTo("run.zone.0.entity.4")); + Assert.That(aim.ConvergenceTargetEntityId, Is.EqualTo("run.zone.0.entity.9")); + Assert.That(aim.MinimumConvergenceDistance, Is.EqualTo(50f)); + Assert.That(aim.ViewDotRadius, Is.EqualTo(0.8f)); + } + + [Test] + public void PlanarYawProducesPortableUnitLookDirection() + { + var forward = EveUnityPlayableWorldLookDirection.FromPlanarYaw(0f); + var right = EveUnityPlayableWorldLookDirection.FromPlanarYaw(Mathf.PI * 0.5f); + + Assert.That(forward.DirectionX, Is.EqualTo(0f).Within(0.0001f)); + Assert.That(forward.DirectionZ, Is.EqualTo(1f).Within(0.0001f)); + Assert.That(right.DirectionX, Is.EqualTo(1f).Within(0.0001f)); + Assert.That(right.DirectionZ, Is.EqualTo(0f).Within(0.0001f)); + } + + private static EveUnitySceneNode Node(string id, string kind, params EveUnitySceneNode[] children) => + Node(id, kind, Empty(), children); + + private static EveUnitySceneNode Node(string id, string kind, IReadOnlyDictionary props, params EveUnitySceneNode[] children) => + new EveUnitySceneNode(id, kind, "scene-node", props, Empty(), Empty(), 0, 0, + Array.Empty(), null, children); + + private static IReadOnlyDictionary Empty() => + new Dictionary(StringComparer.Ordinal); + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAimPresentationTests.cs.meta b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAimPresentationTests.cs.meta new file mode 100644 index 0000000..c2b439c --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityAimPresentationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2bcc030e48554a49accc4efc105c36b4 diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityBeamPresentationTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityBeamPresentationTests.cs new file mode 100644 index 0000000..08186a3 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityBeamPresentationTests.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene.Tests +{ + public sealed class EveUnityBeamPresentationTests + { + [Test] + public void ProjectionPreservesProviderBeamSourceAssetAndFossilShape() + { + var beam = EveUnityBeamPresentation.FindAll(Projection(Node( + "tractor", "beam.presentation", new Dictionary(StringComparer.Ordinal) + { + ["sourceEntityId"] = "run.zone.0.entity.4", + ["assetRole"] = "effect.beam.tractor", + ["directionMode"] = "source-forward.v1", + ["renderChannel"] = "world.effects", + ["activationActionId"] = "pilot.scoop", + ["power"] = "0.625", + ["activationThreshold"] = "0.01", + ["radius"] = "25", + ["maximumDistance"] = "75" + }))).Single(); + + Assert.That(beam.SourceEntityId, Is.EqualTo("run.zone.0.entity.4")); + Assert.That(beam.AssetRole, Is.EqualTo("effect.beam.tractor")); + Assert.That(beam.ActivationActionId, Is.EqualTo("pilot.scoop")); + Assert.That(beam.UsesSourceForward, Is.True); + Assert.That(beam.Power, Is.EqualTo(0.625f).Within(0.0001f)); + Assert.That(beam.Radius, Is.EqualTo(25f)); + Assert.That(beam.MaximumDistance, Is.EqualTo(75f)); + } + + [Test] + public void RendererReconcilesProviderPrefabAgainstPresentedSourceWithoutPhysics() + { + var host = new GameObject("beam-test-host"); + var source = new GameObject("beam-test-source"); + var prefab = new GameObject("beam-test-prefab"); + prefab.AddComponent(); + var renderer = host.AddComponent(); + var registry = new Registry("source", source.transform); + var provider = new Provider(prefab); + try + { + renderer.Apply(new[] { Presentation(0.5f) }, registry, provider); + + Assert.That(provider.LastAssetRef, Is.EqualTo("effect.beam.tractor")); + Assert.That(provider.LastRole, Is.EqualTo("effect.beam.tractor")); + Assert.That(renderer.ActiveBeamCount, Is.EqualTo(1)); + Assert.That(renderer.TryGetPower("tractor", out var power), Is.True); + Assert.That(power, Is.EqualTo(0.5f).Within(0.0001f)); + var instance = source.transform.GetChild(0).gameObject; + Assert.That(instance.GetComponent().emission.rateOverTimeMultiplier, + Is.EqualTo(0.5f).Within(0.0001f)); + Assert.That(instance.GetComponentsInChildren(true), Is.Empty); + Assert.That(instance.GetComponentsInChildren(true), Is.Empty); + + renderer.Apply(new[] { Presentation(0.005f) }, registry, provider); + Assert.That(renderer.TryGetPower("tractor", out power), Is.True); + Assert.That(power, Is.Zero); + + renderer.Apply(Array.Empty(), registry, provider); + Assert.That(renderer.ActiveBeamCount, Is.Zero); + } + finally + { + UnityEngine.Object.DestroyImmediate(host); + UnityEngine.Object.DestroyImmediate(source); + UnityEngine.Object.DestroyImmediate(prefab); + } + } + + [Test] + public void RendererReadsDaemonPowerFromThePresentedEntityGeneration() + { + var host = new GameObject("beam-state-test-host"); + var source = new GameObject("beam-state-test-source"); + var prefab = new GameObject("beam-state-test-prefab"); + prefab.AddComponent(); + var renderer = host.AddComponent(); + var registry = new Registry( + "source", + source.transform, + new Dictionary(StringComparer.Ordinal) { ["effect.beam.power"] = 0.75f }); + try + { + var presentation = new EveUnityBeamPresentation(Node( + "tractor", "beam.presentation", new Dictionary(StringComparer.Ordinal) + { + ["sourceEntityId"] = "source", + ["assetRole"] = "effect.beam.tractor", + ["directionMode"] = "source-forward.v1", + ["power"] = "0.125", + ["powerStateSemantic"] = "effect.beam.power", + ["activationThreshold"] = "0.01" + })); + + renderer.Apply(new[] { presentation }, registry, new Provider(prefab)); + + Assert.That(renderer.TryGetPower("tractor", out var power), Is.True); + Assert.That(power, Is.EqualTo(0.75f).Within(0.0001f)); + } + finally + { + UnityEngine.Object.DestroyImmediate(host); + UnityEngine.Object.DestroyImmediate(source); + UnityEngine.Object.DestroyImmediate(prefab); + } + } + + private static EveUnityBeamPresentation Presentation(float power) => new EveUnityBeamPresentation(Node( + "tractor", "beam.presentation", new Dictionary(StringComparer.Ordinal) + { + ["sourceEntityId"] = "source", + ["assetRole"] = "effect.beam.tractor", + ["directionMode"] = "source-forward.v1", + ["power"] = power.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["activationThreshold"] = "0.01" + })); + + private static EveUnitySceneProjection Projection(params EveUnitySceneNode[] children) => + new EveUnitySceneProjection("provider", "surface", "world", "commands", "receipt", "provider", null, + Node("root", "surface", Empty(), children)); + + private static EveUnitySceneNode Node( + string id, + string kind, + IReadOnlyDictionary props, + params EveUnitySceneNode[] children) => + new EveUnitySceneNode(id, kind, "scene-node", props, Empty(), Empty(), 0, 0, + Array.Empty(), null, children); + + private static IReadOnlyDictionary Empty() => + new Dictionary(StringComparer.Ordinal); + + private sealed class Registry : IEveUnityPresentedEntityRegistry + { + private readonly EveUnityPresentedEntityHandle _handle; + public Registry( + string entityId, + Transform transform, + IReadOnlyDictionary? scalarState = null) + { + _handle = new EveUnityPresentedEntityHandle( + new EveUnityPresentedEntity(0, entityId, "ship", "", "", Vector3.zero, 0, Vector3.zero, + 1, 1, 0, 0, true, true, "", scalarState), + transform); + } + public EveUnityPresentedEntityGeneration? CurrentGeneration => null; + public bool TryGetByEntityId(string entityId, out EveUnityPresentedEntityHandle handle) + { + handle = _handle; + return string.Equals(entityId, _handle.Entity.EntityId, StringComparison.Ordinal); + } + public bool TryGetBySourceIndex(int sourceIndex, out EveUnityPresentedEntityHandle handle) + { + handle = _handle; + return sourceIndex == _handle.Entity.SourceIndex; + } + } + + private sealed class Provider : IEveUnityGameObjectAssetProvider + { + private readonly GameObject _prefab; + public Provider(GameObject prefab) { _prefab = prefab; } + public string LastAssetRef { get; private set; } = ""; + public string LastRole { get; private set; } = ""; + public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) + { + LastAssetRef = asset.AssetRef; + LastRole = asset.EntityKind; + return _prefab; + } + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityBeamPresentationTests.cs.meta b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityBeamPresentationTests.cs.meta new file mode 100644 index 0000000..84e1c67 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityBeamPresentationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7cd6b9cdacbecf14593d6ca2e0044849 \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCombatPresentationTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCombatPresentationTests.cs new file mode 100644 index 0000000..8e4bc25 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCombatPresentationTests.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using NUnit.Framework; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene.Tests +{ + public sealed class EveUnityCombatPresentationTests + { + [Test] + public void ProjectionPreservesProviderSelectionLockMetersAndFossilTiming() + { + var projection = Projection(Combat()); + var combat = EveUnityCombatPresentation.Find(projection); + + Assert.That(combat, Is.Not.Null); + Assert.That(combat!.ControlledEntityIndex, Is.EqualTo(4)); + Assert.That(combat.SelectedTargetEntityId, Is.EqualTo("run.zone.0.entity.9")); + Assert.That(combat.TargetVisible, Is.True); + Assert.That(combat.TargetHostile, Is.True); + Assert.That(combat.LockProgress, Is.EqualTo(0.6f).Within(0.0001f)); + Assert.That(combat.RadialFill(0), Is.EqualTo(0.25f).Within(0.0001f)); + Assert.That(combat.RadialFill(1), Is.EqualTo(0.75f).Within(0.0001f)); + Assert.That(combat.HitMarkerDurationSeconds, Is.EqualTo(0.25f)); + } + + [Test] + public void HitMarkerRequiresControlledSourceSelectedTargetAndAuthoritativeDamage() + { + var combat = new EveUnityCombatPresentation(Combat()); + + Assert.That(combat.PresentsHit(Shot(4, 9, true, 12, 0)), Is.True); + Assert.That(combat.PresentsHit(Shot(3, 9, true, 12, 0)), Is.False); + Assert.That(combat.PresentsHit(Shot(4, 8, true, 12, 0)), Is.False); + Assert.That(combat.PresentsHit(Shot(4, 9, false, 0, 0)), Is.False); + Assert.That(combat.PresentsHit(Shot(4, 9, true, 0, 0)), Is.False); + Assert.That(combat.PresentsHit(Shot(4, 9, true, 0, 12)), Is.True); + } + + [Test] + public void HitMarkerRetainsAuthoritativeEndpointAfterTargetLeavesTheWorld() + { + var root = new GameObject("combat-hit-retention-test"); + try + { + var renderer = root.AddComponent(); + var presentation = new EveUnityCombatPresentation(Combat()); + typeof(EveUnityCombatPresentationRenderer) + .GetField("_lastPresentation", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(renderer, presentation); + typeof(EveUnityCombatPresentationRenderer) + .GetMethod("OnShotAvailable", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(renderer, new object[] { Shot(4, 9, true, 12, 0, "7.5,-3.25") }); + typeof(EveUnityCombatPresentationRenderer) + .GetField("_hitUntil", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(renderer, Time.unscaledTime - 1f); + + renderer.RefreshNow(); + + Assert.That(renderer.HitMarkerVisible, Is.True, + "A destroyed target must not erase receipt-owned hit feedback."); + var line = root.transform.Find("Eve combat presentation/Hit marker A")! + .GetComponent(); + var center = (line.GetPosition(0) + line.GetPosition(1)) * 0.5f; + Assert.That(center.x, Is.EqualTo(7.5f).Within(0.0001f)); + Assert.That(center.z, Is.EqualTo(-3.25f).Within(0.0001f)); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + } + } + + private static EveUnitySceneProjection Projection(params EveUnitySceneNode[] children) => + new EveUnitySceneProjection("provider", "surface", "world", "commands", "receipt", "provider", null, + Node("root", "surface", children)); + + private static EveUnitySceneNode Combat() => Node("combat", "combat.presentation", new Dictionary(StringComparer.Ordinal) + { + ["controlledEntityId"] = "run.zone.0.entity.4", + ["controlledEntityIndex"] = "4", + ["selectedTargetEntityId"] = "run.zone.0.entity.9", + ["selectedTargetEntityIndex"] = "9", + ["targetVisible"] = "true", + ["targetHostile"] = "true", + ["contactInformation"] = "0.8", + ["shieldRatio"] = "0.4", + ["hullRatio"] = "0.9", + ["lockProgress"] = "0.6", + ["hitMarkerDurationSeconds"] = "0.25", + ["radialFillMinimum"] = "0.25", + ["radialFillMaximum"] = "0.75" + }); + + private static EveUnityShotReceipt Shot( + int source, + int target, + bool hit, + double damage, + double shield, + string endpoint = "0,0") => + new EveUnityShotReceipt(Node("shot", "shot.receipt", new Dictionary(StringComparer.Ordinal) + { + ["shotId"] = Guid.NewGuid().ToString("N"), + ["sourceEntityIndex"] = source.ToString(), + ["targetEntityIndex"] = target.ToString(), + ["hit"] = hit.ToString(), + ["appliedDamage"] = damage.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["shieldAbsorbedDamage"] = shield.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["endpoint"] = endpoint + })); + + private static EveUnitySceneNode Node(string id, string kind, params EveUnitySceneNode[] children) => + Node(id, kind, Empty(), children); + + private static EveUnitySceneNode Node(string id, string kind, IReadOnlyDictionary props, params EveUnitySceneNode[] children) => + new EveUnitySceneNode(id, kind, "scene-node", props, Empty(), Empty(), 0, 0, + Array.Empty(), null, children); + + private static IReadOnlyDictionary Empty() => + new Dictionary(StringComparer.Ordinal); + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCombatPresentationTests.cs.meta b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCombatPresentationTests.cs.meta new file mode 100644 index 0000000..6a4265c --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCombatPresentationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 30c8f313c1ea4b43858ed212909b7ce9 diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs new file mode 100644 index 0000000..376bc8a --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs @@ -0,0 +1,898 @@ +using System; +using System.Reflection; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GameCult.Eve.Surface; +using GameCult.Eve.UnityScene; +using GameCult.Mesh; +using NUnit.Framework; +using UnityEngine; + +namespace GameCult.Eve.UnityScene.Tests +{ + public sealed class EveUnityCultMeshAssetLookupTests + { + [Test] + public void ConnectCannotPerformRemoteWorkBeforeAsynchronousPreparation() + { + using var transport = new EveUnityCultMeshLiveProviderTransport( + "test-cache", + "cultnet+tcp://127.0.0.1:1", + "test.verse", + "test.runtime", + "test.provider", + "test.surface"); + + var elapsed = Stopwatch.StartNew(); + var failure = Assert.Throws(() => transport.Connect()); + + StringAssert.Contains("Await PrepareAsync", failure!.Message); + Assert.That(elapsed.Elapsed, Is.LessThan(TimeSpan.FromMilliseconds(100))); + } + + [Test] + public async Task CommandOutboxCompletesOnlyAfterCanonicalReceipt() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var sends = 0; + using var outbox = new EveUnityCultMeshCommandOutbox( + async (_, cancellationToken) => + { + Interlocked.Increment(ref sends); + entered.TrySetResult(true); + await AwaitOrCancel(release.Task, cancellationToken); + }, + _ => null, + retryDelay: TimeSpan.FromMilliseconds(250)); + + var elapsed = Stopwatch.StartNew(); + outbox.Enqueue(Request("one-shot", "dock")); + Assert.That(elapsed.Elapsed, Is.LessThan(TimeSpan.FromMilliseconds(100))); + await RequireCompletion(entered.Task); + Assert.That(outbox.PendingCount, Is.EqualTo(1)); + + release.TrySetResult(true); + await WaitUntilAsync(() => outbox.StateOf("one-shot") == + EveUnityCultMeshCommandOutbox.DeliveryState.AwaitingCanonicalReceipt); + Assert.That(outbox.PendingCount, Is.EqualTo(1)); + Assert.That(sends, Is.EqualTo(1)); + Assert.That(outbox.Acknowledge("one-shot"), Is.True); + Assert.That(outbox.Acknowledge("one-shot"), Is.False); + await WaitUntilAsync(() => outbox.PendingCount == 0); + } + + [Test] + public async Task CommandOutboxCoalescesBlockedContinuousInputToTheLatestValue() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var sent = new List(); + using var outbox = new EveUnityCultMeshCommandOutbox( + async (request, cancellationToken) => + { + lock (sent) sent.Add(request.CommandId); + entered.TrySetResult(true); + await AwaitOrCancel(release.Task, cancellationToken); + }, + request => request.PayloadFields["commandId"] + "\u001f" + request.PayloadFields["entityId"], + retryDelay: TimeSpan.FromMilliseconds(250)); + + outbox.Enqueue(Request("move-0", "move")); + await RequireCompletion(entered.Task); + for (var index = 1; index <= 1000; index++) + outbox.Enqueue(Request("move-" + index, "move")); + + Assert.That(outbox.PendingCount, Is.LessThanOrEqualTo(2)); + release.TrySetResult(true); + await WaitUntilAsync(() => outbox.StateOf("move-1000") == + EveUnityCultMeshCommandOutbox.DeliveryState.AwaitingCanonicalReceipt); + Assert.That(outbox.Acknowledge("move-1000"), Is.True); + await WaitUntilAsync(() => outbox.PendingCount == 0); + lock (sent) + { + CollectionAssert.AreEqual(new[] { "move-0", "move-1000" }, sent); + } + } + + [Test] + public async Task CommandOutboxRetriesSameIdWithoutLettingPoisonCommandStarveLaterWork() + { + var attempts = new List(); + using var outbox = new EveUnityCultMeshCommandOutbox( + (request, _) => + { + lock (attempts) attempts.Add(request.CommandId); + if (request.CommandId == "poison") throw new IOException("route unavailable"); + return Task.CompletedTask; + }, + _ => null, + retryDelay: TimeSpan.FromMilliseconds(20), + maximumRetryDelay: TimeSpan.FromMilliseconds(40)); + + outbox.Enqueue(Request("poison", "dock")); + outbox.Enqueue(Request("healthy", "undock")); + + await WaitUntilAsync(() => outbox.StateOf("healthy") == + EveUnityCultMeshCommandOutbox.DeliveryState.AwaitingCanonicalReceipt); + Assert.That(outbox.Acknowledge("healthy"), Is.True); + lock (attempts) + { + Assert.That(attempts.IndexOf("healthy"), Is.GreaterThanOrEqualTo(0)); + Assert.That(attempts.FindAll(value => value == "poison"), Is.Not.Empty); + } + Assert.That(outbox.PendingCount, Is.EqualTo(1)); + } + + [Test] + public async Task CommandOutboxRetransmitsUnacknowledgedCommandWithOriginalId() + { + var attempts = 0; + using var outbox = new EveUnityCultMeshCommandOutbox( + (request, _) => + { + Assert.That(request.CommandId, Is.EqualTo("retry-me")); + Interlocked.Increment(ref attempts); + return Task.CompletedTask; + }, + _ => null, + retryDelay: TimeSpan.FromMilliseconds(20), + maximumRetryDelay: TimeSpan.FromMilliseconds(20)); + + outbox.Enqueue(Request("retry-me", "dock")); + await WaitUntilAsync(() => Volatile.Read(ref attempts) >= 2); + Assert.That(outbox.PendingCount, Is.EqualTo(1)); + Assert.That(outbox.Acknowledge("retry-me"), Is.True); + await WaitUntilAsync(() => outbox.PendingCount == 0); + } + + [Test] + public void PendingReceiptIsObservedWithoutRetiringTheCommand() + { + using var transport = new EveUnityCultMeshLiveProviderTransport( + "test-cache", + "cultnet+tcp://127.0.0.1:1", + "test.verse", + "test.runtime", + "test.provider", + "test.surface"); + var request = Request("launch-1", "launch"); + var pendingField = typeof(EveUnityCultMeshLiveProviderTransport).GetField( + "_pendingCommands", + BindingFlags.NonPublic | BindingFlags.Instance); + var publish = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "PublishReceipt", + BindingFlags.NonPublic | BindingFlags.Instance); + Assert.That(pendingField, Is.Not.Null); + Assert.That(publish, Is.Not.Null); + var pending = (IDictionary)pendingField!.GetValue(transport)!; + pending.Add(request.CommandId, request); + var observed = new List(); + transport.CommandReceiptAvailable += observed.Add; + + publish!.Invoke(transport, new object[] { Receipt(request, "pending", "receipt-pending") }); + + Assert.That(pending.ContainsKey(request.CommandId), Is.True); + Assert.That(observed.Select(value => value.State), Is.EqualTo(new[] { "pending" })); + + publish.Invoke(transport, new object[] + { + Receipt(request, "accepted", "receipt-accepted", sourceVersion: 1000) + }); + + Assert.That(pending.ContainsKey(request.CommandId), Is.False); + Assert.That(observed.Select(value => value.State), Is.EqualTo(new[] { "pending", "accepted" })); + } + + [Test] + public void TerminalReceiptWaitsForItsPresentationGenerationAndFreezesTheOldSurface() + { + using var transport = new EveUnityCultMeshLiveProviderTransport( + "test-cache", + "cultnet+tcp://127.0.0.1:1", + "test.verse", + "test.runtime", + "test.provider", + "test.surface"); + var request = Request("select-verse-1", "selectVerse"); + var type = typeof(EveUnityCultMeshLiveProviderTransport); + var pending = (IDictionary)type.GetField( + "_pendingCommands", + BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(transport)!; + var publish = type.GetMethod("PublishReceipt", BindingFlags.NonPublic | BindingFlags.Instance)!; + var complete = type.GetMethod( + "CompletePresentationTransition", + BindingFlags.NonPublic | BindingFlags.Instance)!; + type.GetField("_bootstrapped", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(transport, true); + complete.Invoke(transport, new object[] { 1L }); + pending.Add(request.CommandId, request); + var observed = new List(); + transport.CommandReceiptAvailable += observed.Add; + using var outbox = new EveUnityCultMeshCommandOutbox( + (_, _) => Task.CompletedTask, + _ => null, + retryDelay: TimeSpan.FromSeconds(30), + maximumRetryDelay: TimeSpan.FromSeconds(30)); + type.GetField("_commandOutbox", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(transport, outbox); + outbox.Enqueue(request); + + publish.Invoke(transport, new object[] + { + Receipt( + request, + "accepted", + "receipt-accepted", + sourceVersion: 1000, + presentationSurfaceVersion: 7) + }); + + Assert.That(pending.ContainsKey(request.CommandId), Is.True); + Assert.That(outbox.PendingCount, Is.Zero); + Assert.That(observed, Is.Empty); + type.GetProperty("CurrentSurfaceDocument")!.SetValue(transport, SceneSurface(100)); + var blocked = Assert.Throws(() => + transport.SubmitCommand(Request("stale-launch", "launch"))); + StringAssert.Contains("read-only", blocked!.Message); + + complete.Invoke(transport, new object[] { 7L }); + + Assert.That(pending.ContainsKey(request.CommandId), Is.False); + Assert.That(observed.Select(value => value.State), Is.EqualTo(new[] { "accepted" })); + } + + [Test] + public void ReceiptForAnotherInvocationCannotRetireTheCommand() + { + using var transport = new EveUnityCultMeshLiveProviderTransport( + "test-cache", + "cultnet+tcp://127.0.0.1:1", + "test.verse", + "test.runtime", + "test.provider", + "test.surface"); + var request = Request("launch-1", "launch"); + var pendingField = typeof(EveUnityCultMeshLiveProviderTransport).GetField( + "_pendingCommands", + BindingFlags.NonPublic | BindingFlags.Instance); + var publish = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "PublishReceipt", + BindingFlags.NonPublic | BindingFlags.Instance); + var pending = (IDictionary)pendingField!.GetValue(transport)!; + pending.Add(request.CommandId, request); + var observed = new List(); + transport.CommandReceiptAvailable += observed.Add; + var collision = new EveCommandReceiptDocument( + "receipt-collision", + request.CommandId, + request.Command, + "accepted", + "AetheriaEve", + "test.runtime", + request.ProviderId, + request.SurfaceId, + "", + DateTimeOffset.UtcNow.ToString("O"), + 1, + invocationHash: EveCommandInvocationHash.Compute(Request(request.CommandId, "another-payload"))); + + publish!.Invoke(transport, new object[] { collision }); + + Assert.That(pending.ContainsKey(request.CommandId), Is.True); + Assert.That(observed, Is.Empty); + } + + [TestCase("provider", "other.provider")] + [TestCase("surface", "other.surface")] + [TestCase("authority", "other.runtime")] + [TestCase("state", "mysterious")] + public void UntrustedReceiptCannotRetireOrPublishTheCommand(string field, string value) + { + using var transport = new EveUnityCultMeshLiveProviderTransport( + "test-cache", + "cultnet+tcp://127.0.0.1:1", + "test.verse", + "test.runtime", + "test.provider", + "test.surface"); + var request = Request("launch-untrusted", "launch"); + var type = typeof(EveUnityCultMeshLiveProviderTransport); + var pending = (IDictionary)type.GetField( + "_pendingCommands", + BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(transport)!; + var publish = type.GetMethod("PublishReceipt", BindingFlags.NonPublic | BindingFlags.Instance)!; + pending.Add(request.CommandId, request); + var observed = new List(); + transport.CommandReceiptAvailable += observed.Add; + + var providerId = field == "provider" ? value : request.ProviderId; + var surfaceId = field == "surface" ? value : request.SurfaceId; + var authority = field == "authority" ? value : "test.runtime"; + var state = field == "state" ? value : "accepted"; + var receipt = new EveCommandReceiptDocument( + "receipt-untrusted-" + field, + request.CommandId, + request.Command, + state, + "AetheriaEve", + authority, + providerId, + surfaceId, + "", + DateTimeOffset.UtcNow.ToString("O"), + 1, + invocationHash: EveCommandInvocationHash.Compute(request)); + + publish.Invoke(transport, new object[] { receipt }); + + Assert.That(pending.ContainsKey(request.CommandId), Is.True); + Assert.That(observed, Is.Empty); + } + + [Test] + public void ProviderSelectionCarriesStableIdentityWithoutExposingAPhysicalRoute() + { + var selection = new EveUnityCultMeshProviderSelection( + "cultnet+tcp://odin.example:3075", + "aetheria.public", + "aetheria.public", + "aetheria.daemon", + "aetheria.pilot", + "interactive-world"); + + Assert.That(selection.RendezvousEndpoint, Is.EqualTo("cultnet+tcp://odin.example:3075")); + Assert.That(selection.VerseId, Is.EqualTo("aetheria.public")); + Assert.That(selection.AuthorityRuntimeId, Is.EqualTo("aetheria.public")); + Assert.That(typeof(EveUnityCultMeshProviderSelection).GetProperty("Endpoint"), Is.Null); + } + + [Test] + public void NavigationAuthorityCannotBeReplacedByAlphabeticallyEarlierPeer() + { + var target = new EveUnitySceneNavigationTarget( + "gamecult.aetheria", + "aetheria.daemon", + "aetheria.pilot", + "interactive-world", + new[] { "cultnet+tcp://odin.example:3075" }, + "z-owner"); + var eligible = EveUnityCultMeshProviderDiscovery.EligibleAuthorityRuntimeIds( + new[] { "a-decoy", "z-owner" }, + target.AuthorityRuntimeId); + + Assert.That(target.AuthorityRuntimeId, Is.EqualTo("z-owner")); + CollectionAssert.AreEqual(new[] { "z-owner" }, eligible); + } + + private static EveSurfaceCommandRequest Request(string id, string command) => new EveSurfaceCommandRequest( + "test.provider", + "test.surface", + CultMesh.OperationInvocation("test.command", idempotencyKey: id), + CultMesh.OperationPayload(new Dictionary + { + ["commandId"] = command, + ["entityId"] = "ship" + }), + DateTimeOffset.UtcNow, + "test-client"); + + private static EveCommandReceiptDocument Receipt( + EveSurfaceCommandRequest request, + string state, + string receiptId, + long sourceVersion = 0, + long presentationSurfaceVersion = 0) => new EveCommandReceiptDocument( + receiptId, + request.CommandId, + request.Command, + state, + "AetheriaEve", + "test.runtime", + request.ProviderId, + request.SurfaceId, + "", + DateTimeOffset.UtcNow.ToString("O"), + sourceVersion, + invocationHash: EveCommandInvocationHash.Compute(request), + presentationSurfaceVersion: presentationSurfaceVersion); + + [Test] + public void FailedNewestCandidateCanRetryWithoutAllowingAnOlderSurface() + { + using var transport = new EveUnityCultMeshLiveProviderTransport( + "test-cache", + "cultnet+tcp://127.0.0.1:1", + "test.verse", + "test.runtime", + "test.provider", + "test.surface"); + var begin = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "TryBeginBaseSurfaceCandidate", + BindingFlags.NonPublic | BindingFlags.Instance)!; + var retire = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "RetireFailedBaseSurfaceCandidate", + BindingFlags.NonPublic | BindingFlags.Instance)!; + var first = new object[] { 10L, -1L }; + var older = new object[] { 7L, -1L }; + var duplicate = new object[] { 10L, -1L }; + + Assert.That(begin.Invoke(transport, first), Is.True); + Assert.That(begin.Invoke(transport, older), Is.False); + Assert.That(begin.Invoke(transport, duplicate), Is.False); + retire.Invoke(transport, new[] { first[0], first[1] }); + var retry = new object[] { 10L, -1L }; + Assert.That(begin.Invoke(transport, retry), Is.True); + Assert.That((long)retry[1], Is.GreaterThan((long)first[1])); + Assert.That(begin.Invoke(transport, new object[] { 9L, -1L }), Is.False); + } + + [Test] + public async Task FailedAssetPreparationRunsBoundedRetryPolicyAndRetiresCandidate() + { + using var transport = new EveUnityCultMeshLiveProviderTransport( + "test-cache", + "cultnet+tcp://127.0.0.1:1", + "test.verse", + "test.runtime", + "test.provider", + "test.surface"); + var type = typeof(EveUnityCultMeshLiveProviderTransport); + var begin = type.GetMethod( + "TryBeginBaseSurfaceCandidate", + BindingFlags.NonPublic | BindingFlags.Instance)!; + var refresh = type.GetMethod( + "RefreshAssetsForSurfaceAsync", + BindingFlags.NonPublic | BindingFlags.Instance)!; + var attempts = type.GetField( + "_lastAssetPresentationAttemptCount", + BindingFlags.NonPublic | BindingFlags.Instance)!; + var first = new object[] { 10L, -1L }; + Assert.That(begin.Invoke(transport, first), Is.True); + var task = (Task)refresh.Invoke(transport, new object[] + { + SceneSurface(10).SurfaceDocument, + CreateAssetSource("test.verse", "test.runtime", "test.provider", "catalog", 41), + (long)first[1], + CancellationToken.None + })!; + + await task; + + Assert.That(attempts.GetValue(transport), Is.EqualTo(3)); + Assert.That(begin.Invoke(transport, new object[] { 10L, -1L }), Is.True); + } + + private static EveUnitySceneProviderSurfaceDocument SceneSurface(long version) + { + var surface = new EveSurfaceDocument( + "test.provider", + "", + "", + version, + "", + new EveSurfaceTree( + "test.surface", + new EveSurfaceComponent( + "root", + "surface", + new Dictionary(), + Array.Empty()), + Array.Empty()), + Array.Empty()); + return new EveUnitySceneProviderSurfaceDocument( + surface, + new EveUnitySceneProviderSurfaceAdvertisement( + "test.surface", + "interactive-world", + new EveUnitySceneWorldInteraction("", "", EveCommandReceiptDocument.SchemaId, "")), + "test:surface", + version); + } + + private static async Task AwaitOrCancel(Task task, CancellationToken cancellationToken) + { + var cancelled = Task.Delay(Timeout.Infinite, cancellationToken); + if (await Task.WhenAny(task, cancelled) != task) + cancellationToken.ThrowIfCancellationRequested(); + await task; + } + + private static async Task RequireCompletion(Task task) + { + if (await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(2))) != task) + Assert.Fail("Timed out waiting for asynchronous command delivery."); + await task; + } + + private static async Task WaitUntilAsync(Func predicate) + { + var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(2); + while (!predicate() && DateTimeOffset.UtcNow < deadline) + await Task.Delay(10); + Assert.That(predicate(), Is.True, "Timed out waiting for the command outbox to drain."); + } + + [Test] + public void LiveTransportOwnsOneCultMeshClientAndNoDirectSnapshotReplica() + { + var fields = typeof(EveUnityCultMeshLiveProviderTransport) + .GetFields(BindingFlags.Instance | BindingFlags.NonPublic); + var names = Array.ConvertAll(fields, field => field.Name); + + CollectionAssert.Contains(names, "_meshClient"); + CollectionAssert.DoesNotContain(names, "_node"); + CollectionAssert.DoesNotContain(names, "_snapshot"); + CollectionAssert.DoesNotContain(names, "_networkRegistry"); + CollectionAssert.DoesNotContain(names, "_replicaShard"); + CollectionAssert.DoesNotContain(names, "_subscriptions"); + CollectionAssert.DoesNotContain(names, "_entitySubscriptions"); + } + + [Test] + public void UnavailableTcpRendezvousIsReportedAsRetryablePreparationFailure() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + + var error = Assert.ThrowsAsync(() => + new EveUnityCultMeshProviderDiscovery().DiscoverAsync($"cultnet+tcp://127.0.0.1:{port}")); + + StringAssert.Contains("Could not query CultMesh rendezvous endpoint", error!.Message); + Assert.That(error.InnerException, Is.TypeOf()); + } + + [Test] + public void UnpreparedProviderGetterFailsFastWithoutStartingNetworkDiscovery() + { + var gameObject = new GameObject("unprepared-eve-provider"); + try + { + var provider = gameObject.AddComponent(); + provider.Configure("rudp://127.0.0.1:1"); + var stopwatch = Stopwatch.StartNew(); + + var error = Assert.Throws(() => _ = provider.CurrentInputCapability); + + stopwatch.Stop(); + StringAssert.Contains("Await PrepareAsync", error!.Message); + Assert.That(stopwatch.ElapsedMilliseconds, Is.LessThan(100)); + Assert.That(provider.Selection, Is.Null); + } + finally + { + UnityEngine.Object.DestroyImmediate(gameObject); + } + } + + [Test] + public void ProviderSubscriptionsDoNotDependOnPreparedTransportLifetime() + { + var gameObject = new GameObject("unprepared-eve-provider-subscriptions"); + var provider = gameObject.AddComponent(); + Action documentHandler = _ => { }; + Action receiptHandler = _ => { }; + + Assert.DoesNotThrow(() => provider.DocumentAvailable += documentHandler); + Assert.DoesNotThrow(() => provider.ReceiptAvailable += receiptHandler); + Assert.DoesNotThrow(() => provider.DocumentAvailable -= documentHandler); + Assert.DoesNotThrow(() => provider.ReceiptAvailable -= receiptHandler); + Assert.DoesNotThrow(() => UnityEngine.Object.DestroyImmediate(gameObject)); + } + + [Test] + public void ResolvesUnityNormalizedBundleNamesWithoutChangingLogicalAssetIdentity() + { + var method = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "ResolveBundleAssetName", + BindingFlags.Static | BindingFlags.NonPublic); + + Assert.That(method, Is.Not.Null); + var result = method!.Invoke(null, new object[] + { + new[] { "assets/generated/eve/thermal/death.asset" }, + "Assets/Generated/Eve/Thermal/Death.asset" + }); + + Assert.That(result, Is.EqualTo("assets/generated/eve/thermal/death.asset")); + } + + [Test] + public void RejectsBundleNamesThatDoNotMatchTheAdvertisedAsset() + { + var method = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "ResolveBundleAssetName", + BindingFlags.Static | BindingFlags.NonPublic); + + var result = method!.Invoke(null, new object[] + { + new[] { "assets/generated/eve/thermal/heatstroke.asset" }, + "Assets/Generated/Eve/Thermal/Death.asset" + }); + + Assert.That(result, Is.Null); + } + + [Test] + public void SelectedRuntimeVariantOwnsConcreteNativeProgramBindings() + { + var method = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "MergeAssetMetadata", + BindingFlags.Static | BindingFlags.NonPublic); + + Assert.That(method, Is.Not.Null); + var result = method!.Invoke(null, new object[] + { + new Dictionary { ["presentationRole"] = "environment.volume" }, + new Dictionary + { + ["unity.volume.texturePort.surfaceHeight"] = "_NebulaSurfaceHeight", + ["unity.volume.pass.raymarch"] = "0" + } + }) as IReadOnlyDictionary; + + Assert.That(result, Is.Not.Null); + Assert.That(result!["presentationRole"], Is.EqualTo("environment.volume")); + Assert.That(result["unity.volume.texturePort.surfaceHeight"], Is.EqualTo("_NebulaSurfaceHeight")); + Assert.That(result["unity.volume.pass.raymarch"], Is.EqualTo("0")); + } + + [Test] + public void ConfiguringCandidateCatalogRegistersBundlesWithoutMaterializingThem() + { + var platformMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "CurrentBundlePlatform", + BindingFlags.Static | BindingFlags.NonPublic); + var configureMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "ConfigureAssetCatalog", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.That(platformMethod, Is.Not.Null); + Assert.That(configureMethod, Is.Not.Null); + var variant = new EveAssetVariant( + "unity-scene", + (string)platformMethod!.Invoke(null, Array.Empty())!, + "unity-assetbundle", + "cdn:manifest:lazy", + "sha256:00", + 1, + "assets/prefab.prefab"); + var catalog = new EveAssetCatalogDocument( + "provider", + "catalog", + 1, + DateTimeOffset.UtcNow.ToString("O"), + new[] { new EveAssetCatalogEntry("prefab.entity", "prefab", new[] { variant }) }); + var candidate = CreateAssetGeneration("verse", "authority", "provider", "catalog", "candidate", 1); + + configureMethod!.Invoke(null, new[] { candidate, catalog }); + + Assert.That(GenerationCollection(candidate, "AssetBundles").Count, Is.Zero); + Assert.That(GenerationDictionary(candidate, "AssetSelections").Count, Is.EqualTo(1)); + ((IDisposable)candidate).Dispose(); + } + + [Test] + public void FailedAssetCandidatePreservesCommittedGeneration() + { + var transportType = typeof(EveUnityCultMeshLiveProviderTransport); + var generationField = transportType.GetField( + "_assetGeneration", + BindingFlags.Instance | BindingFlags.NonPublic)!; + var configureMethod = transportType.GetMethod( + "ConfigureAssetCatalog", + BindingFlags.Static | BindingFlags.NonPublic)!; + using var transport = new EveUnityCultMeshLiveProviderTransport( + Path.Combine(Path.GetTempPath(), $"eve-asset-generation-{Guid.NewGuid():N}.cc"), + "cultnet+tcp://127.0.0.1:1", + "verse-a", + "authority-a", + "provider-a", + "surface-a"); + var initial = (IDisposable)generationField.GetValue(transport)!; + var committed = CreateAssetGeneration( + "verse-a", "authority-a", "provider-a", "catalog-a", "source-a", 1); + var prefab = new GameObject("committed-a"); + GenerationDictionary(committed, "Prefabs")["prefab.a"] = prefab; + initial.Dispose(); + generationField.SetValue(transport, committed); + + var invalidVariant = new EveAssetVariant( + "browser", + "web", + "url", + "https://example.invalid/a", + "sha256:00", + 1, + "prefab.a"); + var invalidCatalog = new EveAssetCatalogDocument( + "provider-b", + "catalog-b", + 2, + DateTimeOffset.UtcNow.ToString("O"), + new[] { new EveAssetCatalogEntry("prefab.b", "prefab", new[] { invalidVariant }) }); + var rejected = CreateAssetGeneration( + "verse-b", "authority-b", "provider-b", "catalog-b", "source-b", -1); + var error = Assert.Throws(() => + configureMethod.Invoke(null, new[] { rejected, invalidCatalog })); + Assert.That(error!.InnerException, Is.TypeOf()); + ((IDisposable)rejected).Dispose(); + Assert.That(generationField.GetValue(transport), Is.SameAs(committed)); + Assert.That(GenerationProperty(committed, "SourceIdentity"), Is.EqualTo("source-a")); + Assert.That(GenerationDictionary(committed, "Prefabs")["prefab.a"], Is.SameAs(prefab)); + UnityEngine.Object.DestroyImmediate(prefab); + } + + [Test] + public void CandidateCatalogObservationSurvivesPrecommitWindow() + { + var candidate = CreateAssetGeneration( + "verse-b", "authority-b", "provider-b", "catalog-b", "source-b", 1); + var candidateType = candidate.GetType(); + var observe = candidateType.GetMethod("ObserveCatalog")!; + var activate = candidateType.GetMethod("ActivateCatalogObservation")!; + var variant = new EveAssetVariant( + "unity-scene", "StandaloneWindows64", "url", "https://example.invalid/b", "sha256:01", 1, "prefab.b"); + var catalogB = new EveAssetCatalogDocument( + "provider-b", "catalog-b", 2, DateTimeOffset.UtcNow.ToString("O"), + new[] { new EveAssetCatalogEntry("prefab.b", "prefab", new[] { variant }) }); + + Assert.That(observe.Invoke(candidate, new object[] { catalogB }), Is.Null); + Assert.That(activate.Invoke(candidate, Array.Empty()), Is.SameAs(catalogB)); + ((IDisposable)candidate).Dispose(); + } + + [Test] + public void PinnedSurfaceCatalogDoesNotAutoAdvanceWithoutANewerBaseSurface() + { + var transportType = typeof(EveUnityCultMeshLiveProviderTransport); + var generationField = transportType.GetField( + "_assetGeneration", + BindingFlags.Instance | BindingFlags.NonPublic)!; + var pendingField = transportType.GetField( + "_pendingAssetCatalog", + BindingFlags.Instance | BindingFlags.NonPublic)!; + var queue = transportType.GetMethod( + "QueueAssetCatalogUpdate", + BindingFlags.Instance | BindingFlags.NonPublic)!; + using var transport = new EveUnityCultMeshLiveProviderTransport( + Path.Combine(Path.GetTempPath(), $"eve-pinned-catalog-{Guid.NewGuid():N}.cc"), + "cultnet+tcp://127.0.0.1:1", + "verse-a", + "authority-a", + "provider-a", + "surface-a"); + var initial = (IDisposable)generationField.GetValue(transport)!; + var committed = CreateAssetGeneration( + "verse-a", "authority-a", "provider-a", "catalog-a", "source-a", 41, 41); + initial.Dispose(); + generationField.SetValue(transport, committed); + var catalog42 = new EveAssetCatalogDocument( + "provider-a", + "catalog-a", + 42, + DateTimeOffset.UtcNow.ToString("O"), + Array.Empty()); + + queue.Invoke(transport, new object[] { "source-a", catalog42 }); + + Assert.That(GenerationProperty(generationField.GetValue(transport)!, "CatalogVersion"), Is.EqualTo(41)); + var committedSource = GenerationProperty(generationField.GetValue(transport)!, "Source"); + Assert.That(GenerationProperty(committedSource, "RequiredCatalogVersion"), Is.EqualTo(41)); + Assert.That(pendingField.GetValue(transport), Is.Null); + } + + [Test] + public void PinnedSurfaceRejectsWrongImmutableCatalogRecordImmediately() + { + var require = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "RequireCatalogVersion", + BindingFlags.Static | BindingFlags.NonPublic)!; + var catalog42 = new EveAssetCatalogDocument( + "provider-a", + "eve:assets:aetheria.daemon:version:42", + 42, + DateTimeOffset.UtcNow.ToString("O"), + Array.Empty()); + + var error = Assert.Throws(() => require.Invoke( + null, + new object[] { catalog42, 41L, "eve:assets:aetheria.daemon:version:41" })); + + Assert.That(error!.InnerException, Is.TypeOf()); + Assert.That(error.InnerException!.Message, Does.Contain("requires immutable version 41")); + } + + [Test] + public void CrossTargetAssetsUseConfiguredRemoteTrust() + { + var localTrust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment); + var remoteTrust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.AuthenticatedRemote); + using var transport = new EveUnityCultMeshLiveProviderTransport( + Path.Combine(Path.GetTempPath(), $"eve-asset-trust-{Guid.NewGuid():N}.cc"), + "cultnet+tcp://127.0.0.1:1", + "verse-a", + "authority-a", + "provider-a", + "surface-a", + authorityTrust: localTrust, + crossTargetAuthorityTrust: remoteTrust); + var trustMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "AssetAuthorityTrust", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + var localSource = CreateAssetSource("verse-a", "authority-a", "provider-a", "catalog-a"); + var remoteSource = CreateAssetSource("verse-b", "authority-b", "provider-b", "catalog-b"); + + Assert.That(trustMethod.Invoke(transport, new[] { localSource }), Is.SameAs(localTrust)); + Assert.That(trustMethod.Invoke(transport, new[] { remoteSource }), Is.SameAs(remoteTrust)); + } + + private static object CreateAssetSource( + string verseId, + string authorityRuntimeId, + string providerId, + string manifestRecordRef, + long requiredCatalogVersion = 0) + { + var type = typeof(EveUnityCultMeshLiveProviderTransport).GetNestedType( + "AssetSource", + BindingFlags.NonPublic)!; + return Activator.CreateInstance( + type, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + null, + new object[] + { + new CultMeshSessionTarget(verseId, authorityRuntimeId), + providerId, + manifestRecordRef, + Array.Empty(), + requiredCatalogVersion + }, + null)!; + } + + private static object CreateAssetGeneration( + string verseId, + string authorityRuntimeId, + string providerId, + string manifestRecordRef, + string sourceIdentity, + long catalogVersion, + long requiredCatalogVersion = 0) + { + var type = typeof(EveUnityCultMeshLiveProviderTransport).GetNestedType( + "AssetGeneration", + BindingFlags.NonPublic)!; + return Activator.CreateInstance( + type, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + null, + new[] + { + CreateAssetSource(verseId, authorityRuntimeId, providerId, manifestRecordRef, requiredCatalogVersion), + sourceIdentity, + catalogVersion, + null + }, + null)!; + } + + private static System.Collections.IList GenerationCollection(object generation, string property) => + (System.Collections.IList)generation.GetType().GetProperty(property)!.GetValue(generation)!; + + private static System.Collections.IDictionary GenerationDictionary(object generation, string property) => + (System.Collections.IDictionary)generation.GetType().GetProperty(property)!.GetValue(generation)!; + + private static T GenerationProperty(object generation, string property) => + (T)generation.GetType().GetProperty(property)!.GetValue(generation)!; + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs.meta b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs.meta new file mode 100644 index 0000000..790a2ed --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d746a249576c8e74696ea8add7a25731 \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityEmbeddedSurfaceCompositionTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityEmbeddedSurfaceCompositionTests.cs new file mode 100644 index 0000000..c3d1924 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityEmbeddedSurfaceCompositionTests.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Eve.Surface; +using NUnit.Framework; + +namespace GameCult.Eve.UnityScene.Tests +{ + public sealed class EveUnityEmbeddedSurfaceCompositionTests + { + [Test] + public void ComposesAdvertisedReactiveSurfaceWithoutReplacingBaseTopology() + { + const string record = "eve:surface:test.reactive"; + var slot = new EveEmbeddedDocumentSlot( + "reactive", + record, + EveSurfaceDocument.SchemaId, + "reactive-state"); + var world = Component("world", "world.scene3d"); + var mount = new EveSurfaceComponent( + "reactive-mount", + "layer.reactive", + EmptyProps(), + Array.Empty(), + Array.Empty(), + new[] { slot }); + var @base = Surface(4, Component("root", "surface", world, mount)); + var fragment = Surface(12, Component( + "fragment", + "layer.reactive", + Component("shot", "shot.receipt"))); + + var composed = EveUnityCultMeshLiveProviderTransport.ComposeSurface( + @base, + new Dictionary(StringComparer.Ordinal) + { + [record] = fragment + }); + + Assert.That(composed.Version, Is.EqualTo(12)); + Assert.That(composed.Surface.Root.Children[0].Id, Is.EqualTo("world")); + Assert.That(composed.Surface.Root.Children[1].Children.Single().Id, Is.EqualTo("fragment")); + Assert.That(composed.Surface.Root.Children[1].Children.Single().Children.Single().Kind, + Is.EqualTo("shot.receipt")); + } + + private static EveSurfaceDocument Surface(long version, EveSurfaceComponent root) => + new EveSurfaceDocument( + "provider", + "test", + "Test", + version, + "2026-07-19T00:00:00Z", + new EveSurfaceTree("test", root, Array.Empty()), + Array.Empty()); + + private static EveSurfaceComponent Component( + string id, + string kind, + params EveSurfaceComponent[] children) => + new EveSurfaceComponent(id, kind, EmptyProps(), children); + + private static IReadOnlyDictionary EmptyProps() => + new Dictionary(StringComparer.Ordinal); + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityEmbeddedSurfaceCompositionTests.cs.meta b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityEmbeddedSurfaceCompositionTests.cs.meta new file mode 100644 index 0000000..d304bb8 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityEmbeddedSurfaceCompositionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8e721e8052744db6ba925b8a3831de70 diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityFeedbackPresenterTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityFeedbackPresenterTests.cs index e4bd612..8dc0d35 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityFeedbackPresenterTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityFeedbackPresenterTests.cs @@ -29,14 +29,51 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(emitted.Count, Is.EqualTo(1)); } + [Test] + public void FeedbackEventPreservesProviderOwnedCargoDeltaAndReason() + { + var node = Event("pickup", "pickup.collected", 9, new Dictionary + { + ["itemKey"] = "scrap-metal", + ["scalarValue"] = "1", + ["auxiliaryValue"] = "0", + ["cargoQuantityBefore"] = "0", + ["cargoQuantityAfter"] = "1", + ["reason"] = "" + }); + + var value = new EveUnityFeedbackEvent(node); + + Assert.That(value.ItemKey, Is.EqualTo("scrap-metal")); + Assert.That(value.ScalarValue, Is.EqualTo(1)); + Assert.That(value.AuxiliaryValue, Is.Zero); + Assert.That(value.CargoQuantityBefore, Is.Zero); + Assert.That(value.CargoQuantityAfter, Is.EqualTo(1)); + Assert.That(value.Reason, Is.Empty); + } + private static EveUnitySceneProjection Projection(params EveUnitySceneNode[] events) => new EveUnitySceneProjection("provider", "surface", "world", "commands", "receipt", "provider", null, Node("root", "surface", Node("feedback", "feedback.stream", events))); - private static EveUnitySceneNode Event(string id, string kind, long frame) => + private static EveUnitySceneNode Event( + string id, + string kind, + long frame, + IReadOnlyDictionary? extra = null) + { + var props = new Dictionary(StringComparer.Ordinal) + { + ["eventId"] = id, + ["eventKind"] = kind, + ["frameId"] = frame.ToString() + }; + foreach (var pair in extra ?? Empty()) props[pair.Key] = pair.Value; + return new EveUnitySceneNode(id, "feedback.event", "scene-node", - new Dictionary(StringComparer.Ordinal) { ["eventId"] = id, ["eventKind"] = kind, ["frameId"] = frame.ToString() }, + props, Empty(), Empty(), 0, 0, Array.Empty(), null, Array.Empty()); + } private static EveUnitySceneNode Node(string id, string kind, params EveUnitySceneNode[] children) => new EveUnitySceneNode(id, kind, "scene-node", Empty(), Empty(), Empty(), 0, 0, diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs index 5c3557e..7f83143 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -1,10 +1,21 @@ using System; using System.Collections.Generic; -using System.IO.MemoryMappedFiles; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Collections; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Eve.PluginFields; using GameCult.Eve.Surface; using GameCult.Mesh; using NUnit.Framework; using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.Rendering.Universal; +using UnityEngine.UIElements; +using UnityEngine.TestTools; #nullable enable @@ -13,42 +24,498 @@ namespace GameCult.Eve.UnityScene.Tests public sealed class EveUnitySceneSurfaceLowererTests { [Test] - public void EntitySoaViewReadsGenericSemanticColumnsFromAdvertisedBackend() + public void LiveTransportDefersDefaultBodyAuthorizationUntilAdvertisementResolution() { - var location = $"eve-unity-soa-{Guid.NewGuid():N}"; - using var mapped = MemoryMappedFile.CreateNew(location, 32); - using (var writer = mapped.CreateViewAccessor()) + Assert.DoesNotThrow(() => { - writer.Write(0, 4f); - writer.Write(4, 5f); - writer.Write(8, 6f); - writer.Write(12, 7f); - writer.Write(16, 8f); - writer.Write(20, 9f); - writer.Write(24, 41); - writer.Write(28, 42); - } + using var transport = new EveUnityCultMeshLiveProviderTransport( + "test-cache", + "cultnet://127.0.0.1:3075", + "aetheria", + "aetheria", + "aetheria", + "aetheria.pilot"); + }); + } + + [Test] + public async System.Threading.Tasks.Task LiveTransportResolvesViewGenerationWhenNextPublicationAlreadyExists() + { + var view = EntityLeaseDocument(); + var generationN = BodyPublication(view, sequence: view.Sequence); + var generationNPlusOne = BodyPublication(view, sequence: view.Sequence + 1); + var cache = new CultCache(); + await cache.UpsertAsync(generationN, + new CultRecordHandle(generationN.RecordKey)); + await cache.UpsertAsync(generationNPlusOne, + new CultRecordHandle(generationNPlusOne.RecordKey)); + + var handle = (CultMeshBodyPublicationHandle)typeof(EveUnityCultMeshLiveProviderTransport) + .GetMethod("BodyPublicationHandle", BindingFlags.NonPublic | BindingFlags.Static)! + .Invoke(null, new object[] { view })!; + var resolved = cache.Get(handle.RecordKey); + + Assert.That(resolved, Is.Not.Null); + handle.Validate(resolved!); + Assert.That(resolved!.Sequence, Is.EqualTo(view.Sequence)); + Assert.That(handle.RecordKey, Is.Not.EqualTo(generationNPlusOne.RecordKey)); + } + + [Test] + public void LiveTransportReusesStableLayoutForNewBodyPublications() + { + using var transport = new EveUnityCultMeshLiveProviderTransport( + "test-cache", + "cultnet://127.0.0.1:3075", + "aetheria", + "aetheria", + "aetheria", + "aetheria.pilot"); + var view = EntityLeaseDocument(); + var publication = BodyPublication(view, view.Sequence + 1); + var queueView = typeof(EveUnityCultMeshLiveProviderTransport) + .GetMethod("QueueEntityView", BindingFlags.NonPublic | BindingFlags.Instance)!; + var queuePublication = typeof(EveUnityCultMeshLiveProviderTransport) + .GetMethod("QueueBodyPublication", BindingFlags.NonPublic | BindingFlags.Instance)!; + var queue = (System.Collections.Concurrent.ConcurrentQueue) + typeof(EveUnityCultMeshLiveProviderTransport) + .GetField("_liveDocuments", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(transport)!; + + queueView.Invoke(transport, new object[] { view }); + Assert.That(queue, Is.Empty, "a layout without its body publication is not a complete generation"); + queuePublication.Invoke(transport, new object[] { publication }); + + Assert.That(queue.TryDequeue(out var queued), Is.True); + var generation = queued!.GetType().GetProperty("View")!.GetValue(queued) as EveEntitySoaViewDocument; + Assert.That(generation, Is.Not.Null); + Assert.That(generation!.Sequence, Is.EqualTo(publication.Sequence)); + Assert.That(generation.ProducerEpoch, Is.EqualTo(publication.ProducerEpoch)); + Assert.That(generation.Buffers, Is.SameAs(view.Buffers)); + Assert.That(generation.Columns, Is.SameAs(view.Columns)); + Assert.That(generation.Identities, Is.SameAs(view.Identities)); + Assert.That(generation.DirtyRanges.All(range => range.Sequence == publication.Sequence), Is.True); + } + + [Test] + public void LiveTransportPairsBodyPublicationThatArrivesBeforeItsLayout() + { + using var transport = new EveUnityCultMeshLiveProviderTransport( + "test-cache", + "cultnet://127.0.0.1:3075", + "aetheria", + "aetheria", + "aetheria", + "aetheria.pilot"); + var view = EntityLeaseDocument(); + var publication = BodyPublication(view, view.Sequence); + var queueView = typeof(EveUnityCultMeshLiveProviderTransport) + .GetMethod("QueueEntityView", BindingFlags.NonPublic | BindingFlags.Instance)!; + var queuePublication = typeof(EveUnityCultMeshLiveProviderTransport) + .GetMethod("QueueBodyPublication", BindingFlags.NonPublic | BindingFlags.Instance)!; + var queue = (System.Collections.Concurrent.ConcurrentQueue) + typeof(EveUnityCultMeshLiveProviderTransport) + .GetField("_liveDocuments", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(transport)!; + + queuePublication.Invoke(transport, new object[] { publication }); + Assert.That(queue, Is.Empty, "a publication without its layout is not a complete generation"); + queueView.Invoke(transport, new object[] { view }); + + Assert.That(queue.TryDequeue(out var queued), Is.True); + Assert.That(queued!.GetType().GetProperty("View")!.GetValue(queued), Is.SameAs(view)); + Assert.That(queued.GetType().GetProperty("Publication")!.GetValue(queued), Is.SameAs(publication)); + } + + [Test] + public void LiveTransportKeepsOnlyLatestPendingRealtimeGenerationPerBody() + { + using var transport = new EveUnityCultMeshLiveProviderTransport( + "test-cache", + "cultnet://127.0.0.1:3075", + "provider", + "provider", + "provider", + "provider.pilot"); + var enqueue = typeof(EveUnityCultMeshLiveProviderTransport) + .GetMethod("EnqueueRealtimeEntityFrame", BindingFlags.NonPublic | BindingFlags.Instance)!; + var queue = (System.Collections.Concurrent.ConcurrentQueue) + typeof(EveUnityCultMeshLiveProviderTransport) + .GetField("_liveDocuments", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(transport)!; + var latest = (Dictionary) + typeof(EveUnityCultMeshLiveProviderTransport) + .GetField("_latestRealtimeFrames", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(transport)!; + + for (var sequence = 0; sequence < 100; sequence++) + enqueue.Invoke(transport, new object[] { RealtimeFrame("entities", 7, sequence) }); + enqueue.Invoke(transport, new object[] { RealtimeFrame("entities", 7, 50) }); + + Assert.That(queue.Count, Is.EqualTo(1), "superseded frames must not become render-thread debt"); + Assert.That(latest, Has.Count.EqualTo(1)); + Assert.That(latest["entities"].ProducerEpoch, Is.EqualTo(7)); + Assert.That(latest["entities"].Sequence, Is.EqualTo(99)); + } + + [Test] + public void LiveTransportReadsLaterMappedFramesWithoutNewControlDocuments() + { + using var transport = new EveUnityCultMeshLiveProviderTransport( + "test-cache", + "cultnet://127.0.0.1:3075", + "provider", + "provider", + "provider", + "provider.pilot"); + var view = EntityLeaseDocument(); + view.Sequence = 0; + using var publisher = new CultMeshFrameBodyPublisher( + view.Buffers[0].BufferId, + view.BodySchemaId, + view.LayoutVersion, + view.Capacity, + view.ProducerEpoch, + slotByteLength: 4); + publisher.TryPublish(new byte[] { 1, 2, 3, 4 }, DateTimeOffset.UtcNow, out var bootstrap); + var publication = new CultMeshBodyPublicationDocument + { + BodyId = bootstrap.BodyId, + ProducerId = view.ProviderId, + SchemaId = bootstrap.SchemaId, + LayoutVersion = bootstrap.LayoutVersion, + ByteSize = bootstrap.ByteSize, + Capacity = bootstrap.Capacity, + ProducerEpoch = bootstrap.ProducerEpoch, + Sequence = bootstrap.Sequence, + Synchronization = bootstrap.Synchronization, + LivenessExpiresAtUnixMs = bootstrap.LeaseExpiresAtUnixMs, + Representations = new[] { bootstrap } + }; + var queueView = typeof(EveUnityCultMeshLiveProviderTransport) + .GetMethod("QueueEntityView", BindingFlags.NonPublic | BindingFlags.Instance)!; + var ensureCursor = typeof(EveUnityCultMeshLiveProviderTransport) + .GetMethod("EnsureMappedEntityFrameCursor", BindingFlags.NonPublic | BindingFlags.Instance)!; + var pumpCursor = typeof(EveUnityCultMeshLiveProviderTransport) + .GetMethod("PumpMappedEntityFrame", BindingFlags.NonPublic | BindingFlags.Instance)!; + var queue = (System.Collections.Concurrent.ConcurrentQueue) + typeof(EveUnityCultMeshLiveProviderTransport) + .GetField("_liveDocuments", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(transport)!; + queueView.Invoke(transport, new object[] { view }); + queue.TryDequeue(out _); + ensureCursor.Invoke(transport, new object[] { publication }); + + publisher.TryPublish(new byte[] { 9, 8, 7, 6 }, DateTimeOffset.UtcNow, out _); + EveEntitySoaViewDocument? presented = null; + byte presentedByte = 0; + transport.EntityViewAvailable += (generation, lease) => + { + presented = generation; + presentedByte = lease.ReadByte(0); + lease.Dispose(); + }; + + pumpCursor.Invoke(transport, Array.Empty()); + + Assert.That(presented, Is.Not.Null); + Assert.That(presented!.Sequence, Is.EqualTo(1)); + Assert.That(presentedByte, Is.EqualTo(9)); + Assert.That(queue, Is.Empty, "mapped frames must not be represented as queued CultNet documents"); + } + + [Test] + public void LiveTransportDropsExpiredHistoricalGenerationWithoutReinterpretingLatest() + { + var view = EntityLeaseDocument(); + var publication = BodyPublication(view, view.Sequence); + publication.LivenessExpiresAtUnixMs = 999; + var method = typeof(EveUnityCultMeshLiveProviderTransport) + .GetMethod("IsPublicationLive", BindingFlags.NonPublic | BindingFlags.Static)!; + + var live = (bool)method.Invoke(null, new object[] + { + publication, + DateTimeOffset.FromUnixTimeMilliseconds(1000) + })!; + + Assert.That(live, Is.False); + Assert.That(publication.RecordKey, Is.EqualTo( + new CultMeshBodyPublicationHandle(view.Buffers[0].BufferId, view.ProducerEpoch, view.Sequence).RecordKey)); + } + + [Test] + public void LiveTransportHasNoSnapshotWireRegistryForBodyBytes() + { + var transport = typeof(EveUnityCultMeshLiveProviderTransport); + var wireRegistry = transport.GetField( + "WireDocumentTypes", + BindingFlags.NonPublic | BindingFlags.Static); + var fieldTypes = transport + .GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static) + .Select(field => field.FieldType) + .ToArray(); + + Assert.That(wireRegistry, Is.Null); + Assert.That(fieldTypes.Any(type => type == typeof(CultMeshCdnArtifactChunk)), Is.False); + Assert.That(fieldTypes.Any(type => type.Name == "CultMeshNetworkBodyDocument"), Is.False); + } + + [Test] + public void LiveTransportBuildsImmutableNetworkFallbackForMappedAssetContent() + { + var manifest = CultMeshCdn.PackArtifact( + "provider/world/windows", + Enumerable.Range(0, 64).Select(value => (byte)value).ToArray()).Manifest; + var now = DateTimeOffset.UtcNow; + var method = typeof(EveUnityCultMeshLiveProviderTransport) + .GetMethod("NetworkArtifactDescriptor", BindingFlags.NonPublic | BindingFlags.Static)!; + + var descriptor = (CultMeshBodyDescriptor)method.Invoke( + null, + new object[] { manifest, now, TimeSpan.FromMinutes(5) })!; + + Assert.That(descriptor.BodyId, Is.EqualTo(manifest.ArtifactId)); + Assert.That(descriptor.SchemaId, Is.EqualTo("gamecult.mesh.cdn-artifact.v1")); + Assert.That(descriptor.ByteSize, Is.EqualTo(manifest.SizeBytes)); + Assert.That(descriptor.Capacity, Is.EqualTo(manifest.SizeBytes)); + Assert.That(descriptor.AccessMode, Is.EqualTo(CultMeshBodyAccessMode.ReadOnly)); + Assert.That(descriptor.Synchronization, Is.EqualTo(CultMeshBodySynchronization.ImmutableSequence)); + Assert.That(descriptor.TransportKind, Is.EqualTo(CultMeshBodyTransportKind.Network)); + Assert.That(descriptor.SemanticHash, Is.EqualTo(manifest.ContentHash)); + Assert.That(descriptor.CapabilityToken, Is.EqualTo( + CultMeshCdnArtifactManifest.CreateRecordKey(manifest).Value)); + } + + [Test] + public void LiveTransportAuthorizesOnlyExplicitlyAdvertisedBodyProducers() + { + var advertisement = ProviderAdvertisement("aetheria", "aetheria-daemon-17", "aetheria.daemon"); + var method = typeof(EveUnityCultMeshLiveProviderTransport) + .GetMethod("RequireAdvertisedBodyProducerIds", BindingFlags.NonPublic | BindingFlags.Static)!; + + var producerIds = (IReadOnlyList)method.Invoke(null, new object?[] { advertisement })!; + + Assert.That(producerIds, Is.EqualTo(new[] { "aetheria.daemon" })); + Assert.That(producerIds, Does.Not.Contain(advertisement.ProviderId)); + Assert.That(producerIds, Does.Not.Contain(advertisement.ServiceId)); + + var authorizes = typeof(EveUnityCultMeshLiveProviderTransport) + .GetMethod("IsAdvertisedBodyProducer", BindingFlags.NonPublic | BindingFlags.Static)!; + Assert.That(authorizes.Invoke(null, new object[] { producerIds, "aetheria.daemon" }), Is.True); + Assert.That(authorizes.Invoke(null, new object[] { producerIds, "aetheria" }), Is.False); + Assert.That(authorizes.Invoke(null, new object[] { producerIds, "aetheria-daemon-17" }), Is.False); + Assert.That(authorizes.Invoke(null, new object[] { producerIds, "impostor" }), Is.False); + } + + [Test] + public void LiveTransportRejectsAdvertisementWithoutBodyProducerIdentity() + { + var advertisement = ProviderAdvertisement("aetheria", "aetheria-daemon-17"); + var method = typeof(EveUnityCultMeshLiveProviderTransport) + .GetMethod("RequireAdvertisedBodyProducerIds", BindingFlags.NonPublic | BindingFlags.Static)!; + + var error = Assert.Throws(() => + method.Invoke(null, new object?[] { advertisement })); + + Assert.That(error!.InnerException, Is.TypeOf()); + StringAssert.Contains("does not advertise an authorized body producer", error.InnerException!.Message); + } + + [Test] + public void ProviderAdvertisementRoundTripPreservesBodyProducerAuthority() + { + var advertisement = ProviderAdvertisement( + "aetheria", + "aetheria-daemon-17", + "aetheria.daemon", + "aetheria.asset-publisher"); + + var bytes = MessagePack.MessagePackSerializer.Serialize(advertisement); + var decoded = MessagePack.MessagePackSerializer.Deserialize(bytes); + + Assert.That(decoded.ProviderId, Is.EqualTo("aetheria")); + Assert.That(decoded.ServiceId, Is.EqualTo("aetheria-daemon-17")); + Assert.That(decoded.AuthorizedBodyProducerIds, + Is.EqualTo(new[] { "aetheria.daemon", "aetheria.asset-publisher" })); + } + + [Test] + public void EntitySoaViewReadsGenericSemanticColumnsFromInjectedLease() + { + var bytes = new byte[32]; + Buffer.BlockCopy(new[] { 4f, 5f, 6f, 7f, 8f, 9f }, 0, bytes, 0, 24); + Buffer.BlockCopy(new[] { 41, 42 }, 0, bytes, 24, 8); var document = new EveEntitySoaViewDocument { ProviderId = "provider", ViewId = "pilot", - Generation = 7, - Backend = "memory_mapped_file", - Buffers = new[] { new EveEntitySoaBuffer { BufferId = "hot", Backend = "memory_mapped_file", Location = location, ByteLength = 32, Generation = 7 } }, + BodySchemaId = "test.entity.slab.v1", + LayoutVersion = 3, + ProducerEpoch = 5, + Sequence = 7, + Capacity = 2, + Buffers = new[] { new EveEntitySoaBuffer { BufferId = "hot", ByteLength = 32 } }, Columns = new[] { new EveEntitySoaColumn { ColumnId = "position", Semantic = "transform.position", BufferId = "hot", ScalarType = "float3", ElementStride = 12, ElementCount = 2 }, new EveEntitySoaColumn { ColumnId = "entity-index", Semantic = "entity.index", BufferId = "hot", ScalarType = "int32", ByteOffset = 24, ElementStride = 4, ElementCount = 2 } } }; + var lease = new FakeBodyReadLease(document, bytes); - using var view = EveUnityEntitySoaView.Open(document); + using var view = EveUnityEntitySoaView.Open(document, lease); Assert.That(view.TryReadVector3("transform.position", 1, out var position), Is.True); Assert.That(position, Is.EqualTo(new Vector3(7f, 8f, 9f))); Assert.That(view.TryReadInt32("entity.index", 0, out var entityIndex), Is.True); Assert.That(entityIndex, Is.EqualTo(41)); Assert.That(view.Generation, Is.EqualTo(7)); + Assert.That(lease.Disposed, Is.False); + } + + [TestCase("body")] + [TestCase("schema")] + [TestCase("layout")] + [TestCase("epoch")] + [TestCase("sequence")] + [TestCase("capacity")] + public void EntitySoaViewRejectsLeaseThatDisagreesWithLogicalLayout(string mismatch) + { + var document = EntityLeaseDocument(); + var descriptor = FakeBodyReadLease.DescriptorFor(document, 4); + if (mismatch == "body") descriptor.BodyId = "wrong"; + if (mismatch == "schema") descriptor.SchemaId = "wrong"; + if (mismatch == "layout") descriptor.LayoutVersion++; + if (mismatch == "epoch") descriptor.ProducerEpoch++; + if (mismatch == "sequence") descriptor.Sequence++; + if (mismatch == "capacity") descriptor.Capacity++; + + Assert.Throws(() => EveUnityEntitySoaView.Open( + document, new FakeBodyReadLease(descriptor, new byte[4]))); + } + + [Test] + public void EntitySoaPresenterPreservesProviderAuthoredIdentityFacts() + { + var bytes = new byte[16]; + Buffer.BlockCopy(new[] { 12 }, 0, bytes, 0, 4); + Buffer.BlockCopy(new[] { 1f, 2f, 3f }, 0, bytes, 4, 12); + var document = EntityLeaseDocument(); + document.Buffers[0].ByteLength = bytes.Length; + document.Columns = new[] + { + new EveEntitySoaColumn { ColumnId = "entity-index", Semantic = "entity.index", BufferId = "hot", ScalarType = "int32", ElementStride = 4, ElementCount = 1 }, + new EveEntitySoaColumn { ColumnId = "position", Semantic = "transform.position", BufferId = "hot", ScalarType = "float3", ByteOffset = 4, ElementStride = 12, ElementCount = 1 } + }; + var sink = new FakePlayableWorldSceneSink(); + + new EveUnityEntitySoaPresenter(sink).Apply(document, new FakeBodyReadLease(document, bytes)); + + var entity = sink.Upserts.Single().entity; + Assert.That(entity.EntityId, Is.EqualTo("entity:pilot")); + Assert.That(entity.Label, Is.EqualTo("Pilot")); + Assert.That(entity.EntityKind, Is.EqualTo("player")); + Assert.That(entity.Faction, Is.EqualTo("alliance")); + Assert.That(entity.Selectable, Is.True); + Assert.That(entity.Controllable, Is.True); + Assert.That(entity.AssetRef, Is.EqualTo("cultmesh://assets/pilot")); + } + + [Test] + public void EntitySoaPresenterPublishesImmutableGenerationFactsAndRegistryLookups() + { + var root = new GameObject("presented-entities"); + try + { + var bytes = new byte[48]; + Buffer.BlockCopy(new[] { 12 }, 0, bytes, 0, 4); + Buffer.BlockCopy(new[] { 1f, 2f, 3f }, 0, bytes, 4, 12); + Buffer.BlockCopy(new[] { 0.5f }, 0, bytes, 16, 4); + Buffer.BlockCopy(new[] { 4f, 5f, 6f }, 0, bytes, 20, 12); + Buffer.BlockCopy(new[] { 7f }, 0, bytes, 32, 4); + Buffer.BlockCopy(new[] { 1.5f }, 0, bytes, 36, 4); + Buffer.BlockCopy(new[] { 9 }, 0, bytes, 40, 4); + Buffer.BlockCopy(new[] { 2 }, 0, bytes, 44, 4); + var document = EntityLeaseDocument(); + document.Buffers[0].ByteLength = bytes.Length; + document.Columns = new[] + { + SoaColumn("entity.index", "int32", 0, 4), + SoaColumn("transform.position", "float3", 4, 12), + SoaColumn("transform.rotation.radians", "float32", 16, 4), + SoaColumn("transform.velocity", "float3", 20, 12), + SoaColumn("physics.body.radius", "float32", 32, 4), + SoaColumn("render.scale", "float32", 36, 4), + SoaColumn("render.group.id", "uint32", 40, 4), + SoaColumn("render.lod", "int32", 44, 4) + }; + var sink = new EveUnityGameObjectPlayableWorldSceneSink( + root.transform, new FixedGameObjectAssetProvider(null)); + + new EveUnityEntitySoaPresenter(sink).Apply(document, new FakeBodyReadLease(document, bytes)); + + Assert.That(sink.CurrentGeneration, Is.Not.Null); + Assert.That(sink.CurrentGeneration!.ProducerEpoch, Is.EqualTo(document.ProducerEpoch)); + Assert.That(sink.CurrentGeneration.Sequence, Is.EqualTo(document.Sequence)); + Assert.That(sink.TryGetByEntityId("entity:pilot", out var byId), Is.True); + Assert.That(sink.TryGetBySourceIndex(12, out var byIndex), Is.True); + Assert.That(byIndex, Is.SameAs(byId)); + Assert.That(byId.Entity.Position, Is.EqualTo(new Vector3(1f, 2f, 3f))); + Assert.That(byId.Entity.Velocity, Is.EqualTo(new Vector3(4f, 5f, 6f))); + Assert.That(byId.Entity.Radius, Is.EqualTo(7f)); + Assert.That(byId.Entity.Scale, Is.EqualTo(1.5f)); + Assert.That(byId.Entity.RenderGroupId, Is.EqualTo(9)); + Assert.That(byId.Entity.Lod, Is.EqualTo(2)); + Assert.That(byId.Entity.Selectable, Is.True); + Assert.That(byId.Entity.Controllable, Is.True); + Assert.That(byId.Entity.AssetRef, Is.EqualTo("cultmesh://assets/pilot")); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + } + } + + [Test] + public void GameObjectGenerationRegistrySwapsOnlyAfterCompleteApplication() + { + var root = new GameObject("atomic-generation"); + EveUnityGameObjectPlayableWorldSceneSink? sink = null; + long observedSequenceDuringApply = -1; + try + { + var provider = new ObservingGameObjectAssetProvider(() => + observedSequenceDuringApply = sink?.CurrentGeneration?.Sequence ?? -1); + sink = new EveUnityGameObjectPlayableWorldSceneSink(root.transform, provider); + sink.ApplyGeneration(new EveUnityPresentedEntityGeneration( + "entities", 3, 10, + new[] { PresentedEntity(1, "first", "asset:first") })); + Assert.That(sink.TryGetByEntityId("first", out var firstGeneration), Is.True); + var firstTransform = firstGeneration.Transform; + + observedSequenceDuringApply = -1; + sink.ApplyGeneration(new EveUnityPresentedEntityGeneration( + "entities", 3, 11, + new[] + { + PresentedEntity(1, "first", "asset:first"), + PresentedEntity(2, "second", "asset:second") + })); + + Assert.That(observedSequenceDuringApply, Is.EqualTo(10)); + Assert.That(sink.CurrentGeneration!.Sequence, Is.EqualTo(11)); + Assert.That(sink.TryGetByEntityId("first", out var retained), Is.True); + Assert.That(retained.Transform, Is.SameAs(firstTransform)); + Assert.That(sink.TryGetByEntityId("second", out var second), Is.True); + Assert.That(sink.TryGetBySourceIndex(2, out var byIndex), Is.True); + Assert.That(byIndex, Is.SameAs(second)); + Assert.That(sink.ActiveEntityCount, Is.EqualTo(2)); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + } } [Test] @@ -106,25 +573,55 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(projection.Root.Children[0].SceneObjectKind, Is.EqualTo("playable-world-root")); Assert.That(projection.Root.Children[0].Children[0].SceneObjectKind, Is.EqualTo("playable-world-entity")); - Assert.That(projection.Root.Children[0].Children[3].SceneObjectKind, Is.EqualTo("world-field-3d")); + Assert.That(projection.Root.Children[0].Children.Any(child => child.SceneObjectKind == "world-field-3d"), Is.True); Assert.That(projection.PlayableWorld, Is.Not.Null); Assert.That(projection.PlayableWorld!.WorldRootId, Is.EqualTo("aetheria.daemon.game.playable")); Assert.That(projection.PlayableWorld.StatePointerId, Is.EqualTo("cultmesh://aetheria/run/current")); Assert.That(projection.PlayableWorld.EntityViewPointerId, Is.EqualTo("cultmesh://aetheria/world/entities.soa")); Assert.That(projection.PlayableWorld.EntityViewSchema, Is.EqualTo(EveEntitySoaViewDocument.SchemaId)); + Assert.That(projection.PlayableWorld.EntityBodyId, Is.EqualTo("eve:entity-soa:aetheria.daemon:pilot")); Assert.That(projection.PlayableWorld.ZoneRenderPointerId, Is.EqualTo("cultmesh://aetheria/world/zone-render")); Assert.That(projection.PlayableWorld.AssetManifest, Is.EqualTo("cultmesh://aetheria/assets/manifest")); + Assert.That(projection.PlayableWorld.AssetProviderId, Is.EqualTo("aetheria.assets")); + Assert.That(projection.PlayableWorld.AssetVerseId, Is.EqualTo("aetheria.mods")); + Assert.That(projection.PlayableWorld.AssetAuthorityRuntimeId, Is.EqualTo("mod-authority")); + Assert.That(projection.PlayableWorld.AssetRendezvousEndpoints, Is.EqualTo(new[] { "wss://odin-a/cultmesh", "wss://odin-b/cultmesh" })); Assert.That(projection.PlayableWorld.InputProfile, Is.EqualTo("arpg-third-person")); - Assert.That(projection.PlayableWorld.CameraRig, Is.EqualTo("third-person-orbit")); - Assert.That(projection.PlayableWorld.PlayerEntityId, Is.EqualTo("player-vanguard")); + Assert.That(projection.PlayableWorld.CameraRig, Is.EqualTo("planar.top-down-follow.v1")); + Assert.That(projection.PlayableWorld.CameraLookAt, Is.Empty); Assert.That(projection.PlayableWorld.CameraTargetEntityId, Is.EqualTo("player-vanguard")); - Assert.That(projection.PlayableWorld.SubjectVisible, Is.True); - Assert.That(projection.PlayableWorld.MovementEnabled, Is.True); - Assert.That(projection.PlayableWorld.PresentationMode, Is.EqualTo("world")); + Assert.That(projection.PlayableWorld.CameraDistance, Is.EqualTo(150f)); + Assert.That(projection.PlayableWorld.CameraVerticalFieldOfViewDegrees, Is.EqualTo(60f)); + Assert.That(projection.PlayableWorld.CameraTargetScreenX, Is.EqualTo(0.9f)); + Assert.That(projection.PlayableWorld.CameraTargetScreenY, Is.EqualTo(0.55f)); + Assert.That(projection.PlayableWorld.CameraPositionDamping, Is.EqualTo(5f)); + Assert.That(projection.PlayableWorld.CameraNearClipPlane, Is.EqualTo(0.3f)); + Assert.That(projection.PlayableWorld.CameraFarClipPlane, Is.EqualTo(4096f)); + Assert.That(projection.PlayableWorld.AmbientLightR, Is.EqualTo(0.2f)); + Assert.That(projection.PlayableWorld.AmbientLightG, Is.EqualTo(0.2f)); + Assert.That(projection.PlayableWorld.AmbientLightB, Is.EqualTo(0.2f)); + Assert.That(projection.PlayableWorld.AmbientLightIntensity, Is.EqualTo(1.46f)); + Assert.That(projection.PlayableWorld.SkyboxAssetRef, Is.Empty); + Assert.That(projection.PlayableWorld.ReflectionAssetRef, Is.Empty); + Assert.That(projection.PlayableWorld.PostProcessProfileAssetRef, Is.Empty); + Assert.That(projection.PlayableWorld.ReflectionIntensity, Is.EqualTo(1f)); + Assert.That(projection.PlayableWorld.ExcludedRenderChannels, Is.EqualTo(new[] { "map" })); + Assert.That(projection.PlayableWorld.PlayerEntityId, Is.EqualTo("player-vanguard")); Assert.That(projection.PlayableWorld.MovementCommand, Is.EqualTo("aetheria.daemon.move_intent")); Assert.That(projection.PlayableWorld.FocusCommand, Is.EqualTo("aetheria.daemon.focus")); Assert.That(projection.PlayableWorld.TargetCommand, Is.EqualTo("aetheria.daemon.target")); Assert.That(projection.PlayableWorld.EntityCount, Is.EqualTo(3)); + Assert.That(projection.PlayableWorld.FieldVolumes.Count, Is.EqualTo(1)); + Assert.That(projection.PlayableWorld.FieldVolumes[0].DocumentRef, Is.EqualTo("cultmesh://aetheria/world/fog-splats")); + Assert.That(projection.PlayableWorld.FieldVolumes[0].MaterialAssetRef, Is.EqualTo("shader.environment.gravity-fog")); + Assert.That(projection.PlayableWorld.FieldVolumes[0].RenderChannel, Is.EqualTo("world.transparent")); + Assert.That(projection.PlayableWorld.FieldParticles.Count, Is.EqualTo(1)); + Assert.That(projection.PlayableWorld.FieldParticles[0].DocumentRef, + Is.EqualTo("cultmesh://aetheria/world/fog-splats")); + Assert.That(projection.PlayableWorld.FieldParticles[0].ComputeProgramAssetRef, + Is.EqualTo("compute.environment.stardust")); + Assert.That(projection.PlayableWorld.FieldParticles[0].MaterialAssetRef, + Is.EqualTo("material.environment.stardust")); var player = FindEntity(projection.PlayableWorld, "player-vanguard"); Assert.That(player.EntityKind, Is.EqualTo("player")); @@ -220,6 +717,18 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(moveVectorIntent.Payload.GetString("directionX"), Is.EqualTo("0.25")); Assert.That(moveVectorIntent.Payload.GetString("directionY"), Is.EqualTo("0.75")); Assert.That(moveVectorIntent.Payload.GetString("scalarValue"), Is.EqualTo("0.8")); + + var lookIntent = session.CreateLookDirectionIntent( + "player-vanguard", + 0.6f, + 0f, + 0.8f, + DateTimeOffset.Parse("2026-07-09T00:00:02Z")); + Assert.That(lookIntent.Payload.GetString("commandId"), Is.EqualTo("aetheria.daemon.look_intent")); + Assert.That(lookIntent.Payload.GetString("entityId"), Is.EqualTo("player-vanguard")); + Assert.That(lookIntent.Payload.GetString("directionX"), Is.EqualTo("0.6")); + Assert.That(lookIntent.Payload.GetString("directionY"), Is.EqualTo("0")); + Assert.That(lookIntent.Payload.GetString("directionZ"), Is.EqualTo("0.8")); } [Test] @@ -342,25 +851,11 @@ namespace GameCult.Eve.UnityScene.Tests [Test] public void PlayableWorldRuntimeComposesProviderDocumentsAssetsReceiptsAndSceneSink() { - var surfaceDocuments = new FakeProviderSurfaceDocumentSource( - new EveUnitySceneProviderSurfaceDocument( - PlayableArpgDocument(), - Advertisement("aetheria.daemon.game"), - "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", - 1), - currentInputCapability: new EveInputCapabilityDocument - { - ProviderId = "aetheria", - Actions = new[] - { - new EveInputActionDocument - { - ActionId = "weapon-group.2.fire", - Operation = "aetheria.daemon.commands.FireWeaponGroup", - Payload = new Dictionary { ["weaponGroup"] = "2" } - } - } - }); + var surfaceDocuments = new FakeProviderSurfaceDocumentSource(new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1)); var assetDocuments = new FakeAssetManifestDocumentSource(new EveUnityPlayableWorldAssetManifestDocument( "cultmesh://aetheria/assets/manifest", new[] @@ -410,14 +905,6 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(moveIntent.CommandBoundary, Is.EqualTo("aetheria.daemon.commands")); Assert.That(moveIntent.ReceiptSchema, Is.EqualTo("aetheria.eve_command_acceptance_status.v1")); - var actionIntent = runtime.SubmitActionIntent("player-vanguard", "weapon-group.2.fire"); - - Assert.That(commandSink.Submitted.Count, Is.EqualTo(2)); - Assert.That(commandSink.Submitted[1], Is.SameAs(actionIntent)); - Assert.That(actionIntent.Payload.GetString("commandId"), Is.EqualTo("aetheria.daemon.commands.FireWeaponGroup")); - Assert.That(actionIntent.Payload.GetString("weaponGroup"), Is.EqualTo("2")); - Assert.That(actionIntent.Payload.GetString("actionId"), Is.EqualTo("weapon-group.2.fire")); - surfaceDocuments.Publish(new EveUnitySceneProviderSurfaceDocument( PlayableArpgDocument(includeRaider: false, playerPosition: "14,0,9"), Advertisement("aetheria.daemon.game"), @@ -438,61 +925,19 @@ namespace GameCult.Eve.UnityScene.Tests "aetheria.daemon.move_intent", "accepted", "Aetheria", - "provider-owned-daemon")); + "provider-owned-daemon", + navigation: new EveUnitySceneNavigationTarget( + "gamecult.aetheria", + "aetheria", + "aetheria.pilot", + "interactive-world"))); Assert.That(runtime.LastReceipt, Is.Not.Null); Assert.That(runtime.LastReceipt!.IsProviderOwned, Is.True); Assert.That(runtime.LastReceipt.ShouldRefreshProviderSurface, Is.True); - } - - [Test] - public void PlayableWorldRuntimeReportsAuthoritativeSoaEntityCountOnConnect() - { - var location = $"eve-unity-runtime-soa-{Guid.NewGuid():N}"; - using var mapped = MemoryMappedFile.CreateNew(location, 16); - using (var writer = mapped.CreateViewAccessor()) - { - writer.Write(0, 4f); - writer.Write(4, 5f); - writer.Write(8, 6f); - writer.Write(12, 41); - } - var entityView = new EveEntitySoaViewDocument - { - ProviderId = "aetheria", - ViewId = "pilot", - Generation = 1, - Backend = "memory_mapped_file", - Buffers = new[] { new EveEntitySoaBuffer { BufferId = "hot", Backend = "memory_mapped_file", Location = location, ByteLength = 16, Generation = 1 } }, - Columns = new[] - { - new EveEntitySoaColumn { ColumnId = "position", Semantic = "transform.position", BufferId = "hot", ScalarType = "float3", ElementStride = 12, ElementCount = 1 }, - new EveEntitySoaColumn { ColumnId = "entity-index", Semantic = "entity.index", BufferId = "hot", ScalarType = "int32", ByteOffset = 12, ElementStride = 4, ElementCount = 1 } - }, - Identities = new[] - { - new EveEntitySoaIdentity { EntityIndex = 41, EntityId = "world:entity:41", Kind = "ship", Label = "Test ship", Controllable = true, AssetRef = "prefab.entity.player" } - } - }; - var surfaceDocuments = new FakeProviderSurfaceDocumentSource( - new EveUnitySceneProviderSurfaceDocument( - PlayableArpgDocument(), - Advertisement("aetheria.daemon.game"), - "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", - 1), - entityView); - var sceneSink = new FakePlayableWorldSceneSink(); - - using var runtime = new EveUnityPlayableWorldRuntime( - surfaceDocuments, - new FakeCommandSink("cultmesh-command-sink"), - sceneSink); - - var presentation = runtime.Connect(); - - Assert.That(presentation.ActiveEntities, Is.EqualTo(1)); - Assert.That(runtime.LastPresentation!.ActiveEntities, Is.EqualTo(1)); - Assert.That(sceneSink.Upserts.Exists(entry => entry.entity.EntityId == "world:entity:41"), Is.True); + Assert.That(runtime.LastReceipt.Navigation, Is.Not.Null); + Assert.That(runtime.LastReceipt.Navigation!.VerseId, Is.EqualTo("gamecult.aetheria")); + Assert.That(runtime.LastReceipt.Navigation.SurfaceId, Is.EqualTo("aetheria.pilot")); } [Test] @@ -570,6 +1015,37 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(transport.RefreshCount, Is.EqualTo(0)); } + [Test] + public void LiveProviderBridgeRollsBackSubscriptionsWhenTransportConnectFails() + { + var transport = new FakeLiveProviderTransport( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + Array.Empty(), + "aetheria")) + { + FailNextConnect = true + }; + using var bridge = new EveUnitySceneLiveProviderBridge(transport); + + Assert.Throws(() => bridge.Connect()); + Assert.That(transport.SurfaceSubscriberCount, Is.Zero); + Assert.That(transport.AssetManifestSubscriberCount, Is.Zero); + Assert.That(transport.ReceiptSubscriberCount, Is.Zero); + + bridge.Connect(); + + Assert.That(transport.ConnectCount, Is.EqualTo(2)); + Assert.That(transport.SurfaceSubscriberCount, Is.EqualTo(1)); + Assert.That(transport.AssetManifestSubscriberCount, Is.EqualTo(1)); + Assert.That(transport.ReceiptSubscriberCount, Is.EqualTo(1)); + } + [Test] public void PlayableWorldClientHostMountsInterfaceProviderWithoutProviderTypes() { @@ -655,6 +1131,9 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(bootstrap.SceneRoot!.childCount, Is.EqualTo(3)); Assert.That(hostObject.GetComponent(), Is.Not.Null); Assert.That(hostObject.GetComponent(), Is.Not.Null); + Assert.That(hostObject.GetComponent(), Is.Not.Null); + Assert.That(hostObject.GetComponent().Root, Is.Not.Null); + Assert.That(hostObject.GetComponent().panelSettings.themeStyleSheet, Is.Not.Null); Assert.That(bootstrap.CameraTransform, Is.Not.Null); Assert.That(provider.RefreshCount, Is.EqualTo(1)); @@ -676,6 +1155,204 @@ namespace GameCult.Eve.UnityScene.Tests } } + [UnityTest] + public IEnumerator PlayableWorldClientBootstrapFollowsProviderOwnedReceiptNavigation() + { + var hostObject = new GameObject("generic-eve-navigation-client"); + try + { + var provider = hostObject.AddComponent(); + provider.Set( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + Array.Empty(), + "aetheria")); + var bootstrap = hostObject.AddComponent(); + bootstrap.ConfigureProvider(provider); + bootstrap.Mount(); + var originalHost = bootstrap.Host; + var originalRoot = bootstrap.SceneRoot!; + provider.PreviousPresentationActive = () => originalRoot.gameObject.activeSelf; + + provider.PublishReceipt(new EveUnitySceneCommandReceipt( + "receipt:launch", + "aetheria.hangar.launch", + "launch", + "accepted", + "Aetheria", + "commander-daemon", + navigation: new EveUnitySceneNavigationTarget( + "gamecult.aetheria", + "aetheria.daemon", + "aetheria.pilot", + "interactive-world", + new[] { "cultnet+tcp://odin.example:3076" }))); + + yield return null; + yield return null; + + Assert.That(provider.LastNavigation, Is.Not.Null); + Assert.That(provider.LastNavigation!.VerseId, Is.EqualTo("gamecult.aetheria")); + Assert.That(provider.LastNavigation.RendezvousEndpoints, Is.EqualTo(new[] { "cultnet+tcp://odin.example:3076" })); + Assert.That(bootstrap.Host, Is.Not.SameAs(originalHost)); + Assert.That(bootstrap.Host!.ConnectionEpoch, Is.EqualTo(1)); + Assert.That(provider.CommitNavigationCount, Is.EqualTo(1)); + Assert.That(provider.FinalizeNavigationCount, Is.EqualTo(1)); + Assert.That(provider.RollbackNavigationCount, Is.Zero); + Assert.That(provider.PreviousPresentationActiveDuringCommit, Is.False); + + provider.PublishReceipt(new EveUnitySceneCommandReceipt( + "receipt:launch-again", + "aetheria.hangar.launch", + "launch", + "accepted", + "Aetheria", + "commander-daemon", + navigation: new EveUnitySceneNavigationTarget( + "gamecult.aetheria", + "aetheria.daemon", + "aetheria.pilot", + "interactive-world", + new[] { "cultnet+tcp://odin.example:3076" }))); + + yield return null; + yield return null; + + Assert.That(provider.CommitNavigationCount, Is.EqualTo(2)); + Assert.That(provider.FinalizeNavigationCount, Is.EqualTo(2)); + Assert.That(provider.ActiveConnectionLeases, Is.EqualTo(1), + "disposing superseded presentations must leave the committed Verse connected"); + Assert.That(hostObject.transform.Cast() + .Count(child => child.name == "Eve Unity Candidate Presentation"), Is.EqualTo(1)); + } + finally + { + UnityEngine.Object.DestroyImmediate(hostObject); + } + } + + [UnityTest] + public IEnumerator PlayableWorldClientBootstrapKeepsMountedPresentationAfterNavigationMountFailure() + { + var hostObject = new GameObject("generic-eve-navigation-rollback-client"); + try + { + var provider = hostObject.AddComponent(); + provider.Set( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + Array.Empty(), + "aetheria")); + var bootstrap = hostObject.AddComponent(); + bootstrap.ConfigureProvider(provider); + bootstrap.Mount(); + var originalHost = bootstrap.Host; + var originalRuntime = originalHost!.Runtime; + var originalRoot = bootstrap.SceneRoot; + var originalPresentation = bootstrap.LastPresentation; + provider.FailNextNavigationMount = true; + LogAssert.Expect(LogType.Error, new Regex("Eve provider navigation failed; the current surface remains mounted")); + + provider.PublishReceipt(new EveUnitySceneCommandReceipt( + "receipt:launch-failure", + "aetheria.hangar.launch", + "launch", + "accepted", + "Aetheria", + "commander-daemon", + navigation: new EveUnitySceneNavigationTarget( + "gamecult.aetheria", + "broken-provider", + "aetheria.pilot", + "interactive-world", + new[] { "cultnet+tcp://odin.example:3999" }))); + + yield return null; + yield return null; + + Assert.That(provider.CommitNavigationCount, Is.Zero); + Assert.That(provider.RollbackNavigationCount, Is.EqualTo(1)); + Assert.That(bootstrap.LastNavigationFailure, Is.Not.Null); + Assert.That(bootstrap.Host, Is.SameAs(originalHost)); + Assert.That(bootstrap.Host!.Runtime, Is.SameAs(originalRuntime)); + Assert.That(bootstrap.SceneRoot, Is.SameAs(originalRoot)); + Assert.That(bootstrap.LastPresentation, Is.SameAs(originalPresentation)); + Assert.That(bootstrap.Host!.Runtime, Is.Not.Null); + Assert.That(bootstrap.Host.ActiveWorld, Is.Not.Null); + Assert.That(bootstrap.Host.ActiveWorld!.PlayerEntityId, Is.EqualTo("player-vanguard")); + Assert.That(bootstrap.Host.ConnectionEpoch, Is.EqualTo(1)); + } + finally + { + UnityEngine.Object.DestroyImmediate(hostObject); + } + } + + [UnityTest] + public IEnumerator PlayableWorldClientBootstrapRestoresProviderRouteBeforeReactivatingPreviousView() + { + var hostObject = new GameObject("generic-eve-navigation-order-client"); + try + { + var provider = hostObject.AddComponent(); + provider.Set( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + Array.Empty(), + "aetheria")); + var bootstrap = hostObject.AddComponent(); + bootstrap.ConfigureProvider(provider); + bootstrap.Mount(); + var originalRoot = bootstrap.SceneRoot!; + provider.PreviousPresentationActive = () => originalRoot.gameObject.activeSelf; + provider.FailNextNavigationFinalize = true; + LogAssert.Expect(LogType.Error, new Regex("Eve provider navigation failed; the current surface remains mounted")); + + provider.PublishReceipt(new EveUnitySceneCommandReceipt( + "receipt:launch-finalize-failure", + "aetheria.hangar.launch", + "launch", + "accepted", + "Aetheria", + "commander-daemon", + navigation: new EveUnitySceneNavigationTarget( + "gamecult.aetheria", + "candidate-provider", + "aetheria.pilot", + "interactive-world", + new[] { "cultnet+tcp://odin.example:3999" }))); + + yield return null; + yield return null; + + Assert.That(provider.CommitNavigationCount, Is.EqualTo(1)); + Assert.That(provider.RollbackNavigationCount, Is.EqualTo(1)); + Assert.That(provider.ActiveRoute, Is.EqualTo("original")); + Assert.That(provider.PreviousPresentationActiveDuringRollback, Is.False); + Assert.That(originalRoot.gameObject.activeSelf, Is.True); + Assert.That(bootstrap.Host!.Runtime, Is.Not.Null); + } + finally + { + UnityEngine.Object.DestroyImmediate(hostObject); + } + } + [Test] public void PlayableWorldClientHostSubmitsProviderOwnedMoveVector() { @@ -758,6 +1435,31 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(request.Payload.GetDouble("directionY", 0), Is.EqualTo(0f).Within(0.0001f)); Assert.That(request.Payload.GetDouble("scalarValue", 0), Is.EqualTo(1f).Within(0.0001f)); + var controlledYaw = rootObject.GetComponentsInChildren(true) + .Single(marker => marker.EntityId == "player-vanguard") + .transform.eulerAngles.y * Mathf.Deg2Rad; + driver.QueueLookInput(250f); + driver.QueueLookInput(250f); + driver.QueueLookInput(500f); + var look = driver.SubmitPendingLookInput(); + Assert.That(look, Is.Not.Null); + Assert.That(provider.Submitted.Count, Is.EqualTo(2)); + Assert.That(look!.Payload.GetString("commandId"), Is.EqualTo("aetheria.daemon.look_intent")); + Assert.That(look.Payload.GetDouble("directionX", 0), Is.EqualTo(Mathf.Sin(controlledYaw - 1f)).Within(0.0001f)); + Assert.That(look.Payload.GetDouble("directionZ", 0), Is.EqualTo(Mathf.Cos(controlledYaw - 1f)).Within(0.0001f)); + + host.Connect(); + var rebasedYaw = rootObject.GetComponentsInChildren(true) + .Single(marker => marker.EntityId == "player-vanguard") + .transform.eulerAngles.y * Mathf.Deg2Rad; + var rebasedLook = driver.SubmitLookInput(1000f); + Assert.That(rebasedLook, Is.Not.Null); + Assert.That(provider.Submitted.Count, Is.EqualTo(3)); + Assert.That(rebasedLook!.Payload.GetDouble("directionX", 0), + Is.EqualTo(Mathf.Sin(rebasedYaw - 1f)).Within(0.0001f)); + Assert.That(rebasedLook.Payload.GetDouble("directionZ", 0), + Is.EqualTo(Mathf.Cos(rebasedYaw - 1f)).Within(0.0001f)); + var movement = EveUnityPlayableWorldMoveVector.FromCameraRelativeInput(1f, 1f, null); Assert.That(movement.HasInput, Is.True); Assert.That(movement.ScalarValue, Is.EqualTo(1f).Within(0.0001f)); @@ -771,7 +1473,49 @@ namespace GameCult.Eve.UnityScene.Tests } [Test] - public void PlayableWorldCameraRigFollowsAdvertisedPlayerEntityWithoutProviderTypes() + public void PlayableWorldInputDriverUsesAdvertisedPrimaryBinding() + { + var capability = new EveInputCapabilityDocument + { + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "weapon-group.0.fire", + Availability = "available" + } + }, + DefaultProfiles = new[] + { + new EveInputProfileDocument + { + ProfileId = "keyboard-mouse", + DeviceClass = "keyboard-mouse", + Bindings = new[] + { + new EveInputBindingDocument + { + ActionId = "weapon-group.0.fire", + Gesture = new EveInputGestureDocument + { + Controls = new[] { "mouse.primary" } + }, + ActionBar = true + } + } + } + } + }; + + Assert.That( + EveUnityPlayableWorldInputDriver.ResolvePrimaryActionId(capability), + Is.EqualTo("weapon-group.0.fire")); + capability.Actions[0].Availability = "unavailable"; + Assert.That(EveUnityPlayableWorldInputDriver.ResolvePrimaryActionId(capability), Is.Empty); + } + + [Test] + public void PlayableWorldInputDriverPerformsAdvertisedViewDirectionBindingOnPressEdge() { var rootObject = new GameObject("generic-eve-world-root"); var hostObject = new GameObject("generic-eve-client"); @@ -780,35 +1524,89 @@ namespace GameCult.Eve.UnityScene.Tests try { + cameraObject.transform.rotation = Quaternion.Euler(30f, 90f, 0f); + var capability = new EveInputCapabilityDocument + { + ProviderId = "generic-provider", + CapabilityId = "pilot.input", + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "pilot.target-reticle", + Operation = "generic.commands.TargetReticle", + Availability = "available", + InputValue = new EveInputValueDocument + { + Model = EveUnityAdvertisedInputAction.ViewDirectionValueModel, + PayloadKeys = new[] { "directionX", "directionY", "directionZ" } + } + } + }, + DefaultProfiles = new[] + { + new EveInputProfileDocument + { + ProfileId = "keyboard-mouse", + DeviceClass = "keyboard-mouse", + Bindings = new[] + { + new EveInputBindingDocument + { + BindingId = "target.reticle.r", + ActionId = "pilot.target-reticle", + Gesture = new EveInputGestureDocument + { + Kind = "direct", + Controls = new[] { "keyboard.r" } + } + } + } + } + } + }; var provider = hostObject.AddComponent(); provider.Set( new EveUnitySceneProviderSurfaceDocument( PlayableArpgDocument(), Advertisement("aetheria.daemon.game"), - "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + "cultmesh://generic/eve/surfaces/game", 1), new EveUnityPlayableWorldAssetManifestDocument( - "cultmesh://aetheria/assets/manifest", + "cultmesh://generic/assets/manifest", Array.Empty(), - "aetheria")); - + "generic-provider"), + capability); var host = hostObject.AddComponent(); host.Configure(rootObject.transform, provider, provider, provider, provider); host.Connect(); + var pressed = false; + var driver = hostObject.AddComponent(); + driver.Host = host; + driver.CameraTransform = cameraObject.transform; + driver.DigitalControlReader = control => control == "keyboard.r" ? pressed : (bool?)null; - var rig = hostObject.AddComponent(); - rig.Host = host; - rig.CameraTransform = cameraObject.transform; + driver.SubmitChangedAdvertisedActions(); + pressed = true; + driver.SubmitChangedAdvertisedActions(); + driver.SubmitChangedAdvertisedActions(); - var player = rootObject.GetComponentInChildren(); - Assert.That(player, Is.Not.Null); - var largeVisual = GameObject.CreatePrimitive(PrimitiveType.Cube); - largeVisual.transform.SetParent(player.transform, false); - largeVisual.transform.localScale = Vector3.one * 100f; + Assert.That(provider.Submitted.Count, Is.EqualTo(1)); + var request = provider.Submitted.Single(); + Assert.That(request.Payload.GetString("commandId"), Is.EqualTo("generic.commands.TargetReticle")); + Assert.That(request.Payload.GetString("entityId"), Is.EqualTo("player-vanguard")); + Assert.That(request.Payload.GetString("actionId"), Is.EqualTo("pilot.target-reticle")); + var expected = cameraObject.transform.forward.normalized; + Assert.That(request.Payload.GetDouble("directionX", 0), Is.EqualTo(expected.x).Within(0.000001)); + Assert.That(request.Payload.GetDouble("directionY", 0), Is.EqualTo(expected.y).Within(0.000001)); + Assert.That(request.Payload.GetDouble("directionZ", 0), Is.EqualTo(expected.z).Within(0.000001)); - Assert.That(rig.ApplyRig(0f), Is.True); - Assert.That(cameraObject.transform.position.y, Is.GreaterThan(0f)); - Assert.That(Vector3.Distance(cameraObject.transform.position, largeVisual.transform.position), Is.GreaterThan(100f)); + pressed = false; + driver.SubmitChangedAdvertisedActions(); + pressed = true; + driver.SubmitChangedAdvertisedActions(); + Assert.That(provider.Submitted.Count, Is.EqualTo(2), + "a released and re-pressed generic binding must perform exactly once per press edge"); } finally { @@ -819,7 +1617,435 @@ namespace GameCult.Eve.UnityScene.Tests } [Test] - public void ProviderPrefabKeepsAuthoredScaleWhileFallbackUsesSemanticRadius() + public void GenericActionBarSelectsSuggestionsAndSubmitsScalarThroughAdvertisedOperation() + { + var rootObject = new GameObject("generic-eve-world-root"); + var hostObject = new GameObject("generic-eve-client"); + hostObject.SetActive(false); + + try + { + var capability = new EveInputCapabilityDocument + { + ProviderId = "generic-provider", + CapabilityId = "pilot.input", + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "equipment.0.temperature", + Label = "Target Temperature", + Operation = "generic.commands.SetTemperature", + Availability = "available", + ActionBar = true, + IconRef = "item.thermostat.icon", + Payload = new Dictionary(StringComparer.Ordinal) + { + ["equipmentIndex"] = "0", + ["behaviorIndex"] = "3" + }, + InputValue = new EveInputValueDocument + { + Model = EveUnityAdvertisedInputAction.ScalarValueModel, + PayloadKey = "scalarValue", + CurrentValue = 300, + Unit = "kelvin" + } + }, + new EveInputActionDocument + { + ActionId = "pilot.interact", + Label = "Interact", + Operation = "generic.commands.Interact", + Availability = "available" + }, + new EveInputActionDocument + { + ActionId = "pilot.hidden", + Label = "Hidden", + Operation = "generic.commands.Hidden", + Availability = "available" + } + }, + DefaultProfiles = new[] + { + new EveInputProfileDocument + { + ProfileId = "keyboard-mouse", + DeviceClass = "keyboard-mouse", + Bindings = new[] + { + new EveInputBindingDocument + { + BindingId = "interact.f", + ActionId = "pilot.interact", + ActionBar = true, + Gesture = new EveInputGestureDocument + { + Kind = "direct", + Controls = new[] { "keyboard.f" } + } + } + } + } + } + }; + Assert.That(EveUnityInputActionBar.SelectActions(capability) + .Select(action => action.ActionId), + Is.EqualTo(new[] { "pilot.interact", "equipment.0.temperature" })); + + var provider = hostObject.AddComponent(); + provider.Set( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://generic/eve/surfaces/game", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://generic/assets/manifest", + Array.Empty(), + "generic-provider"), + capability); + var host = hostObject.AddComponent(); + host.Configure(rootObject.transform, provider, provider, provider, provider); + host.Connect(); + var bar = hostObject.GetComponent(); + Assert.That(bar, Is.Not.Null); + bar!.RefreshNow(); + Assert.That(bar.PresentedActionIds, + Is.EquivalentTo(new[] { "pilot.interact", "equipment.0.temperature" })); + + bar.SubmitScalar("equipment.0.temperature", "425.5"); + + var request = provider.Submitted.Single(); + Assert.That(request.Payload.GetString("commandId"), Is.EqualTo("generic.commands.SetTemperature")); + Assert.That(request.Payload.GetString("entityId"), Is.EqualTo("player-vanguard")); + Assert.That(request.Payload.GetString("actionId"), Is.EqualTo("equipment.0.temperature")); + Assert.That(request.Payload.GetString("equipmentIndex"), Is.EqualTo("0")); + Assert.That(request.Payload.GetDouble("scalarValue", 0), Is.EqualTo(425.5).Within(0.000001)); + } + finally + { + UnityEngine.Object.DestroyImmediate(hostObject); + UnityEngine.Object.DestroyImmediate(rootObject); + } + } + + [Test] + public void PlayableWorldCameraRigFollowsAdvertisedPlayerEntityWithoutProviderTypes() + { + var rootObject = new GameObject("generic-eve-world-root"); + var hostObject = new GameObject("generic-eve-client"); + var cameraObject = new GameObject("generic-eve-camera"); + var competingCameraObject = new GameObject("competing-eve-camera"); + var secondaryRootObject = new GameObject("secondary-eve-world-root"); + var secondaryHostObject = new GameObject("secondary-eve-client"); + var secondaryCameraObject = new GameObject("secondary-eve-camera"); + hostObject.SetActive(false); + secondaryHostObject.SetActive(false); + var ambientMode = RenderSettings.ambientMode; + var ambientLight = RenderSettings.ambientLight; + var ambientIntensity = RenderSettings.ambientIntensity; + var previousSkybox = RenderSettings.skybox; + var previousReflectionMode = RenderSettings.defaultReflectionMode; + var previousCustomReflection = RenderSettings.customReflectionTexture; + var previousReflectionIntensity = RenderSettings.reflectionIntensity; + var previousDefaultPipeline = GraphicsSettings.defaultRenderPipeline; + var previousQualityPipeline = QualitySettings.renderPipeline; + var gradingRendererData = ScriptableObject.CreateInstance(); + var gradingPipelineAsset = UniversalRenderPipelineAsset.Create(gradingRendererData); + gradingPipelineAsset.colorGradingMode = ColorGradingMode.LowDynamicRange; + GraphicsSettings.defaultRenderPipeline = gradingPipelineAsset; + QualitySettings.renderPipeline = gradingPipelineAsset; + var previousGradingMode = gradingPipelineAsset.colorGradingMode; + Material? skyboxMaterial = null; + Cubemap? reflectionCubemap = null; + VolumeProfile? postProcessProfile = null; + + try + { + var provider = hostObject.AddComponent(); + var nativeAssets = hostObject.AddComponent(); + var skyboxShader = Shader.Find("Skybox/Procedural"); + Assert.That(skyboxShader, Is.Not.Null); + skyboxMaterial = new Material(skyboxShader!); + reflectionCubemap = new Cubemap(4, TextureFormat.RGBA32, false); + postProcessProfile = ScriptableObject.CreateInstance(); + nativeAssets.Set("material.environment.skybox", skyboxMaterial); + nativeAssets.Set("texture.environment.reflection", reflectionCubemap); + nativeAssets.Set("profile.environment.flight", postProcessProfile); + provider.Set( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument( + skyboxAssetRef: "material.environment.skybox", + reflectionAssetRef: "texture.environment.reflection", + postProcessProfileAssetRef: "profile.environment.flight", + cameraReconstruction: "temporal-reprojection.v1", + temporalQuality: "high", + temporalHistoryBlend: "0.99", + temporalJitterScale: "0.1", + temporalSharpening: "0", + exposureMode: "histogram.v1", + exposureLowPercent: "47.37294", + exposureHighPercent: "99", + exposureMinimumEv: "-3", + exposureMaximumEv: "0.3", + exposureKeyValue: "0.5", + exposureAdaptation: "progressive", + exposureSpeedUp: "2", + exposureSpeedDown: "1", + colorGradingSpace: "hdr-before-tonemap.v1"), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + Array.Empty(), + "aetheria")); + + var host = hostObject.AddComponent(); + host.Configure(rootObject.transform, provider, provider, provider, provider, nativeAssets); + host.Connect(); + hostObject.SetActive(true); + var exposedWorld = host.ActiveProjection?.PlayableWorld; + Assert.That(exposedWorld, Is.Not.Null); + Assert.That(exposedWorld!.ExposureMode, Is.EqualTo("histogram.v1")); + Assert.That(exposedWorld.ColorGradingSpace, Is.EqualTo("hdr-before-tonemap.v1")); + Assert.That(exposedWorld.ExposureLowPercent, Is.EqualTo(47.37294f).Within(0.00001f)); + Assert.That(exposedWorld.ExposureHighPercent, Is.EqualTo(99f)); + Assert.That(exposedWorld.ExposureMinimumEv, Is.EqualTo(-3f)); + Assert.That(exposedWorld.ExposureMaximumEv, Is.EqualTo(0.3f).Within(0.00001f)); + Assert.That(exposedWorld.ExposureKeyValue, Is.EqualTo(0.5f)); + Assert.That(exposedWorld.ExposureAdaptation, Is.EqualTo("progressive")); + Assert.That(exposedWorld.ExposureSpeedUp, Is.EqualTo(2f)); + Assert.That(exposedWorld.ExposureSpeedDown, Is.EqualTo(1f)); + + var rig = hostObject.AddComponent(); + rig.Host = host; + rig.CameraTransform = cameraObject.transform; + rig.RenderPolicySource = new FixedRenderChannelPolicy("map", 14); + var camera = cameraObject.AddComponent(); + camera.clearFlags = CameraClearFlags.SolidColor; + camera.aspect = 16f / 9f; + camera.orthographic = true; + camera.lensShift = new Vector2(0.25f, -0.2f); + + var player = rootObject.GetComponentInChildren(); + Assert.That(player, Is.Not.Null); + var largeVisual = GameObject.CreatePrimitive(PrimitiveType.Cube); + largeVisual.transform.SetParent(player.transform, false); + largeVisual.transform.localScale = Vector3.one * 100f; + largeVisual.layer = 14; + + Assert.That(rig.ApplyRig(0f), Is.True); + Assert.That(host.ActiveCameraTransform, Is.SameAs(cameraObject.transform)); + Assert.That(camera.cullingMask & (1 << 14), Is.Zero); + Assert.That(camera.fieldOfView, Is.EqualTo(60f).Within(0.001f)); + Assert.That(camera.orthographic, Is.False); + Assert.That(camera.lensShift, Is.EqualTo(Vector2.zero)); + Assert.That(camera.nearClipPlane, Is.EqualTo(0.3f).Within(0.001f)); + Assert.That(camera.farClipPlane, Is.EqualTo(4096f).Within(0.001f)); + Assert.That(cameraObject.transform.position.y - player.transform.position.y, Is.EqualTo(150f).Within(0.001f)); + var viewport = camera.WorldToViewportPoint(player.transform.position); + Assert.That(viewport.x, Is.EqualTo(0.9f).Within(0.001f)); + Assert.That(viewport.y, Is.EqualTo(0.55f).Within(0.001f)); + Assert.That(RenderSettings.ambientMode, Is.EqualTo(UnityEngine.Rendering.AmbientMode.Skybox)); + Assert.That(RenderSettings.ambientIntensity, Is.EqualTo(1.46f).Within(0.001f)); + Assert.That(RenderSettings.skybox, Is.SameAs(skyboxMaterial)); + Assert.That(RenderSettings.defaultReflectionMode, Is.EqualTo(DefaultReflectionMode.Custom)); + Assert.That(RenderSettings.customReflectionTexture, Is.SameAs(reflectionCubemap)); + Assert.That(RenderSettings.reflectionIntensity, Is.EqualTo(1f).Within(0.001f)); + Assert.That(camera.clearFlags, Is.EqualTo(CameraClearFlags.Skybox)); + Assert.That(camera.GetComponent(), Is.Not.Null); + var cameraData = camera.GetComponent(); + Assert.That(cameraData.renderPostProcessing, Is.True); + Assert.That(cameraData.antialiasing, Is.EqualTo(AntialiasingMode.TemporalAntiAliasing)); + Assert.That(cameraData.taaSettings.quality, Is.EqualTo(TemporalAAQuality.High)); + Assert.That(cameraData.taaSettings.baseBlendFactor, Is.EqualTo(0.99f).Within(0.0001f)); + Assert.That(cameraData.taaSettings.jitterScale, Is.EqualTo(0.1f).Within(0.0001f)); + Assert.That(cameraData.taaSettings.contrastAdaptiveSharpening, Is.Zero); + var adaptiveExposure = camera.GetComponent(); + Assert.That(adaptiveExposure, Is.Not.Null); + Assert.That(adaptiveExposure.IsConfigured, Is.True); + Assert.That(gradingPipelineAsset.colorGradingMode, Is.EqualTo(ColorGradingMode.HighDynamicRange)); + var postProcessTransform = hostObject.transform.Find("Eve World Post Process"); + Assert.That(postProcessTransform, Is.Not.Null); + Assert.That(postProcessTransform.GetComponent().sharedProfile, Is.SameAs(postProcessProfile)); + var keyLightTransform = hostObject.transform.Find("Eve World Key Light"); + Assert.That(keyLightTransform, Is.Not.Null); + var keyLight = keyLightTransform.GetComponent(); + Assert.That(keyLight.type, Is.EqualTo(LightType.Directional)); + Assert.That(keyLight.intensity, Is.EqualTo(0.75f).Within(0.001f)); + Assert.That(keyLight.color, Is.EqualTo(new Color(1f, 0.95f, 0.9f, 1f))); + Assert.That(Vector3.Dot(keyLightTransform.forward, new Vector3(0.4f, -1f, 0.25f).normalized), + Is.GreaterThan(0.999f)); + Assert.That(nativeAssets.LastBinding?.AssetRef, Is.EqualTo("profile.environment.flight")); + Assert.That(nativeAssets.LastBinding?.EntityKind, Is.Empty); + + var secondaryHost = secondaryHostObject.AddComponent(); + secondaryHost.Configure(secondaryRootObject.transform, provider, provider, provider, provider, nativeAssets); + secondaryHost.Connect(); + secondaryHostObject.SetActive(true); + secondaryCameraObject.AddComponent(); + var secondaryRig = secondaryCameraObject.AddComponent(); + secondaryRig.Host = secondaryHost; + secondaryRig.CameraTransform = secondaryCameraObject.transform; + Assert.That(secondaryRig.ApplyRig(0f), Is.False); + Assert.That(secondaryHost.ActiveCameraTransform, Is.Null); + Assert.That(RenderSettings.skybox, Is.SameAs(skyboxMaterial)); + + var competingRig = competingCameraObject.AddComponent(); + competingRig.Host = host; + competingRig.CameraTransform = competingCameraObject.transform; + Assert.That(competingRig.ApplyRig(0f), Is.False); + Assert.That(host.ActiveCameraTransform, Is.SameAs(cameraObject.transform)); + + var aim = hostObject.GetComponent(); + Assert.That(aim, Is.Not.Null); + var playerPosition = player.transform.position; + var playerRotation = player.transform.rotation; + aim.RefreshNow(); + Assert.That(aim.ViewDotVisible, Is.True); + Assert.That(Vector3.Distance(playerPosition, aim.ViewDotPosition), Is.EqualTo(50f).Within(0.001f)); + Assert.That(player.transform.position, Is.EqualTo(playerPosition)); + Assert.That(player.transform.rotation, Is.EqualTo(playerRotation)); + + var selectedTarget = rootObject.GetComponentsInChildren() + .Single(marker => marker.EntityId == "raider-scout"); + Assert.That(EveUnityCombatPresentation.Find(host.ActiveProjection)?.SelectedTargetEntityId, + Is.EqualTo("raider-scout")); + var poseWithoutSelectedTarget = cameraObject.transform.position; + selectedTarget.transform.position = new Vector3(10000f, 0f, -10000f); + Assert.That(rig.ApplyRig(0f), Is.True); + Assert.That(cameraObject.transform.position, Is.EqualTo(poseWithoutSelectedTarget)); + viewport = camera.WorldToViewportPoint(player.transform.position); + Assert.That(viewport.x, Is.EqualTo(0.9f).Within(0.001f)); + Assert.That(viewport.y, Is.EqualTo(0.55f).Within(0.001f)); + + var beforeFollow = cameraObject.transform.position; + player.transform.position += Vector3.right * 10f; + Assert.That(rig.ApplyRig(0.1f), Is.True); + Assert.That( + cameraObject.transform.position.x - beforeFollow.x, + Is.EqualTo(10f * (1f - Mathf.Exp(-0.5f))).Within(0.001f)); + rig.CameraTransform = null; + Assert.That(host.ActiveCameraTransform, Is.Null); + Assert.That(hostObject.transform.Find("Eve World Key Light"), Is.Null); + Assert.That(hostObject.transform.Find("Eve World Post Process"), Is.Null); + Assert.That(camera.GetComponent(), Is.Null); + Assert.That(gradingPipelineAsset.colorGradingMode, Is.EqualTo(previousGradingMode)); + aim.RefreshNow(); + Assert.That(aim.ViewDotVisible, Is.False); + Assert.That(RenderSettings.ambientMode, Is.EqualTo(ambientMode)); + Assert.That(RenderSettings.ambientLight, Is.EqualTo(ambientLight)); + Assert.That(RenderSettings.ambientIntensity, Is.EqualTo(ambientIntensity)); + Assert.That(RenderSettings.skybox, Is.SameAs(previousSkybox)); + Assert.That(RenderSettings.defaultReflectionMode, Is.EqualTo(previousReflectionMode)); + Assert.That(RenderSettings.customReflectionTexture, Is.SameAs(previousCustomReflection)); + Assert.That(RenderSettings.reflectionIntensity, Is.EqualTo(previousReflectionIntensity)); + Assert.That(camera.clearFlags, Is.EqualTo(CameraClearFlags.SolidColor)); + rig.CameraTransform = cameraObject.transform; + nativeAssets.Clear(); + nativeAssets.Set("some.other.asset", skyboxMaterial); + Assert.That(rig.ApplyRig(0f), Is.False); + Assert.That(host.ActiveCameraTransform, Is.Null); + nativeAssets.Set("material.environment.skybox", reflectionCubemap); + nativeAssets.Set("texture.environment.reflection", skyboxMaterial); + nativeAssets.Set("profile.environment.flight", skyboxMaterial); + Assert.That(rig.ApplyRig(0f), Is.False); + Assert.That(host.ActiveCameraTransform, Is.Null); + } + finally + { + RenderSettings.ambientMode = ambientMode; + RenderSettings.ambientLight = ambientLight; + RenderSettings.ambientIntensity = ambientIntensity; + RenderSettings.skybox = previousSkybox; + RenderSettings.defaultReflectionMode = previousReflectionMode; + RenderSettings.customReflectionTexture = previousCustomReflection; + RenderSettings.reflectionIntensity = previousReflectionIntensity; + QualitySettings.renderPipeline = previousQualityPipeline; + GraphicsSettings.defaultRenderPipeline = previousDefaultPipeline; + UnityEngine.Object.DestroyImmediate(gradingPipelineAsset); + UnityEngine.Object.DestroyImmediate(gradingRendererData); + if (skyboxMaterial != null) UnityEngine.Object.DestroyImmediate(skyboxMaterial); + if (reflectionCubemap != null) UnityEngine.Object.DestroyImmediate(reflectionCubemap); + if (postProcessProfile != null) UnityEngine.Object.DestroyImmediate(postProcessProfile); + UnityEngine.Object.DestroyImmediate(secondaryCameraObject); + UnityEngine.Object.DestroyImmediate(secondaryHostObject); + UnityEngine.Object.DestroyImmediate(secondaryRootObject); + UnityEngine.Object.DestroyImmediate(competingCameraObject); + UnityEngine.Object.DestroyImmediate(cameraObject); + UnityEngine.Object.DestroyImmediate(hostObject); + UnityEngine.Object.DestroyImmediate(rootObject); + } + } + + [Test] + public void PlayableWorldCameraRigFramesEntityAndLooksAtAdvertisedConvergencePointWithoutProviderTypes() + { + var rootObject = new GameObject("generic-forward-world-root"); + var hostObject = new GameObject("generic-forward-client"); + var cameraObject = new GameObject("generic-forward-camera"); + hostObject.SetActive(false); + try + { + var provider = hostObject.AddComponent(); + var nativeAssets = hostObject.AddComponent(); + provider.Set( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument( + cameraRig: "perspective.entity-forward-follow.v1", + cameraDistance: "30", + cameraTargetScreenX: "0.64", + cameraTargetScreenY: "0.81", + cameraPositionDamping: "0", + cameraNearClipPlane: "1", + cameraFarClipPlane: "4096", + cameraLookAt: "aim.convergence-point.v1"), + Advertisement("aetheria.daemon.game"), + "cultmesh://generic/eve/surfaces/forward", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://generic/assets/manifest", + Array.Empty(), + "generic")); + + var host = hostObject.AddComponent(); + host.Configure(rootObject.transform, provider, provider, provider, provider, nativeAssets); + host.Connect(); + hostObject.SetActive(true); + var camera = cameraObject.AddComponent(); + camera.aspect = 16f / 9f; + var rig = hostObject.AddComponent(); + rig.Host = host; + rig.CameraTransform = cameraObject.transform; + + var player = rootObject.GetComponentInChildren(); + Assert.That(player, Is.Not.Null); + Assert.That(rig.ApplyRig(0f), Is.True); + var aimPoint = player!.transform.position + player.transform.forward * 50f; + var cameraToAim = (aimPoint - cameraObject.transform.position).normalized; + Assert.That(Vector3.Dot(cameraObject.transform.forward, cameraToAim), Is.GreaterThan(0.999f)); + Assert.That(cameraObject.transform.forward.y, Is.GreaterThan(0f)); + Assert.That(camera.fieldOfView, Is.EqualTo(60f).Within(0.001f)); + Assert.That(camera.nearClipPlane, Is.EqualTo(1f).Within(0.001f)); + Assert.That(camera.farClipPlane, Is.EqualTo(4096f).Within(0.001f)); + var viewport = camera.WorldToViewportPoint(player.transform.position); + Assert.That(viewport.x, Is.EqualTo(0.64f).Within(0.001f)); + Assert.That(viewport.y, Is.EqualTo(0.81f).Within(0.001f)); + Assert.That(cameraObject.transform.position.y, Is.LessThan(player.transform.position.y)); + } + finally + { + UnityEngine.Object.DestroyImmediate(cameraObject); + UnityEngine.Object.DestroyImmediate(hostObject); + UnityEngine.Object.DestroyImmediate(rootObject); + } + } + + [Test] + public void SemanticScaleWrapsProviderAuthoredScaleAndFallbackGeometry() { var root = new GameObject("world"); var prefab = new GameObject("provider-prefab"); @@ -834,7 +2060,7 @@ namespace GameCult.Eve.UnityScene.Tests "prefab.ship", "ship", "provider-asset-ref")); var instance = root.transform.GetChild(0); - Assert.That(instance.localScale, Is.EqualTo(Vector3.one)); + Assert.That(instance.localScale, Is.EqualTo(Vector3.one * 12f)); Assert.That(instance.GetChild(0).localScale, Is.EqualTo(new Vector3(2f, 3f, 4f))); var fallbackSink = new EveUnityGameObjectPlayableWorldSceneSink( @@ -865,7 +2091,7 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(firstPresentation.WorldRootId, Is.EqualTo("aetheria.daemon.game.playable")); Assert.That(firstPresentation.PlayerEntityId, Is.EqualTo("player-vanguard")); Assert.That(firstPresentation.InputProfile, Is.EqualTo("arpg-third-person")); - Assert.That(firstPresentation.CameraRig, Is.EqualTo("third-person-orbit")); + Assert.That(firstPresentation.CameraRig, Is.EqualTo("planar.top-down-follow.v1")); Assert.That(firstPresentation.UpsertedEntities, Is.EqualTo(3)); Assert.That(firstPresentation.RemovedEntities, Is.EqualTo(0)); Assert.That(firstPresentation.ActiveEntities, Is.EqualTo(3)); @@ -962,7 +2188,7 @@ namespace GameCult.Eve.UnityScene.Tests } [Test] - public void LivePlayableWorldClientWaitsForProviderSurfaceUpdateAfterReceipt() + public void LivePlayableWorldClientReconcilesReceiptAfterReactiveStateVersionAdvances() { var source = new FakeProviderSurfaceSource( "aetheria", @@ -1019,12 +2245,18 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(client.LastReceipt.State, Is.EqualTo("pending")); Assert.That(client.ActiveVersion, Is.EqualTo(1)); + client.AdvanceStateVersion(2); + + Assert.That(client.LastReceipt.State, Is.EqualTo("reconciled")); + Assert.That(client.ActiveVersion, Is.EqualTo(2)); + Assert.That(sceneSink.ConfiguredWorlds.Count, Is.EqualTo(1), + "reactive state visibility must not require republishing heavy surface topology"); + source.Publish(new EveUnitySceneProviderSurfaceSnapshot( PlayableArpgDocument(includeRaider: false, playerPosition: "30,0,30"), Advertisement("aetheria.daemon.game"), "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", 2)); - Assert.That(client.LastReceipt.State, Is.EqualTo("reconciled")); Assert.That(client.ActiveVersion, Is.EqualTo(2)); Assert.That(client.LastPresentation, Is.Not.Null); Assert.That(client.LastPresentation!.ActiveEntities, Is.EqualTo(2)); @@ -1324,7 +2556,33 @@ namespace GameCult.Eve.UnityScene.Tests private static EveSurfaceDocument PlayableArpgDocument( bool includeRaider = true, - string playerPosition = "0,0,0") + string playerPosition = "0,0,0", + string skyboxAssetRef = "", + string reflectionAssetRef = "", + string postProcessProfileAssetRef = "", + string cameraRig = "planar.top-down-follow.v1", + string cameraDistance = "150", + string cameraTargetScreenX = "0.9", + string cameraTargetScreenY = "0.55", + string cameraPositionDamping = "5", + string cameraNearClipPlane = "0.3", + string cameraFarClipPlane = "4096", + string cameraLookAt = "", + string cameraReconstruction = "", + string temporalQuality = "high", + string temporalHistoryBlend = "0", + string temporalJitterScale = "0", + string temporalSharpening = "0", + string exposureMode = "", + string exposureLowPercent = "50", + string exposureHighPercent = "95", + string exposureMinimumEv = "0", + string exposureMaximumEv = "0", + string exposureKeyValue = "1", + string exposureAdaptation = "progressive", + string exposureSpeedUp = "2", + string exposureSpeedDown = "1", + string colorGradingSpace = "") { var playableChildren = new List { @@ -1383,6 +2641,31 @@ namespace GameCult.Eve.UnityScene.Tests "", "")); + playableChildren.Add(new EveSurfaceComponent( + "aetheria.daemon.game.aim", + "aim.presentation", + new Dictionary(StringComparer.Ordinal) + { + ["controlledEntityId"] = "player-vanguard", + ["convergenceTargetEntityId"] = "", + ["viewDotRole"] = "pilot.view-direction", + ["minimumConvergenceDistance"] = "50", + ["viewDotRadius"] = "0.8" + }, + Array.Empty())); + + playableChildren.Add(new EveSurfaceComponent( + "aetheria.daemon.game.combat", + "combat.presentation", + new Dictionary(StringComparer.Ordinal) + { + ["controlledEntityId"] = "player-vanguard", + ["selectedTargetEntityId"] = "raider-scout", + ["targetVisible"] = "true", + ["targetHostile"] = "true" + }, + Array.Empty())); + playableChildren.Add(new EveSurfaceComponent( "aetheria.daemon.game.flow", "field.vector3d", @@ -1395,6 +2678,36 @@ namespace GameCult.Eve.UnityScene.Tests }, Array.Empty())); + playableChildren.Add(new EveSurfaceComponent( + "aetheria.daemon.game.gravity-fog", + "field.volume3d", + new Dictionary(StringComparer.Ordinal) + { + ["documentRef"] = "cultmesh://aetheria/world/fog-splats", + ["documentSchema"] = EveFieldsSchemas.Splats, + ["materialAssetRef"] = "shader.environment.gravity-fog", + ["renderChannel"] = "world.transparent", + ["compositeMode"] = "premultiplied-alpha", + ["quality"] = "normal", + ["layerBindings"] = "fog.surface_height=surfaceHeight;fog.tint=tint" + }, + Array.Empty())); + + playableChildren.Add(new EveSurfaceComponent( + "aetheria.daemon.game.stardust", + "field.particles3d", + new Dictionary(StringComparer.Ordinal) + { + ["documentRef"] = "cultmesh://aetheria/world/fog-splats", + ["documentSchema"] = EveFieldsSchemas.Splats, + ["computeProgramAssetRef"] = "compute.environment.stardust", + ["materialAssetRef"] = "material.environment.stardust", + ["renderChannel"] = "world.transparent", + ["span"] = "256", + ["spacing"] = "6" + }, + Array.Empty())); + return new EveSurfaceDocument( "aetheria", "game.runtime", @@ -1417,17 +2730,55 @@ namespace GameCult.Eve.UnityScene.Tests ["statePointerId"] = "cultmesh://aetheria/run/current", ["entityViewPointerId"] = "cultmesh://aetheria/world/entities.soa", ["entityViewSchema"] = EveEntitySoaViewDocument.SchemaId, + ["entityBodyId"] = "eve:entity-soa:aetheria.daemon:pilot", ["zoneRenderPointerId"] = "cultmesh://aetheria/world/zone-render", ["zoneRenderSchema"] = "gamecult.aetheria.zone_render.v1", ["assetManifest"] = "cultmesh://aetheria/assets/manifest", + ["assetProviderId"] = "aetheria.assets", + ["assetVerseId"] = "aetheria.mods", + ["assetAuthorityRuntimeId"] = "mod-authority", + ["assetRendezvousEndpoints"] = "wss://odin-a/cultmesh;wss://odin-b/cultmesh", ["inputProfile"] = "arpg-third-person", - ["cameraRig"] = "third-person-orbit", - ["playerEntityId"] = "player-vanguard", + ["cameraRig"] = cameraRig, + ["cameraLookAt"] = cameraLookAt, ["cameraTargetEntityId"] = "player-vanguard", - ["subjectVisible"] = "true", - ["movementEnabled"] = "true", - ["presentationMode"] = "world", + ["cameraDistance"] = cameraDistance, + ["cameraVerticalFieldOfViewDegrees"] = "60", + ["cameraTargetScreenX"] = cameraTargetScreenX, + ["cameraTargetScreenY"] = cameraTargetScreenY, + ["cameraPositionDamping"] = cameraPositionDamping, + ["cameraNearClipPlane"] = cameraNearClipPlane, + ["cameraFarClipPlane"] = cameraFarClipPlane, + ["ambientLightColor"] = "0.2,0.2,0.2", + ["ambientLightIntensity"] = "1.46", + ["skyboxAssetRef"] = skyboxAssetRef, + ["reflectionAssetRef"] = reflectionAssetRef, + ["reflectionIntensity"] = "1", + ["postProcessProfileAssetRef"] = postProcessProfileAssetRef, + ["cameraReconstruction"] = cameraReconstruction, + ["temporalQuality"] = temporalQuality, + ["temporalHistoryBlend"] = temporalHistoryBlend, + ["temporalJitterScale"] = temporalJitterScale, + ["temporalSharpening"] = temporalSharpening, + ["exposureMode"] = exposureMode, + ["exposureLowPercent"] = exposureLowPercent, + ["exposureHighPercent"] = exposureHighPercent, + ["exposureMinimumEv"] = exposureMinimumEv, + ["exposureMaximumEv"] = exposureMaximumEv, + ["exposureKeyValue"] = exposureKeyValue, + ["exposureAdaptation"] = exposureAdaptation, + ["exposureSpeedUp"] = exposureSpeedUp, + ["exposureSpeedDown"] = exposureSpeedDown, + ["colorGradingSpace"] = colorGradingSpace, + ["keyLightDirection"] = "0.4,-1,0.25", + ["keyLightColor"] = "1,0.95,0.9", + ["keyLightIntensity"] = "0.75", + ["excludedRenderChannels"] = "map", + ["playerEntityId"] = "player-vanguard", ["movementCommand"] = "aetheria.daemon.move_intent", + ["lookCommand"] = "aetheria.daemon.look_intent", + ["lookModel"] = "planar-yaw.v1", + ["lookSensitivityRadians"] = "-0.001", ["focusCommand"] = "aetheria.daemon.focus", ["targetCommand"] = "aetheria.daemon.target", ["actionCommand"] = "aetheria.daemon.use_equipment" @@ -1624,42 +2975,22 @@ namespace GameCult.Eve.UnityScene.Tests } } - private sealed class FakeProviderSurfaceDocumentSource : - IEveUnitySceneProviderSurfaceDocumentSource, - IEveUnityEntitySoaViewDocumentSource, - IEveUnityInputCapabilityDocumentSource + private sealed class FakeProviderSurfaceDocumentSource : IEveUnitySceneProviderSurfaceDocumentSource { - public FakeProviderSurfaceDocumentSource( - EveUnitySceneProviderSurfaceDocument currentDocument, - EveEntitySoaViewDocument? currentEntityView = null, - EveInputCapabilityDocument? currentInputCapability = null) + public FakeProviderSurfaceDocumentSource(EveUnitySceneProviderSurfaceDocument currentDocument) { CurrentDocument = currentDocument; - CurrentEntityView = currentEntityView; - CurrentInputCapability = currentInputCapability; } public EveUnitySceneProviderSurfaceDocument CurrentDocument { get; private set; } public event Action? DocumentAvailable; - public EveEntitySoaViewDocument? CurrentEntityView { get; private set; } - - public event Action? EntityViewAvailable; - - public EveInputCapabilityDocument? CurrentInputCapability { get; } - public void Publish(EveUnitySceneProviderSurfaceDocument document) { CurrentDocument = document; DocumentAvailable?.Invoke(document); } - - public void PublishEntityView(EveEntitySoaViewDocument document) - { - CurrentEntityView = document; - EntityViewAvailable?.Invoke(document); - } } private sealed class FakeCommandReceiptSource : IEveUnitySceneCommandReceiptSource @@ -1756,6 +3087,10 @@ namespace GameCult.Eve.UnityScene.Tests private sealed class FakeLiveProviderTransport : IEveUnitySceneLiveProviderTransport { + private Action? _surfaceDocumentAvailable; + private Action? _assetManifestDocumentAvailable; + private Action? _commandReceiptAvailable; + public FakeLiveProviderTransport( EveUnitySceneProviderSurfaceDocument surfaceDocument, EveUnityPlayableWorldAssetManifestDocument assetManifestDocument) @@ -1782,15 +3117,38 @@ namespace GameCult.Eve.UnityScene.Tests public int RefreshCount { get; private set; } - public event Action? SurfaceDocumentAvailable; + public bool FailNextConnect { get; set; } - public event Action? AssetManifestDocumentAvailable; + public int SurfaceSubscriberCount => _surfaceDocumentAvailable?.GetInvocationList().Length ?? 0; - public event Action? CommandReceiptAvailable; + public int AssetManifestSubscriberCount => _assetManifestDocumentAvailable?.GetInvocationList().Length ?? 0; + + public int ReceiptSubscriberCount => _commandReceiptAvailable?.GetInvocationList().Length ?? 0; + + public event Action? SurfaceDocumentAvailable + { + add => _surfaceDocumentAvailable += value; + remove => _surfaceDocumentAvailable -= value; + } + + public event Action? AssetManifestDocumentAvailable + { + add => _assetManifestDocumentAvailable += value; + remove => _assetManifestDocumentAvailable -= value; + } + + public event Action? CommandReceiptAvailable + { + add => _commandReceiptAvailable += value; + remove => _commandReceiptAvailable -= value; + } public void Connect() { ConnectCount++; + if (!FailNextConnect) return; + FailNextConnect = false; + throw new IOException("transient transport failure"); } public void Disconnect() @@ -1811,18 +3169,18 @@ namespace GameCult.Eve.UnityScene.Tests public void PublishSurface(EveUnitySceneProviderSurfaceDocument document) { CurrentSurfaceDocument = document; - SurfaceDocumentAvailable?.Invoke(document); + _surfaceDocumentAvailable?.Invoke(document); } public void PublishAssetManifest(EveUnityPlayableWorldAssetManifestDocument document) { CurrentAssetManifestDocument = document; - AssetManifestDocumentAvailable?.Invoke(document); + _assetManifestDocumentAvailable?.Invoke(document); } public void PublishReceipt(EveUnitySceneCommandReceipt receipt) { - CommandReceiptAvailable?.Invoke(receipt); + _commandReceiptAvailable?.Invoke(receipt); } } @@ -1846,6 +3204,166 @@ namespace GameCult.Eve.UnityScene.Tests "", "", "", ""); } + private static EveEntitySoaViewDocument EntityLeaseDocument() => new EveEntitySoaViewDocument + { + ProviderId = "provider", + ViewId = "entities", + BodySchemaId = "test.entity.slab.v1", + LayoutVersion = 3, + ProducerEpoch = 5, + Sequence = 7, + Capacity = 1, + Buffers = new[] { new EveEntitySoaBuffer { BufferId = "hot", ByteLength = 4 } }, + Identities = new[] + { + new EveEntityIdentity + { + Index = 12, + EntityId = "entity:pilot", + EntityKind = "player", + Label = "Pilot", + Faction = "alliance", + Selectable = true, + Controllable = true, + AssetRef = "cultmesh://assets/pilot" + } + }, + Columns = new[] + { + new EveEntitySoaColumn + { + ColumnId = "entity-index", + Semantic = "entity.index", + BufferId = "hot", + ScalarType = "int32", + ElementStride = 4, + ElementCount = 1 + } + } + }; + + private static CultMeshRealtimeFrame RealtimeFrame(string bodyId, long epoch, long sequence) => + new CultMeshRealtimeFrame + { + ChannelId = "state", + BodyId = bodyId, + SchemaId = "test.entity.slab.v1", + ProducerEpoch = epoch, + Sequence = sequence, + Delivery = CultMeshRealtimeDelivery.LatestOnly, + Payload = new byte[4] + }; + + private static EveProviderAdvertisementDocument ProviderAdvertisement( + string providerId, + string serviceId, + params string[] authorizedBodyProducerIds) => + new EveProviderAdvertisementDocument( + providerId, + serviceId, + "test-verse", + "Test Provider", + "test", + "cultmesh://test/provider", + DateTimeOffset.UtcNow.ToString("O"), + new EveProviderFreshness("fresh", DateTimeOffset.UtcNow.ToString("O"), 5000), + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + authorizedBodyProducerIds); + + private static CultMeshBodyPublicationDocument BodyPublication( + EveEntitySoaViewDocument view, + long sequence) + { + var descriptor = FakeBodyReadLease.DescriptorFor(view, view.Buffers[0].ByteLength); + descriptor.Sequence = sequence; + descriptor.Synchronization = CultMeshBodySynchronization.ImmutableSequence; + descriptor.LeaseExpiresAtUnixMs = DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeMilliseconds(); + descriptor.TransportKind = CultMeshBodyTransportKind.SharedFileMapping; + var network = FakeBodyReadLease.DescriptorFor(view, view.Buffers[0].ByteLength); + network.Sequence = sequence; + network.Synchronization = descriptor.Synchronization; + network.LeaseExpiresAtUnixMs = descriptor.LeaseExpiresAtUnixMs; + network.TransportKind = CultMeshBodyTransportKind.Network; + return new CultMeshBodyPublicationDocument + { + BodyId = descriptor.BodyId, + ProducerId = view.ProviderId, + SchemaId = descriptor.SchemaId, + LayoutVersion = descriptor.LayoutVersion, + ByteSize = descriptor.ByteSize, + Capacity = descriptor.Capacity, + ProducerEpoch = descriptor.ProducerEpoch, + Sequence = sequence, + Synchronization = descriptor.Synchronization, + LivenessExpiresAtUnixMs = descriptor.LeaseExpiresAtUnixMs, + Representations = new[] { descriptor, network } + }; + } + + private static EveEntitySoaColumn SoaColumn( + string semantic, + string scalarType, + long byteOffset, + int stride) => new EveEntitySoaColumn + { + ColumnId = semantic, + Semantic = semantic, + BufferId = "hot", + ScalarType = scalarType, + ByteOffset = byteOffset, + ElementStride = stride, + ElementCount = 1 + }; + + private static EveUnityPresentedEntity PresentedEntity(int index, string entityId, string assetRef) => + new EveUnityPresentedEntity( + index, entityId, "entity", entityId, "", Vector3.zero, 0f, Vector3.zero, + 1f, 1f, 0, 0, true, false, assetRef); + + private sealed class FakeBodyReadLease : ICultMeshBodyReadLease + { + private readonly byte[] _bytes; + + public FakeBodyReadLease(EveEntitySoaViewDocument document, byte[] bytes) + : this(DescriptorFor(document, bytes.Length), bytes) { } + + public FakeBodyReadLease(CultMeshBodyDescriptor descriptor, byte[] bytes) + { + Descriptor = descriptor; + _bytes = bytes; + } + + public CultMeshBodyDescriptor Descriptor { get; } + public CultMeshBodyTransportKind TransportKind => CultMeshBodyTransportKind.Network; + public bool Disposed { get; private set; } + public byte ReadByte(long offset) => _bytes[checked((int)offset)]; + public int ReadInt32(long offset) => BitConverter.ToInt32(_bytes, checked((int)offset)); + public long ReadInt64(long offset) => BitConverter.ToInt64(_bytes, checked((int)offset)); + public float ReadSingle(long offset) => BitConverter.ToSingle(_bytes, checked((int)offset)); + public double ReadDouble(long offset) => BitConverter.ToDouble(_bytes, checked((int)offset)); + public int CopyTo(long offset, byte[] destination, int destinationOffset, int count) + { + Buffer.BlockCopy(_bytes, checked((int)offset), destination, destinationOffset, count); + return count; + } + public void Dispose() => Disposed = true; + + public static CultMeshBodyDescriptor DescriptorFor(EveEntitySoaViewDocument document, long byteSize) => + new CultMeshBodyDescriptor + { + BodyId = document.Buffers[0].BufferId, + SchemaId = document.BodySchemaId, + LayoutVersion = document.LayoutVersion, + ByteSize = byteSize, + Capacity = document.Capacity, + ProducerEpoch = document.ProducerEpoch, + Sequence = document.Sequence + }; + } + private sealed class FixedGameObjectAssetProvider : IEveUnityGameObjectAssetProvider { private readonly GameObject? _prefab; @@ -1858,18 +3376,104 @@ namespace GameCult.Eve.UnityScene.Tests public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) => _prefab; } + private sealed class FixedNativeAssetProviderComponent : MonoBehaviour, IEveUnityNativeAssetProvider + { + private readonly Dictionary _assets = + new Dictionary(StringComparer.Ordinal); + + public EveUnityPlayableWorldAssetBinding? LastBinding { get; private set; } + + public void Set(string assetRef, UnityEngine.Object asset) + { + _assets[assetRef ?? ""] = asset; + } + + public void Clear() => _assets.Clear(); + + public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) => null; + + public UnityEngine.Object? ResolveAsset(EveUnityPlayableWorldAssetBinding asset, Type assetType) + { + LastBinding = asset; + return _assets.TryGetValue(asset?.AssetRef ?? "", out var value) && assetType.IsInstanceOfType(value) + ? value + : null; + } + } + + private sealed class ObservingGameObjectAssetProvider : IEveUnityGameObjectAssetProvider + { + private readonly Action _onResolve; + + public ObservingGameObjectAssetProvider(Action onResolve) + { + _onResolve = onResolve; + } + + public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) + { + _onResolve(); + return null; + } + } + + private sealed class FixedRenderChannelPolicy : IEveUnityCameraRenderPolicySource + { + private readonly string _channel; + private readonly int _layer; + + public FixedRenderChannelPolicy(string channel, int layer) + { + _channel = channel; + _layer = layer; + } + + public bool TryGetRenderChannelLayer(string channel, out int layer) + { + layer = _layer; + return string.Equals(channel, _channel, StringComparison.Ordinal); + } + } + private sealed class FakePlayableWorldProviderComponent : MonoBehaviour, IEveUnitySceneProviderSurfaceDocumentSource, IEveUnityPlayableWorldAssetManifestDocumentSource, IEveUnitySceneCommandSink, IEveUnitySceneCommandReceiptSource, - IEveUnityProviderRefreshSource + IEveUnityProviderRefreshSource, + IEveUnityInputCapabilitySource, + IEveUnitySceneProviderSurfaceDocumentConnection, + IEveUnityNavigableProvider { public List Submitted { get; } = new List(); public int RefreshCount { get; private set; } + public EveUnitySceneNavigationTarget? LastNavigation { get; private set; } + + public int CommitNavigationCount { get; private set; } + + public int FinalizeNavigationCount { get; private set; } + + public int RollbackNavigationCount { get; private set; } + + public bool FailNextNavigationMount { get; set; } + + public bool FailNextNavigationFinalize { get; set; } + + public int ActiveConnectionLeases { get; private set; } + + public string ActiveRoute { get; private set; } = "original"; + + public Func? PreviousPresentationActive { get; set; } + + public bool? PreviousPresentationActiveDuringCommit { get; private set; } + + public bool? PreviousPresentationActiveDuringRollback { get; private set; } + + private bool _failSurfaceRead; + public string SinkKind => "fake-provider-command-sink"; public string ManifestRef => CurrentDocument.ManifestRef; @@ -1887,8 +3491,13 @@ namespace GameCult.Eve.UnityScene.Tests Array.Empty(), "aetheria"); + public EveInputCapabilityDocument CurrentInputCapability { get; private set; } = + new EveInputCapabilityDocument(); + EveUnitySceneProviderSurfaceDocument IEveUnitySceneProviderSurfaceDocumentSource.CurrentDocument => - CurrentSurfaceDocument; + _failSurfaceRead + ? throw new InvalidOperationException("Deliberate candidate surface read failure.") + : CurrentSurfaceDocument; public event Action? DocumentAvailable; @@ -1904,10 +3513,12 @@ namespace GameCult.Eve.UnityScene.Tests public void Set( EveUnitySceneProviderSurfaceDocument surfaceDocument, - EveUnityPlayableWorldAssetManifestDocument assetManifest) + EveUnityPlayableWorldAssetManifestDocument assetManifest, + EveInputCapabilityDocument? inputCapability = null) { CurrentSurfaceDocument = surfaceDocument; CurrentDocument = assetManifest; + CurrentInputCapability = inputCapability ?? new EveInputCapabilityDocument(); } public void Refresh() @@ -1926,6 +3537,51 @@ namespace GameCult.Eve.UnityScene.Tests { ReceiptAvailable?.Invoke(receipt); } + + public Task NavigateAsync(EveUnitySceneNavigationTarget target) + { + LastNavigation = target; + _failSurfaceRead = FailNextNavigationMount; + FailNextNavigationMount = false; + return Task.CompletedTask; + } + + public void Connect() + { + ActiveConnectionLeases++; + } + + public void Disconnect() + { + if (ActiveConnectionLeases > 0) + ActiveConnectionLeases--; + } + + public void CommitNavigation() + { + PreviousPresentationActiveDuringCommit = PreviousPresentationActive?.Invoke(); + CommitNavigationCount++; + ActiveRoute = "candidate"; + _failSurfaceRead = false; + } + + public void FinalizeNavigation() + { + FinalizeNavigationCount++; + if (FailNextNavigationFinalize) + { + FailNextNavigationFinalize = false; + throw new InvalidOperationException("Deliberate provider finalization failure."); + } + } + + public void RollbackNavigation() + { + RollbackNavigationCount++; + ActiveRoute = "original"; + _failSurfaceRead = false; + PreviousPresentationActiveDuringRollback = PreviousPresentationActive?.Invoke(); + } } } } diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotReceiptPresenterTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotReceiptPresenterTests.cs index 98beaa5..02920dd 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotReceiptPresenterTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotReceiptPresenterTests.cs @@ -34,6 +34,8 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(emitted[0].ItemKey, Is.EqualTo("test-cannon")); Assert.That(emitted[0].ImpactKind, Is.EqualTo("shield")); Assert.That(emitted[0].PresentationIntensity, Is.EqualTo(9)); + Assert.That(emitted[0].AppliedDamage, Is.EqualTo(12)); + Assert.That(emitted[0].ShieldAbsorbedDamage, Is.EqualTo(8)); Assert.That(presenter.Apply(next), Is.Zero); } @@ -69,7 +71,9 @@ namespace GameCult.Eve.UnityScene.Tests ["presentationKind"] = "test-laser", ["itemKey"] = "test-cannon", ["impactKind"] = "shield", - ["presentationIntensity"] = "9" + ["presentationIntensity"] = "9", + ["appliedDamage"] = "12", + ["shieldAbsorbedDamage"] = "8" }, Empty(), Empty(), 0, 0, Array.Empty(), null, Array.Empty()); diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotTrajectoryRendererTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotTrajectoryRendererTests.cs new file mode 100644 index 0000000..8dff40b --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotTrajectoryRendererTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene.Tests +{ + public sealed class EveUnityShotTrajectoryRendererTests + { + [Test] + public void ProviderShotEffectReplacesGenericFallbackLine() + { + var root = new GameObject("shot-renderer-provider-test"); + var prefab = new GameObject("provider-bolt"); + try + { + var host = root.AddComponent(); + var renderer = root.AddComponent(); + renderer.Bind(host, new BoltProvider(prefab)); + + renderer.Present(Shot("provider-shot")); + + Assert.That(renderer.ActiveTrajectoryCount, Is.EqualTo(1)); + Assert.That(renderer.ActiveFallbackTrajectoryCount, Is.Zero); + Assert.That(root.GetComponentsInChildren(), Is.Empty); + Assert.That(root.transform.GetChild(0).Find("provider-bolt(Clone)"), Is.Not.Null); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + UnityEngine.Object.DestroyImmediate(prefab); + } + } + + [Test] + public void MissingProviderShotEffectUsesGenericFallbackLine() + { + var root = new GameObject("shot-renderer-fallback-test"); + try + { + var host = root.AddComponent(); + var renderer = root.AddComponent(); + renderer.Bind(host, new BoltProvider(null)); + + renderer.Present(Shot("fallback-shot")); + + Assert.That(renderer.ActiveTrajectoryCount, Is.EqualTo(1)); + Assert.That(renderer.ActiveFallbackTrajectoryCount, Is.EqualTo(1)); + Assert.That(root.GetComponentsInChildren(), Has.Length.EqualTo(1)); + } + finally + { + UnityEngine.Object.DestroyImmediate(root); + } + } + + private static EveUnityShotReceipt Shot(string id) => + new EveUnityShotReceipt(new EveUnitySceneNode(id, "shot.receipt", "scene-node", + new Dictionary(StringComparer.Ordinal) + { + ["shotId"] = id, + ["origin"] = "0,0", + ["endpoint"] = "10,0", + ["presentationDuration"] = "0.2", + ["presentationKind"] = "bolt", + ["impactKind"] = "armor", + ["presentationIntensity"] = "85", + ["hit"] = "true" + }, Empty(), Empty(), 0, 0, Array.Empty(), null, + Array.Empty())); + + private static IReadOnlyDictionary Empty() => + new Dictionary(StringComparer.Ordinal); + + private sealed class BoltProvider : IEveUnityGameObjectAssetProvider + { + private readonly GameObject? _prefab; + public BoltProvider(GameObject? prefab) { _prefab = prefab; } + public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) => + asset.EntityKind == "effect.shot.bolt" ? _prefab : null; + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotTrajectoryRendererTests.cs.meta b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotTrajectoryRendererTests.cs.meta new file mode 100644 index 0000000..9cf0c04 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotTrajectoryRendererTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a1d80f8e29164e23ad367feefb67c49e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/GameCult.Eve.UnityScene.Tests.asmdef b/packages/org.gamecult.eve.unity-scene/Tests/Editor/GameCult.Eve.UnityScene.Tests.asmdef index 2f8a14e..5617adc 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/GameCult.Eve.UnityScene.Tests.asmdef +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/GameCult.Eve.UnityScene.Tests.asmdef @@ -4,7 +4,11 @@ "references": [ "GameCult.Eve.Surface", "GameCult.Eve.UnityScene", - "GameCult.Eve.PluginFields" + "GameCult.Eve.UnityScene.CultMesh", + "GameCult.Eve.UnityUIToolkit", + "GameCult.Eve.PluginFields", + "Unity.RenderPipelines.Core.Runtime", + "Unity.RenderPipelines.Universal.Runtime" ], "includePlatforms": [ "Editor" diff --git a/packages/org.gamecult.eve.unity-scene/Third Party Notices.md b/packages/org.gamecult.eve.unity-scene/Third Party Notices.md new file mode 100644 index 0000000..e66c613 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Third Party Notices.md @@ -0,0 +1,27 @@ +# Third-party notices + +## Ashima Arts webgl-noise + +The portable 3D simplex implementation in `Runtime/Fields/EveFieldsSplatsCore.hlsl` +is derived from Ashima Arts' `webgl-noise`, translated to Unity shader syntax by +Keijiro Takahashi. + +Copyright (C) 2011 Ashima Arts. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/org.gamecult.eve.surface/README.md.meta b/packages/org.gamecult.eve.unity-scene/Third Party Notices.md.meta similarity index 75% rename from packages/org.gamecult.eve.surface/README.md.meta rename to packages/org.gamecult.eve.unity-scene/Third Party Notices.md.meta index 4bc751a..4745e42 100644 --- a/packages/org.gamecult.eve.surface/README.md.meta +++ b/packages/org.gamecult.eve.unity-scene/Third Party Notices.md.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: fe5aa27636bead047b2aecb14fdc59f2 +guid: 5702faba00994033870c858bb2124d91 TextScriptImporter: externalObjects: {} userData: diff --git a/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json b/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json index 122968b..244999a 100644 --- a/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json +++ b/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json @@ -46,12 +46,17 @@ "pluginId": "fields.surface", "capabilities": [ "field.surface2d", - "gravity.surface" + "gravity.surface", + "field.volume3d", + "volume.raymarch", + "volume.temporal-history", + "volume.composite" ], "projectionAdapter": "EveFieldsSplatRasterizer", "evidencePaths": [ "Runtime/Fields/EveFieldsSplatRasterizer.cs", "Runtime/Fields/EveFieldsSplatLayerRenderer.cs", + "Runtime/Fields/EveUnityFieldsVolumeRenderer.cs", "Tests/Editor/EveFieldsUnitySceneProjectionTests.cs", "artifacts/fields-surface/latest/unity-fields.png" ] @@ -108,12 +113,15 @@ "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs", "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs", "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs", + "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityUiToolkitOverlay.cs", "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs", "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs", "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs", "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityFeedbackPresenter.cs", "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotReceiptPresenter.cs", "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotTrajectoryRenderer.cs", + "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentation.cs", + "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentationRenderer.cs", "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityProviderRefreshSource.cs", "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldPresenter.cs", "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs", @@ -121,7 +129,8 @@ "packages/org.gamecult.eve.unity-scene/Runtime/SaiVisualNovelUnitySceneProjectionAdapter.cs", "packages/org.gamecult.eve.unity-scene/Runtime/NornGraphUnitySceneProjectionAdapter.cs", "packages/org.gamecult.eve.unity-scene/Runtime/TeXMathUnitySceneProjectionAdapter.cs", - "packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs" + "packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs", + "packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCombatPresentationTests.cs" ] } ], @@ -151,10 +160,17 @@ "requiredPackageDependencies": [ { "packageName": "org.gamecult.eve.surface", - "version": "0.1.0", + "version": "0.3.0", "packageManager": "upm", - "ownerRepo": "EveUnity", - "purpose": "Shared Unity DTOs for gamecult.eve.surface.v1 documents." + "ownerRepo": "Eve", + "purpose": "Renderer-neutral contracts and builders for gamecult.eve.surface.v1 documents." + }, + { + "packageName": "org.gamecult.eve.unity-uitoolkit", + "version": "0.1.3", + "packageManager": "upm", + "ownerRepo": "EveUnity", + "purpose": "Generic UI Toolkit lowering for provider-owned Eve panes, metrics, progress, text, and controls." } ] }, diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index a241183..f5dcbe0 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,14 +1,17 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.1.4", - "unity": "2021.3", + "version": "0.3.134", + "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { - "org.gamecult.cultlib": "1.0.6", - "org.gamecult.eve.plugin-fields": "0.1.1", - "org.gamecult.eve.surface": "0.1.0", - "com.unity.mathematics": "1.3.2" + "org.gamecult.cultlib": "1.0.56", + "org.gamecult.eve.plugin-fields": "0.2.3", + "org.gamecult.eve.surface": "0.3.6", + "org.gamecult.eve.unity-uitoolkit": "0.1.9", + "com.unity.mathematics": "1.3.2", + "com.unity.render-pipelines.universal": "17.4.0", + "com.unity.modules.particlesystem": "1.0.0" }, "keywords": [ "Eve", diff --git a/packages/org.gamecult.eve.unity-uitoolkit/README.md b/packages/org.gamecult.eve.unity-uitoolkit/README.md index a0b325c..cf788bd 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/README.md +++ b/packages/org.gamecult.eve.unity-uitoolkit/README.md @@ -27,6 +27,13 @@ Nested CultUI regions use component `EmbeddedDocuments`. Pass an mounts the resolved child surface under the slot while preserving the child document's command surface id. +`progress` components lower to native UI Toolkit `ProgressBar` elements. The +provider supplies a normalized `ratio` (falling back to `value`), label, and +layout; Unity clamps the native visual to `[0,1]` without becoming the value +authority. Generated layout supports relative or absolute positioning plus +`inset`, `top`, `right`, `bottom`, and `left`, so provider-authored overlays +can compose over scene lowerers without consumer-specific HUD code. + Discovery and test coverage for this feature live in the shared Eve parity matrix: `../../tools/parity/parity-manifest.json` names `embeddedDocuments` and `../../web/fixtures/cultui-embedded-surface.json` is the canonical fixture. diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs index f989ccf..f5397a2 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs @@ -13,6 +13,11 @@ namespace GameCult.Eve.UnityUIToolkit public sealed class EveUiToolkitSurfaceLowerer { private readonly EveUiToolkitSurfaceOptions _options; + private EveSurfaceComponent? _inventoryDragSource; + private Vector2 _inventoryDragStart; + private bool _inventoryPointerMoved; + private bool _suppressInventoryClick; + private readonly List _inventoryPreviewCells = new(); public EveUiToolkitSurfaceLowerer(EveUiToolkitSurfaceOptions? options = null) { @@ -41,9 +46,22 @@ namespace GameCult.Eve.UnityUIToolkit element.AddToClassList($"eve-kind-{SafeClass(component.Kind)}"); element.userData = component; ApplyGeneratedLayout(element, component); + element.SetEnabled(IsEnabled(component)); + + if (IsHidden(component) || IsExternalProjectionRoot(component.Kind)) + return element; + + // Select options are semantic children consumed by the control itself. + // They are not separately lowered visual elements. + if (NormalizeKind(component.Kind) == "control.select") + return element; foreach (var child in component.Children) - element.Add(LowerComponent(child, document, commandSink)); + { + var loweredChild = LowerComponent(child, document, commandSink); + PositionInventoryChild(component, child, loweredChild); + element.Add(loweredChild); + } foreach (var slot in component.EmbeddedDocuments) { @@ -97,6 +115,14 @@ namespace GameCult.Eve.UnityUIToolkit element.style.alignItems = Align.Stretch; return element; } + case EveInventoryInteraction.GridKind: + return InventoryGrid(component, document, commandSink); + case EveInventoryInteraction.ItemKind: + return InventoryItem(component, document, commandSink); + case EveInventoryInteraction.DragSessionKind: + return InventoryDragSession(component); + case "control.select": + return Select(component, document, commandSink); case "partition": { var element = new VisualElement(); @@ -106,6 +132,8 @@ namespace GameCult.Eve.UnityUIToolkit element.style.flexWrap = Wrap.Wrap; return element; } + case "scroll": + return new ScrollView(ScrollViewMode.Vertical); case "pane": case "modal": case "card": @@ -115,7 +143,11 @@ namespace GameCult.Eve.UnityUIToolkit card.style.flexDirection = FlexDirection.Column; var title = component.GetProp("title"); if (!string.IsNullOrWhiteSpace(title)) - card.Add(TitleLabel(title)); + { + var titleLabel = TitleLabel(title); + titleLabel.name = "title"; + card.Add(titleLabel); + } return card; } case "metric": @@ -123,17 +155,24 @@ namespace GameCult.Eve.UnityUIToolkit var metric = new VisualElement(); metric.AddToClassList("eve-metric"); metric.style.flexDirection = FlexDirection.Column; - metric.Add(MutedLabel(component.GetProp("label"))); - metric.Add(ValueLabel(component.GetProp("value"))); + var label = MutedLabel(component.GetProp("label")); + label.name = "label"; + metric.Add(label); + var value = ValueLabel(component.GetProp("value")); + value.name = "value"; + metric.Add(value); return metric; } case "progress": { - var progress = new VisualElement(); + var progress = new ProgressBar + { + title = component.GetProp("label"), + lowValue = 0f, + highValue = 1f, + value = ParseRatio(component.GetProp("ratio", component.GetProp("value"))) + }; progress.AddToClassList("eve-progress"); - progress.style.flexDirection = FlexDirection.Column; - progress.Add(MutedLabel(component.GetProp("label"))); - progress.Add(ValueLabel(component.GetProp("value"))); return progress; } case "options": @@ -207,6 +246,234 @@ namespace GameCult.Eve.UnityUIToolkit } } + private VisualElement InventoryGrid( + EveSurfaceComponent component, + EveSurfaceDocument document, + Action? commandSink) + { + var grid = new VisualElement(); + grid.AddToClassList("eve-inventory-grid"); + grid.style.position = Position.Relative; + var columns = Math.Max(1, ParseInt(component.GetProp("columns"), 6)); + var rows = Math.Max(1, ParseInt(component.GetProp("rows"), 3)); + var cellSize = Math.Max(1f, ParseNumber(component.GetProp("cellSize"), 72f)); + var gap = Math.Max(0f, ParseNumber(component.GetProp("cellGap"), 4f)); + grid.style.width = columns * cellSize + Math.Max(0, columns - 1) * gap; + grid.style.height = rows * cellSize + Math.Max(0, rows - 1) * gap; + for (var index = 0; index < Math.Min(columns * rows, 256); index++) + { + var cell = new VisualElement(); + cell.AddToClassList("eve-inventory-cell"); + cell.style.position = Position.Absolute; + cell.style.left = (index % columns) * (cellSize + gap); + cell.style.top = (index / columns) * (cellSize + gap); + cell.style.width = cellSize; + cell.style.height = cellSize; + grid.Add(cell); + } + grid.RegisterCallback(evt => + { + if (_inventoryDragSource == null || _suppressInventoryClick) + return; + if (TryEmitInventoryDrop(document, _inventoryDragSource, component, grid, evt.position, commandSink)) + _inventoryDragSource = null; + ClearInventoryPreview(); + }); + return grid; + } + + private VisualElement InventoryItem( + EveSurfaceComponent component, + EveSurfaceDocument document, + Action? commandSink) + { + var item = new Button { text = component.GetProp("label", component.GetProp("itemKey")) }; + item.AddToClassList("eve-inventory-item"); + item.RegisterCallback(evt => + { + if (!ParseBool(component.GetProp("draggable", "true"))) + return; + _inventoryDragSource = component; + _inventoryDragStart = evt.position; + _inventoryPointerMoved = false; + ClearInventoryPreview(); + item.CapturePointer(evt.pointerId); + evt.StopPropagation(); + }); + item.RegisterCallback(evt => + { + if (_inventoryDragSource != component) + return; + var pointerPosition = new Vector2(evt.position.x, evt.position.y); + if ((pointerPosition - _inventoryDragStart).sqrMagnitude > 16f) + { + _inventoryPointerMoved = true; + var picked = item.panel?.Pick(evt.position); + var targetElement = InventoryGridAncestor(picked); + if (targetElement?.userData is EveSurfaceComponent target) + ShowInventoryPreview(component, target, targetElement, evt.position); + else + ClearInventoryPreview(); + } + }); + item.RegisterCallback(evt => + { + if (_inventoryDragSource != component) + return; + item.ReleasePointer(evt.pointerId); + if (_inventoryPointerMoved) + { + var picked = item.panel?.Pick(evt.position); + var targetElement = InventoryGridAncestor(picked); + if (targetElement?.userData is EveSurfaceComponent target) + TryEmitInventoryDrop(document, component, target, targetElement, evt.position, commandSink); + _inventoryDragSource = null; + _suppressInventoryClick = true; + ClearInventoryPreview(); + } + evt.StopPropagation(); + }); + item.RegisterCallback(evt => + { + if (_suppressInventoryClick) + { + _suppressInventoryClick = false; + evt.StopPropagation(); + return; + } + _inventoryDragSource = component; + evt.StopPropagation(); + }); + return item; + } + + private static VisualElement InventoryDragSession(EveSurfaceComponent component) + { + var panel = new VisualElement(); + panel.AddToClassList("eve-inventory-drag-session"); + var active = ParseBool(component.GetProp("active")); + panel.Add(BodyLabel(active + ? component.GetProp("itemKey", "Dragging") + : "No active drag")); + return panel; + } + + private static void PositionInventoryChild( + EveSurfaceComponent parent, + EveSurfaceComponent child, + VisualElement element) + { + if (!string.Equals(parent.Kind, EveInventoryInteraction.GridKind, StringComparison.Ordinal) || + !string.Equals(child.Kind, EveInventoryInteraction.ItemKind, StringComparison.Ordinal)) + return; + var cellSize = Math.Max(1f, ParseNumber(parent.GetProp("cellSize"), 72f)); + var gap = Math.Max(0f, ParseNumber(parent.GetProp("cellGap"), 4f)); + var widthCells = Math.Max(1, ParseInt(child.GetProp("shapeWidth"), 1)); + var heightCells = Math.Max(1, ParseInt(child.GetProp("shapeHeight"), 1)); + var rotation = child.GetProp("rotation"); + if (string.Equals(rotation, "Clockwise", StringComparison.OrdinalIgnoreCase) || + string.Equals(rotation, "CounterClockwise", StringComparison.OrdinalIgnoreCase) || + string.Equals(rotation, "Right", StringComparison.OrdinalIgnoreCase) || + string.Equals(rotation, "Left", StringComparison.OrdinalIgnoreCase) || + string.Equals(rotation, "Rotate90", StringComparison.OrdinalIgnoreCase) || + string.Equals(rotation, "Rotate270", StringComparison.OrdinalIgnoreCase)) + { + (widthCells, heightCells) = (heightCells, widthCells); + } + element.style.position = Position.Absolute; + element.style.left = Math.Max(0, ParseInt(child.GetProp("x"), 0)) * (cellSize + gap); + element.style.top = Math.Max(0, ParseInt(child.GetProp("y"), 0)) * (cellSize + gap); + element.style.width = widthCells * cellSize + Math.Max(0, widthCells - 1) * gap; + element.style.height = heightCells * cellSize + Math.Max(0, heightCells - 1) * gap; + } + + private static VisualElement? InventoryGridAncestor(VisualElement? element) + { + while (element != null) + { + if (element.userData is EveSurfaceComponent component && + string.Equals(component.Kind, EveInventoryInteraction.GridKind, StringComparison.Ordinal)) + return element; + element = element.parent; + } + return null; + } + + private static bool TryEmitInventoryDrop( + EveSurfaceDocument document, + EveSurfaceComponent source, + EveSurfaceComponent target, + VisualElement targetElement, + Vector2 panelPosition, + Action? commandSink) + { + if (commandSink == null) + return false; + var local = targetElement.WorldToLocal(panelPosition); + var pitch = Math.Max(1f, ParseNumber(target.GetProp("cellSize"), 72f) + + Math.Max(0f, ParseNumber(target.GetProp("cellGap"), 4f))); + var x = Math.Max(0, (int)Math.Floor(local.x / pitch)); + var y = Math.Max(0, (int)Math.Floor(local.y / pitch)); + if (!EveInventoryInteraction.TryCreateDropRequest( + document, source, target, x, y, "unity-uitoolkit", out var request) || request == null) + return false; + commandSink(request); + return true; + } + + private void ShowInventoryPreview( + EveSurfaceComponent source, + EveSurfaceComponent target, + VisualElement targetElement, + Vector2 panelPosition) + { + ClearInventoryPreview(); + var local = targetElement.WorldToLocal(panelPosition); + var cellSize = Math.Max(1f, ParseNumber(target.GetProp("cellSize"), 72f)); + var gap = Math.Max(0f, ParseNumber(target.GetProp("cellGap"), 4f)); + var pitch = cellSize + gap; + var x = Math.Max(0, (int)Math.Floor(local.x / Math.Max(1f, pitch))); + var y = Math.Max(0, (int)Math.Floor(local.y / Math.Max(1f, pitch))); + if (!EveInventoryInteraction.TryCreatePlacementPreview(source, target, x, y, out var preview) || + preview == null) + return; + + foreach (var cell in preview.Cells) + { + var element = new VisualElement { pickingMode = PickingMode.Ignore }; + element.AddToClassList("eve-inventory-placement-preview"); + element.AddToClassList(preview.IsValid + ? "eve-inventory-placement-valid" + : "eve-inventory-placement-invalid"); + element.style.position = Position.Absolute; + element.style.left = cell.X * pitch; + element.style.top = cell.Y * pitch; + element.style.width = cellSize; + element.style.height = cellSize; + element.style.backgroundColor = preview.IsValid + ? new Color(0.2f, 0.9f, 0.65f, 0.34f) + : new Color(1f, 0.25f, 0.2f, 0.38f); + targetElement.Add(element); + _inventoryPreviewCells.Add(element); + } + } + + private void ClearInventoryPreview() + { + foreach (var cell in _inventoryPreviewCells) + cell.RemoveFromHierarchy(); + _inventoryPreviewCells.Clear(); + } + + private static int ParseInt(string value, int fallback) => + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback; + + private static bool ParseBool(string value) => + bool.TryParse(value, out var parsed) && parsed; + + private static float ParseNumber(string value, float fallback) => + float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback; + private static void EmitCommand( EveSurfaceDocument document, EveSurfaceComponent component, @@ -247,10 +514,62 @@ namespace GameCult.Eve.UnityUIToolkit foreach (var template in document.Commands) { if (string.Equals(template.Command, command, StringComparison.Ordinal)) - return GameCult.Mesh.CultMesh.OperationInvocation(template.Operation); + return GameCult.Mesh.CultMesh.OperationInvocation( + template.Operation, + idempotencyKey: $"unity-uitoolkit-{Guid.NewGuid():N}"); } - return GameCult.Mesh.CultMesh.OperationInvocation(command); + return GameCult.Mesh.CultMesh.OperationInvocation( + command, + idempotencyKey: $"unity-uitoolkit-{Guid.NewGuid():N}"); + } + + private static VisualElement Select( + EveSurfaceComponent component, + EveSurfaceDocument document, + Action? commandSink) + { + var options = component.Children + .Where(child => NormalizeKind(child.Kind) == "control.option") + .Select(child => new + { + Label = child.GetProp("label", child.GetProp("value")), + Value = child.GetProp("value") + }) + .Where(option => !string.IsNullOrWhiteSpace(option.Value)) + .ToArray(); + var duplicateLabels = options + .GroupBy(option => option.Label, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToHashSet(StringComparer.Ordinal); + var labels = options + .Select(option => duplicateLabels.Contains(option.Label) + ? $"{option.Label} [{option.Value}]" + : option.Label) + .ToList(); + var selectedValue = component.GetProp("value"); + var selectedIndex = Array.FindIndex(options, option => + string.Equals(option.Value, selectedValue, StringComparison.Ordinal)); + var field = new DropdownField(component.GetProp("label"), labels, Math.Max(0, selectedIndex)); + field.RegisterValueChangedCallback(change => + { + var index = field.index; + if (index < 0 || index >= options.Length) + return; + var option = options[index]; + var payload = new Dictionary(component.Props, StringComparer.Ordinal) + { + ["value"] = option.Value + }; + EmitCommand( + document, + component, + component.GetProp("command", component.GetProp("operationId")), + GameCult.Mesh.CultMesh.OperationPayload(payload), + commandSink); + }); + return field; } private static Label TitleLabel(string text) @@ -313,6 +632,26 @@ namespace GameCult.Eve.UnityUIToolkit element.style.display = string.Equals(display, "none", StringComparison.OrdinalIgnoreCase) ? DisplayStyle.None : DisplayStyle.Flex; + if (TryGet(layout, "position", out var position)) + element.style.position = string.Equals(position, "absolute", StringComparison.OrdinalIgnoreCase) + ? Position.Absolute + : Position.Relative; + if (TryGet(layout, "inset", out var inset)) + { + var value = ParseLength(inset); + element.style.top = value; + element.style.right = value; + element.style.bottom = value; + element.style.left = value; + } + if (TryGet(layout, "top", out var top)) + element.style.top = ParseLength(top); + if (TryGet(layout, "right", out var right)) + element.style.right = ParseLength(right); + if (TryGet(layout, "bottom", out var bottom)) + element.style.bottom = ParseLength(bottom); + if (TryGet(layout, "left", out var left)) + element.style.left = ParseLength(left); if (TryGet(layout, "direction", out var direction)) { if (string.Equals(direction, "horizontal", StringComparison.OrdinalIgnoreCase) || @@ -328,6 +667,12 @@ namespace GameCult.Eve.UnityUIToolkit element.style.alignItems = align; if (TryGet(layout, "justifyContent", out var justifyContent) && TryParseJustify(justifyContent, out var justify)) element.style.justifyContent = justify; + if (TryGet(layout, "flexGrow", out var flexGrow)) + element.style.flexGrow = ParseFloat(flexGrow); + if (TryGet(layout, "flexShrink", out var flexShrink)) + element.style.flexShrink = ParseFloat(flexShrink); + if (TryGet(layout, "flexBasis", out var flexBasis)) + element.style.flexBasis = ParseLength(flexBasis); if (TryGet(layout, "width", out var width)) element.style.width = ParseLength(width); if (TryGet(layout, "minWidth", out var minWidth)) @@ -404,6 +749,14 @@ namespace GameCult.Eve.UnityUIToolkit return false; } + private static bool IsEnabled(EveSurfaceComponent component) + { + if (ParseBool(component.GetProp("disabled"))) + return false; + var enabled = component.GetProp("enabled"); + return string.IsNullOrWhiteSpace(enabled) || ParseBool(enabled); + } + private static StyleLength ParseLength(string value) { value = FirstToken(value); @@ -430,6 +783,13 @@ namespace GameCult.Eve.UnityUIToolkit : StyleKeyword.Null; } + private static float ParseRatio(string value) + { + return float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var ratio) + ? Mathf.Clamp01(ratio) + : 0f; + } + private static void ApplyBoxLength(string value, Action apply) { apply(ParseLength(value)); @@ -524,6 +884,21 @@ namespace GameCult.Eve.UnityUIToolkit return kind; } + internal static bool IsHidden(EveSurfaceComponent component) => + component.Layout != null && + component.Layout.TryGetValue("display", out var display) && + string.Equals(display, "none", StringComparison.OrdinalIgnoreCase); + + internal static bool IsExternalProjectionRoot(string kind) + { + kind = NormalizeKind(kind); + return string.Equals(kind, "world.scene3d", StringComparison.Ordinal) || + string.Equals(kind, "world.scene2d", StringComparison.Ordinal) || + string.Equals(kind, "field.volume3d", StringComparison.Ordinal) || + string.Equals(kind, "field.particles3d", StringComparison.Ordinal) || + string.Equals(kind, "layer.reactive", StringComparison.Ordinal); + } + private static string SafeName(string value) { return string.IsNullOrWhiteSpace(value) ? "eve.component" : value; diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfacePresenter.cs b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfacePresenter.cs new file mode 100644 index 0000000..812ef72 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfacePresenter.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using GameCult.Eve.Surface; +using UnityEngine.UIElements; + +#nullable enable + +namespace GameCult.Eve.UnityUIToolkit +{ + public sealed class EveUiToolkitSurfacePresenter + { + private readonly EveUiToolkitSurfaceLowerer _lowerer; + private string _structure = ""; + + public EveUiToolkitSurfacePresenter(EveUiToolkitSurfaceOptions? options = null) + { + _lowerer = new EveUiToolkitSurfaceLowerer(options); + } + + public VisualElement? Root { get; private set; } + + public bool Present( + EveSurfaceDocument document, + Action? commandSink = null) + { + if (document == null) throw new ArgumentNullException(nameof(document)); + var structure = StructureOf(document); + if (Root == null || !string.Equals(_structure, structure, StringComparison.Ordinal)) + { + Root = _lowerer.Lower(document, commandSink); + _structure = structure; + return true; + } + + var elements = Root.Query() + .ToList() + .Where(element => element.userData is EveSurfaceComponent) + .ToDictionary(element => ((EveSurfaceComponent)element.userData).Id, StringComparer.Ordinal); + if (Root.userData is EveSurfaceComponent rootComponent) + elements[rootComponent.Id] = Root; + ApplyDynamic(document.Surface.Root, elements); + return false; + } + + private static void ApplyDynamic( + EveSurfaceComponent component, + IReadOnlyDictionary elements) + { + if (!elements.TryGetValue(component.Id, out var element)) + return; + element.userData = component; + switch (NormalizeKind(component.Kind)) + { + case "pane": + case "modal": + case "card": + var title = element.Q