Add Unity Sai plugin host proof

This commit is contained in:
Meta Vens 2026-07-09 00:34:47 +01:00
commit 84fd4933aa
12 changed files with 304 additions and 20 deletions

View file

@ -274,11 +274,19 @@ Exit criteria:
## Active Work Queue
1. Add a real Unity plugin-host proof for either Sai or Norn, backed by a
runtime-owned capability manifest and Unity test/capture lifecycle evidence.
1. Add a Unity Norn graph plugin-host proof for `embed.norn`.
2. Move EveUnity toward split readiness with runtime-owned UPM release,
Unity test, and capture lifecycle evidence.
Recently cut:
- Unity UI Toolkit now has a first-party `sai.vn` plugin-host proof. The
runtime owns `IEveUiToolkitPluginHost`, registers
`SaiVisualNovelUiToolkitPluginHost` through `EveUiToolkitSurfaceOptions`, and
declares support for `vn.stage`, `story.choose`, `story.continue`, and
`story.jump` in its runtime capability manifest. Sai still owns story state
and command semantics; Unity only lowers the visual stage/dialogue/action
surface and emits Eve command requests.
- Unity UI Toolkit now publishes a runtime-owned capability manifest at
`packages/org.gamecult.eve.unity-uitoolkit/eve-runtime-capability.json`. The
parity harness validates that manifest against

View file

@ -26,6 +26,10 @@ transport schema, and incubation metadata. The central parity manifest may
coordinate split-readiness, but the runtime body must publish the capability
claim it expects other repos to consume.
Unity UI Toolkit currently uses this path for its first `sai.vn` plugin-host
proof while leaving `norn.graph` and `tex.math` as explicit unsupported plugin
semantics.
## Run
Semantic checks:

View file

@ -7,11 +7,12 @@ It owns native projection only. Providers still own truth, accepted state,
style token values, and command effects through CultMesh/CultNet. Unknown
component kinds degrade to inert containers instead of gaining local semantics.
Plugin semantics are not hosted here yet. `sai.vn`, `norn.graph`, and
`tex.math` surfaces may pass through as generic Eve component structure, but
Unity UI Toolkit does not currently own the Sai VN stage model, Norn graph
layout, or TeX render pipeline. Those remain plugin-owned semantics until an
explicit Unity plugin host exists.
Plugin semantics enter through runtime plugin hosts. The package includes a
first-party `sai.vn` host proof for `vn.stage`, dialogue panels, action rails,
and story command projection. Sai still owns story state and command semantics;
Unity only owns native projection and emits `gamecult.eve.command.v1` requests.
`norn.graph` and `tex.math` still pass through as generic Eve component
structure until they gain explicit Unity plugin hosts.
This package lives in the Eve repository as the shared Unity lowering target.
Aetheria and other Unity consumers should import it from Eve instead of

View file

@ -76,6 +76,10 @@ namespace GameCult.Eve.UnityUIToolkit
EveSurfaceDocument document,
Action<EveSurfaceCommandRequest>? commandSink)
{
var pluginHost = _options.FindPluginHost(component);
if (pluginHost != null)
return pluginHost.Lower(component, document, commandSink);
switch (NormalizeKind(component.Kind))
{
case "surface":

View file

@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Eve.Surface;
#nullable enable
@ -10,11 +12,23 @@ namespace GameCult.Eve.UnityUIToolkit
public static EveUiToolkitSurfaceOptions Default { get; } = new EveUiToolkitSurfaceOptions();
public EveUiToolkitSurfaceOptions(
Func<EveEmbeddedDocumentSlot, EveSurfaceDocument?>? embeddedDocumentResolver = null)
Func<EveEmbeddedDocumentSlot, EveSurfaceDocument?>? embeddedDocumentResolver = null,
IReadOnlyList<IEveUiToolkitPluginHost>? pluginHosts = null)
{
EmbeddedDocumentResolver = embeddedDocumentResolver;
PluginHosts = pluginHosts ?? new IEveUiToolkitPluginHost[]
{
new SaiVisualNovelUiToolkitPluginHost()
};
}
public Func<EveEmbeddedDocumentSlot, EveSurfaceDocument?>? EmbeddedDocumentResolver { get; }
public IReadOnlyList<IEveUiToolkitPluginHost> PluginHosts { get; }
public IEveUiToolkitPluginHost? FindPluginHost(EveSurfaceComponent component)
{
return PluginHosts.FirstOrDefault(host => host.CanLower(component));
}
}
}

View file

@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using GameCult.Eve.Surface;
using UnityEngine.UIElements;
#nullable enable
namespace GameCult.Eve.UnityUIToolkit
{
public interface IEveUiToolkitPluginHost
{
string PluginId { get; }
IReadOnlyList<string> Capabilities { get; }
bool CanLower(EveSurfaceComponent component);
VisualElement Lower(
EveSurfaceComponent component,
EveSurfaceDocument document,
Action<EveSurfaceCommandRequest>? commandSink);
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8e93b6d6e2364ef4a91766cbf5a44b62

View file

@ -0,0 +1,205 @@
using System;
using System.Collections.Generic;
using GameCult.Eve.Surface;
using GameCult.Mesh;
using UnityEngine;
using UnityEngine.UIElements;
#nullable enable
namespace GameCult.Eve.UnityUIToolkit
{
public sealed class SaiVisualNovelUiToolkitPluginHost : IEveUiToolkitPluginHost
{
public string PluginId => "sai.vn";
public IReadOnlyList<string> Capabilities { get; } = new[]
{
"vn.stage",
"story.choose",
"story.continue",
"story.jump"
};
public bool CanLower(EveSurfaceComponent component)
{
switch (component.Kind)
{
case "vn.stage":
case "panel.dialogue":
case "text.dialogue":
case "rail.actions":
return true;
case "control.button":
return IsStoryCommand(component);
default:
return false;
}
}
public VisualElement Lower(
EveSurfaceComponent component,
EveSurfaceDocument document,
Action<EveSurfaceCommandRequest>? commandSink)
{
switch (component.Kind)
{
case "vn.stage":
return Stage(component);
case "panel.dialogue":
return DialoguePanel(component);
case "text.dialogue":
return DialogueText(component);
case "rail.actions":
return ActionRail();
case "control.button":
return StoryButton(component, document, commandSink);
default:
return new VisualElement();
}
}
private static VisualElement Stage(EveSurfaceComponent component)
{
var stage = new VisualElement();
stage.AddToClassList("eve-plugin-host");
stage.AddToClassList("eve-plugin-sai-vn");
stage.AddToClassList("sai-vn-stage");
stage.style.flexGrow = 1;
stage.style.flexDirection = FlexDirection.Column;
var storyId = component.GetProp("storyId");
var currentPath = component.GetProp("currentPath");
if (!string.IsNullOrWhiteSpace(storyId) || !string.IsNullOrWhiteSpace(currentPath))
{
var state = new Label(string.IsNullOrWhiteSpace(currentPath)
? storyId
: $"{storyId}:{currentPath}");
state.AddToClassList("sai-vn-state");
state.style.unityFontStyleAndWeight = FontStyle.Bold;
stage.Add(state);
}
return stage;
}
private static VisualElement DialoguePanel(EveSurfaceComponent component)
{
var panel = new VisualElement();
panel.AddToClassList("eve-plugin-host");
panel.AddToClassList("eve-plugin-sai-vn");
panel.AddToClassList("sai-vn-dialogue");
panel.style.flexDirection = FlexDirection.Column;
var speaker = component.GetProp("speaker");
if (!string.IsNullOrWhiteSpace(speaker))
{
var speakerLabel = new Label(speaker);
speakerLabel.AddToClassList("sai-vn-speaker");
speakerLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
panel.Add(speakerLabel);
}
var scene = component.GetProp("scene");
if (!string.IsNullOrWhiteSpace(scene))
{
var sceneLabel = new Label(scene);
sceneLabel.AddToClassList("sai-vn-scene");
panel.Add(sceneLabel);
}
return panel;
}
private static VisualElement DialogueText(EveSurfaceComponent component)
{
var text = new Label(component.GetProp("text"));
text.AddToClassList("eve-plugin-host");
text.AddToClassList("eve-plugin-sai-vn");
text.AddToClassList("sai-vn-dialogue-text");
return text;
}
private static VisualElement ActionRail()
{
var rail = new VisualElement();
rail.AddToClassList("eve-plugin-host");
rail.AddToClassList("eve-plugin-sai-vn");
rail.AddToClassList("sai-vn-actions");
rail.style.flexDirection = FlexDirection.Row;
rail.style.flexWrap = Wrap.Wrap;
return rail;
}
private static Button StoryButton(
EveSurfaceComponent component,
EveSurfaceDocument document,
Action<EveSurfaceCommandRequest>? commandSink)
{
var label = component.GetProp("label", component.GetProp("title", "Continue"));
var command = StoryCommand(component);
var button = new Button(() => EmitStoryCommand(document, component, command, commandSink)) { text = label };
button.AddToClassList("eve-plugin-host");
button.AddToClassList("eve-plugin-sai-vn");
button.AddToClassList("sai-vn-story-command");
button.AddToClassList($"sai-vn-command-{SafeClass(command)}");
return button;
}
private static bool IsStoryCommand(EveSurfaceComponent component)
{
return StoryCommand(component).StartsWith("story.", StringComparison.Ordinal);
}
private static string StoryCommand(EveSurfaceComponent component)
{
return component.GetProp("command", component.GetProp("action", component.GetProp("type")));
}
private static void EmitStoryCommand(
EveSurfaceDocument document,
EveSurfaceComponent component,
string command,
Action<EveSurfaceCommandRequest>? commandSink)
{
if (commandSink == null || string.IsNullOrWhiteSpace(command))
return;
commandSink(new EveSurfaceCommandRequest(
document.ProviderId,
document.Surface.Id,
ResolveOperation(document, command),
CultMesh.OperationPayload(component.Props),
DateTimeOffset.UtcNow,
"unity-uitoolkit"));
}
private static CultMeshOperationInvocationDescriptor ResolveOperation(
EveSurfaceDocument document,
string command)
{
foreach (var template in document.Commands)
{
if (string.Equals(template.Command, command, StringComparison.Ordinal))
return CultMesh.OperationInvocation(template.Operation);
}
return CultMesh.OperationInvocation(command);
}
private static string SafeClass(string value)
{
if (string.IsNullOrWhiteSpace(value))
return "unknown";
var characters = value.ToCharArray();
for (var index = 0; index < characters.Length; index++)
{
if (!char.IsLetterOrDigit(characters[index]))
characters[index] = '-';
}
return new string(characters).Trim('-').ToLowerInvariant();
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4e8f6fc7d7ef4dd7b22ec33d4f81d321

View file

@ -12,12 +12,19 @@
"supportedFeatures": [
"embeddedDocuments"
],
"supportedPlugins": [],
"unsupportedPlugins": [
"supportedPlugins": [
{
"pluginId": "sai.vn",
"reason": "Unity UI Toolkit currently lowers Sai VN component kinds as generic Eve containers; no Sai plugin host exists in this runtime."
},
"capabilities": [
"vn.stage",
"story.choose",
"story.continue",
"story.jump"
],
"host": "SaiVisualNovelUiToolkitPluginHost"
}
],
"unsupportedPlugins": [
{
"pluginId": "norn.graph",
"reason": "Unity UI Toolkit currently preserves embed.norn as generic Eve structure; no Norn graph plugin host or layout adapter exists in this runtime."

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c3f7a6e51d3f4bbd8d892922299df744

View file

@ -429,9 +429,7 @@
"pendingProofs": [
"UPM package and release lifecycle are runtime-owned.",
"Unity test and capture lifecycle are runtime-owned.",
"Plugin host behavior declares reusable Sai and Norn capabilities.",
"Aetheria consumes provider advertisements and surfaces without Eve internals.",
"Conformance pack can be consumed without depending on Eve's local harness layout."
"Norn graph plugin host behavior declares reusable embed.norn capabilities."
]
}
],
@ -687,11 +685,13 @@
"requiredFixtures": ["embedded-surface"],
"pluginFixtures": ["sai-vn"],
"supportedFeatures": ["embeddedDocuments"],
"unsupportedPlugins": [
"supportedPlugins": [
{
"pluginId": "sai.vn",
"reason": "Unity UI Toolkit currently lowers Sai VN component kinds as generic Eve containers; no Sai plugin host exists in this runtime."
},
"capabilities": ["vn.stage", "story.choose", "story.continue", "story.jump"]
}
],
"unsupportedPlugins": [
{
"pluginId": "norn.graph",
"reason": "Unity UI Toolkit currently preserves embed.norn as generic Eve structure; no Norn graph plugin host or layout adapter exists in this runtime."
@ -701,11 +701,23 @@
"reason": "Unity UI Toolkit currently preserves embed.tex as generic Eve structure; no TeX render plugin host exists in this runtime."
}
],
"expectedPaths": ["packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs"],
"expectedPaths": [
"packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs",
"packages/org.gamecult.eve.unity-uitoolkit/Runtime/IEveUiToolkitPluginHost.cs",
"packages/org.gamecult.eve.unity-uitoolkit/Runtime/SaiVisualNovelUiToolkitPluginHost.cs"
],
"expectedSourceSymbols": [
{
"path": "packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs",
"contains": ["LowerEmbeddedDocument(", "EmbeddedDocuments", "eve-embedded-document"]
"contains": ["LowerEmbeddedDocument(", "EmbeddedDocuments", "eve-embedded-document", "FindPluginHost", "pluginHost.Lower"]
},
{
"path": "packages/org.gamecult.eve.unity-uitoolkit/Runtime/IEveUiToolkitPluginHost.cs",
"contains": ["PluginId", "Capabilities", "CanLower", "Lower("]
},
{
"path": "packages/org.gamecult.eve.unity-uitoolkit/Runtime/SaiVisualNovelUiToolkitPluginHost.cs",
"contains": ["sai.vn", "vn.stage", "story.choose", "story.continue", "story.jump", "StoryButton", "EmitStoryCommand", "EveSurfaceCommandRequest", "sai-vn-stage", "sai-vn-dialogue", "sai-vn-actions"]
}
],
"commandTransportSmoke": {