GGua REFERENCE
DOCS / GUA.RUNTIME

Gua.Runtime implementation guide

Connect a managed engine adapter to Gua semantic frames, actions, screenshots, and the Inspector bridge.

Concrete use cases

SituationGua.Runtime providesYour adapter provides
Support another game engineManaged C ABI wrapper, request queues, versions, bridgeUI enumeration, state conversion, and input delivery
Support a custom UI frameworkSemantic node registration and correlated action resultsWidget-to-role mapping and real widget operations
Expose an internal toolkit to Inspector/MCPWebSocket bridge, UI-tree JSON, logs, screenshot requestsRendering-loop and capture integration
Inspector / MCP / Testsobserve and request↔native gua_runtimestate, queues, bridge↔Gua.Runtimemanaged API↔Custom adapterengine translation↔Game UIreal widgets
Figure 1: Gua.Runtime is the managed boundary between the protocol runtime and engine-specific UI APIs.

Install and deploy

Add the package to an adapter projectpowershell
dotnet add package Gua.Runtime

Align the game integration and related Gua package versions. See version management for recording and pinning the result.

The package targets net10.0 and netstandard2.1. Use matching native libraries for the target OS/CPU: gua.dll/gua_runtime.dll on Windows, libgua.so/libgua_runtime.so on Linux, or libgua.dylib/libgua_runtime.dylib on macOS. Missing or mismatched libraries produce an explicit load error in the constructor.

One adapter frame

BeginFramelogical screen→RegisterNode × Ncurrent UI state→EndFramecommit snapshot→Consume actionsdeliver to engine→Emit resultcorrelate requestId
Figure 2: Publish observed state each frame, then consume requests and report what the host actually did.
Minimal adapter loopcsharp
using System;
using System.Collections.Generic;
using Gua.Core;
using Gua.Runtime;

public sealed class CustomUiGuaAdapter(ICustomUi ui) : IDisposable
{
    // Keep this at least as long as the largest remote action timeout.
    private static readonly TimeSpan StaleIdRetention = TimeSpan.FromSeconds(30);
    private readonly GuaRuntime runtime = new();
    private readonly Dictionary<string, DateTimeOffset> retainedNodeIds =
        new(StringComparer.Ordinal);

    public void Start()
    {
        runtime.SetAdapterVersion("custom_ui", "1.0.0");
        if (!runtime.StartInspectorBridge(GetBridgePort()))
            throw new InvalidOperationException("Gua bridge could not start.");
    }

    private static int GetBridgePort()
    {
        const int defaultPort = 8765;
        var value = Environment.GetEnvironmentVariable("GUA_BRIDGE_PORT");
        if (string.IsNullOrWhiteSpace(value))
            return defaultPort;
        if (!int.TryParse(value, out var port) || port is < 1 or > 65535)
            throw new InvalidOperationException("GUA_BRIDGE_PORT must be between 1 and 65535.");
        return port;
    }

    public void Tick()
    {
        var retainUntil = DateTimeOffset.UtcNow + StaleIdRetention;
        runtime.BeginFrame(ui.ScreenName);
        foreach (var control in ui.Controls)
        {
            retainedNodeIds[control.Id] = retainUntil;
            runtime.RegisterNode(new GuaNodeDescriptor(
                control.Id, control.Role, control.Label,
                new GuaBounds(control.X, control.Y, control.Width, control.Height),
                Visible: control.Visible, Enabled: control.Enabled,
                ParentId: control.ParentId,
                Text: control.Sensitive ? null : control.Text,
                Value: control.Sensitive ? null : control.Value,
                Focused: control.Focused,
                Checked: control.Checked));
        }
        runtime.EndFrame();

        foreach (var nodeId in retainedNodeIds.Keys)
            DrainActions(nodeId);
        DrainActions(null); // global press_key requests

        var now = DateTimeOffset.UtcNow;
        var expiredNodeIds = new List<string>();
        foreach (var pair in retainedNodeIds)
            if (pair.Value <= now)
                expiredNodeIds.Add(pair.Key);
        foreach (var expired in expiredNodeIds)
            retainedNodeIds.Remove(expired);
    }

    private void DrainActions(string? nodeId)
    {
        foreach (GuaActionType action in Enum.GetValues(typeof(GuaActionType)))
        {
            while (runtime.TryConsumeAction(action, nodeId, out var request))
            {
                if (request.Action == GuaActionType.Click)
                {
                    var error = TryClick(request.NodeId);
                    runtime.EmitActionResult(request,
                        error == GuaActionError.None, error);
                }
                else
                {
                    runtime.EmitActionResult(request, false, GuaActionError.Unsupported);
                }
            }
        }
    }

    private GuaActionError TryClick(string? nodeId)
    {
        if (nodeId is null || !ui.TryGetControl(nodeId, out var control))
            return GuaActionError.NodeNotFound;
        if (!control.Visible)
            return GuaActionError.Hidden;
        if (!control.Enabled)
            return GuaActionError.Disabled;
        return ui.TryClick(nodeId)
            ? GuaActionError.None
            : GuaActionError.Unsupported;
    }

    public void Dispose() => runtime.Dispose();
}

GetBridgePort honors the port allocated by an external test host through GUA_BRIDGE_PORT, validates its range, and falls back to 8765 only when no override is supplied. ICustomUi is a placeholder for the host-specific API. Its Sensitive flag must identify passwords, tokens, and similar controls before registration; omit both Text and Value so secrets never enter snapshots, Inspector/MCP responses, or retained diagnostics. Keep IDs stable across frames and express bounds as physical viewport pixels with a top-left origin.

Complete actions correctly

TryConsumeAction matches both action type and node ID. A request based on the preceding snapshot may arrive after the first frame where its control is absent, so one drain pass is not enough. Retain each last-seen ID for a bounded staleness window, continue draining it during that window, then remove it. Configure StaleIdRetention to be at least the longest remote action timeout plus transport margin; the example uses 30 seconds. This bounds dynamic-screen memory and per-frame work by the recent ID rate without abandoning callers that are still allowed to submit. Drain the empty ID separately for global key requests.

Host outcomeErrorExample
Target disappearedNodeNotFoundScene changed before processing
Hidden or disabledHidden / DisabledHost state changed after the snapshot
Unsupported operationUnsupportedClicking a read-only text node
Invalid host valueInvalidValueNon-numeric slider value

Complete screenshot requests

Capture after renderingcsharp
if (runtime.TryConsumeScreenshotRequest(out var request))
{
    if (ui.IsHeadless)
        runtime.CompleteScreenshot(request, GuaScreenshotAvailability.Headless);
    else if (ui.IsRenderingDisabled)
        runtime.CompleteScreenshot(request, GuaScreenshotAvailability.RenderingDisabled);
    else
    {
        var png = ui.CapturePngAfterDraw();
        var dataUri = "data:image/png;base64," + Convert.ToBase64String(png.Bytes);
        runtime.CompleteScreenshot(request, GuaScreenshotAvailability.Available,
            dataUri, png.Width, png.Height);
    }
}

Check headless and rendering-disabled states separately before attempting PNG capture. CompleteScreenshot is the documented completion API. Call it once for every consumed request. If timeout, cancellation, or context reset already invalidated the request, the runtime ignores the late completion; do not retry it.

Adapter checklist

  • Publish a stable lowercase adapter name and version.
  • Keep IDs, roles, parents, states, and supported actions consistent.
  • Use the host's real input/listener path and always emit success or failure.
  • Never copy sensitive values into logs, diagnostics, or completion events.
  • Distinguish headless, rendering-disabled, and stale screenshot outcomes.
  • Dispose the runtime to release the bridge and native handle.

Register typed Observe values

Use Gua.Runtime for registration and Gua.Core for Values and enum catalogs. Register only on an already published UI/Object owner, keep registration tokens alive with the game object, and dispose them when it is removed. Player publication is explicitly opted in and remains subject to host projection.

Register an enum on a published World objectcsharp
using Gua.Core;
using Gua.Runtime;

// Publish the World owner "enemy-1" before registering its values.
using var catalog = new GuaEnumCatalog();
catalog.Register("game.Phase", "First", "Second");
using var owner = runtime.CreateObserveOwner(GuaObserveSource.Object, "enemy-1");
using var phase = owner.Observe("phase",
    () => GuaValue.Enum("game.Phase", enemy.Phase, catalog), allowPlayer: true);
// Keep owner, phase, and catalog alive with the game object.
// Normal World publication samples the getter; phase.Notify() is explicit notification.
Read from an external testcsharp
// remote is a connected Gua.Testing.GuaWebSocketContext.
var snapshot = remote.GetObserveSnapshotJson();
using var subscription = remote.SubscribeObservations();
var initial = subscription.SnapshotJson;
var changes = subscription.PollJson();

The external APIs belong to Gua.Testing and require observe_v1. Dispose the subscription when finished. Reconnect or a gap requires a new snapshot/subscription. Observe engine examples and lifecycle · Value types.

Bind an authorized physics provider

GuaRuntimeSpatialClient is the local Gua.Runtime client. Bind a provider and explicitly authorize the Testing/Debug spatial lane before queries; selecting Debug alone does not enable it. External Gua.Testing.GuaWebSocketContext callers use GetSpatialInfo/QuerySpatialBatch. Reset, provider/scene changes, and revoked authorization invalidate query state. Provider setup and query limits · Physics queries versus semantic radius searches.