diff --git a/AGENTS.md b/AGENTS.md
index 389670e..2bd9ce5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,11 +1,11 @@
# Gjallar Agent Instructions
-Gjallar is a framebuffer-native, multi-scale TUI compositor.
+Gjallar is the renderer-neutral compositor for Odin-visible Eve surfaces.
Do not treat this repo as an Odin subfolder, a Nightwing deployment script, a
-terminal multiplexer clone, or a dashboard skin. Gjallar owns the product kernel
-for weighted pane partitioning, per-pane virtual text grids, font-scale
-selection, framebuffer lowering, and future sparse cell/glyph/dirty-rect pipes.
+terminal multiplexer clone, or a dashboard skin. Gjallar owns aggregation,
+tiling, and layout intent. Eve clients and optional TUI/framebuffer backends own
+pixel lowering.
## Session Bootstrap
@@ -21,14 +21,16 @@ Then restate the current owner map before making nontrivial changes.
- Odin sees: Verse discovery, provider cataloging, schemas, routes, and accepted
provider/proxy surfaces.
-- Gjallar shows: overview composition, panel packing, density, text resolution,
- framebuffer/cell lowering, and display telemetry.
-- Nightwing hosts: the physical machine/body currently running `gjallar.service`.
+- Gjallar composes: overview membership, tiling, density, layout intent, and
+ aggregate-surface publication.
+- Yggdrasil hosts: the headless daemon beside Odin and Idunn.
+- Eve clients lower: UIKit, browser, TUI, framebuffer, and future display bodies.
- Providers speak: each upstream surface owns its own truth.
-If Odin starts deciding where panels go on Nightwing, ownership leaked upward.
-If providers start shaping themselves for Nightwing instead of publishing clean
-surfaces, ownership leaked downward.
+If Odin starts deciding where panels go, ownership leaked upward. If Gjallar
+opens a framebuffer in daemon mode, ownership leaked downward into a client
+body. If providers tune themselves for a renderer instead of publishing clean
+surfaces, ownership leaked into the provider.
## Research Habit
diff --git a/README.md b/README.md
index 758f1f1..279a7e0 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Gjallar
-Gjallar is a framebuffer-native, multi-scale TUI compositor.
+Gjallar is the renderer-neutral compositor for Odin-visible Eve surfaces.
Most terminal UI systems assume one cell grid. Most dashboards assume a browser.
Gjallar starts from a different premise: a live machine room deserves a surface
@@ -31,9 +31,9 @@ operator signal, all on the same physical display.
## Why It Exists
-The immediate body is Nightwing: a local machine with an attached framebuffer
-display. The current feed comes from Odin's provider catalog. Odin discovers
-what surfaces exist; Gjallar decides how to show them.
+The daemon body is Yggdrasil beside Odin and Idunn. Odin discovers and accepts
+provider surfaces; Gjallar composes every visible surface into one tiled
+`gjallar.overview` Eve document. Eve clients decide how to draw that document.
The product is broader than that first deployment:
@@ -62,15 +62,15 @@ Publish a self-contained Linux build:
dotnet publish .\src\Gjallar\Gjallar.csproj -c Release -r linux-x64 --self-contained true -o .\scratch\publish\gjallar
```
-Run against Odin's provider catalog:
+Run the Yggdrasil composition daemon against Odin's local snapshot endpoint:
```bash
-/opt/gamecult/gjallar/Gjallar \
- --fb /dev/fb0 \
- --odin-cultnet-rudp 192.168.1.66:17871 \
- --cultcache-path /var/lib/gjallar/gjallar.service.cc \
- --refresh-hz 60 \
- --stats-path /var/log/gjallar.status
+/srv/gjallar/current/Gjallar \
+ --headless \
+ --odin-cultnet-rudp 127.0.0.1:17871 \
+ --odin-cultmesh-rudp 127.0.0.1:17871 \
+ --cultcache-path /var/lib/gamecult/gjallar/gjallar.service.cc \
+ --stats-path /var/lib/gamecult/gjallar/status.json
```
In native CultNet/RUDP snapshot mode, Gjallar:
@@ -79,23 +79,26 @@ In native CultNet/RUDP snapshot mode, Gjallar:
once to Odin's CultMesh/RUDP document ingress;
2. requests Odin's accepted provider-state snapshot over `cultnet.transport.rudp.v0`;
3. extracts provider-owned surface roots from Odin's accepted state;
-4. builds an in-memory `gjallar.overview` surface;
-5. packs panels into weighted pixel regions;
-6. chooses a font/cell resolution for each region;
-7. draws text, panels, gutters, and marquee;
-8. writes one BGRA frame to the framebuffer.
+4. builds the typed `gjallar.overview` surface with Eve's shared contract;
+5. persists and republishes that aggregate through CultCache/CultMesh/CultNet;
+6. emits composition freshness health to Idunn.
+
+The optional framebuffer backend remains a lowering/debug body. It is not
+started by the Yggdrasil daemon and must never be required for aggregation.
`--odin-cultmesh-rudp` may be used when Odin's document ingress differs from
the snapshot endpoint. By default it reuses `--odin-cultnet-rudp`.
-The older `--url ws://.../eve/deck` path remains as a compatibility fallback.
+The older `--url ws://.../eve/deck` path remains only for local compatibility
+testing and is not configured in the Yggdrasil service.
## Architecture
Current internal organs:
- `GjallarConfig`: runtime flags and display source selection.
-- `GjallarRenderer`: receive loop, catalog composition, frame loop, telemetry.
+- `GjallarRenderer`: receive loop and aggregate composition; framebuffer work
+ is conditional on the explicit non-headless backend.
- `AabbPacker`: weighted region partitioning.
- `EveNode`: retained surface tree projection.
- `FrameDocument`: framebuffer drawing command surface.
@@ -137,26 +140,21 @@ Odin owns Verse discovery, provider cataloging, schemas, routes, and accepted
provider/proxy surfaces. Gjallar consumes those surfaces and owns composition,
density, tiling, font scale, update cadence, and display lowering.
-If Odin decides where a panel goes on Nightwing, ownership leaked upward. If a
-provider starts shaping itself for Nightwing instead of publishing a clean
-surface, ownership leaked downward.
+If Odin decides where a panel goes, ownership leaked upward. If a provider
+starts shaping itself for one client instead of publishing a clean surface,
+ownership leaked downward.
## Status
-Early but live. Gjallar is already deployed on Nightwing as `gjallar.service`
-and writes status telemetry such as:
+The Yggdrasil deployment body is defined under `deploy/` and `systemd/`.
+Headless status explicitly reports framebuffer disablement:
```json
{
"schema": "gamecult.gjallar.frame.v1",
- "receive": {
- "status": "catalog-composed",
- "catalogProviders": 4,
- "composedProviders": 3
- },
- "scene": {
- "panels": 3
- }
+ "mode": "yggdrasil-composition-daemon",
+ "presentMode": "typed-eve-surface-publication",
+ "framebuffer": { "enabled": false }
}
```
diff --git a/deploy/bootstrap-yggdrasil.sh b/deploy/bootstrap-yggdrasil.sh
new file mode 100644
index 0000000..c6250d2
--- /dev/null
+++ b/deploy/bootstrap-yggdrasil.sh
@@ -0,0 +1,32 @@
+#!/bin/sh
+set -eu
+
+[ "$(id -u)" -eq 0 ] || { echo "Gjallar bootstrap requires root." >&2; exit 1; }
+id idunn >/dev/null 2>&1 || { echo "Idunn must be installed first." >&2; exit 1; }
+id gjallar >/dev/null 2>&1 || useradd --system --home-dir /var/lib/gamecult/gjallar --create-home --shell /usr/sbin/nologin gjallar
+
+clone_for_idunn() {
+ name="$1"
+ origin="$2"
+ path="/srv/build/$name"
+ if [ ! -d "$path/.git" ]; then
+ install -d -o idunn -g idunn -m 0755 "$path"
+ rmdir "$path"
+ sudo -H -u idunn git clone --filter=blob:none --no-checkout "$origin" "$path"
+ fi
+ [ "$(sudo -H -u idunn git -C "$path" remote get-url origin)" = "$origin" ] || { echo "$path has a foreign origin." >&2; exit 1; }
+ [ -z "$(find "$path" -xdev ! -user idunn -print -quit)" ] || { echo "$path has foreign ownership." >&2; exit 1; }
+}
+
+clone_for_idunn Gjallar https://github.com/GameCult/Gjallar.git
+clone_for_idunn CultMath https://github.com/GameCult/CultMath.git
+clone_for_idunn CultLib https://github.com/GameCult/CultLib.git
+clone_for_idunn Eve https://github.com/GameCult/Eve.git
+
+install -d -o root -g root -m 0755 /srv/odin/deploy-manifests /srv/gjallar/releases /srv/gjallar/deploy
+install -d -o gjallar -g gjallar -m 0750 /var/lib/gamecult/gjallar
+install -o root -g root -m 0755 "$(dirname "$0")/deploy-yggdrasil.sh" /srv/odin/deploy-manifests/gjallar
+install -o root -g root -m 0644 "$(dirname "$0")/../systemd/gjallar-yggdrasil.service" /etc/systemd/system/gjallar-yggdrasil.service
+systemctl daemon-reload
+systemctl disable --now gjallar-yggdrasil.service 2>/dev/null || true
+echo "Gjallar Yggdrasil body admitted but stopped. Idunn owns first deployment and continuity."
diff --git a/deploy/cultlib.commit b/deploy/cultlib.commit
new file mode 100644
index 0000000..c7c5a16
--- /dev/null
+++ b/deploy/cultlib.commit
@@ -0,0 +1 @@
+75c180782aeba7cfd22d6412877397708a4ed28f
diff --git a/deploy/cultmath.commit b/deploy/cultmath.commit
new file mode 100644
index 0000000..b18a865
--- /dev/null
+++ b/deploy/cultmath.commit
@@ -0,0 +1 @@
+fcdddda72f6aa33ae6eeff3bce6a2d3e26eb16f3
diff --git a/deploy/deploy-yggdrasil.sh b/deploy/deploy-yggdrasil.sh
new file mode 100644
index 0000000..2bcf5b4
--- /dev/null
+++ b/deploy/deploy-yggdrasil.sh
@@ -0,0 +1,92 @@
+#!/bin/sh
+set -eu
+
+[ "${IDUNN_ACTUATOR:-}" = 1 ] || { echo "Gjallar deployment belongs to Idunn." >&2; exit 77; }
+[ "${IDUNN_COMMAND_AUTHORITY:-}" = idunn-daemon ] || { echo "Missing Idunn command authority." >&2; exit 77; }
+
+source_commit="${IDUNN_SOURCE_COMMIT:-}"
+request_id="${IDUNN_DEPLOYMENT_REQUEST_ID:-}"
+case "$source_commit" in *[!0-9a-f]*|'') echo "Invalid Gjallar source commit." >&2; exit 1;; esac
+[ "${#source_commit}" -eq 40 ] || { echo "Gjallar source commit must be 40 hex characters." >&2; exit 1; }
+[ -n "$request_id" ] || { echo "Missing Idunn deployment request id." >&2; exit 1; }
+case "$request_id" in *[!A-Za-z0-9._-]*) echo "Invalid Idunn deployment request id." >&2; exit 1;; esac
+
+sdk_image="mcr.microsoft.com/dotnet/sdk@sha256:0e53453ccfc8ff2d51319fe80c678971c6d0f8008dff3565fa88e15840b69854"
+build_root="/srv/gjallar/build/$request_id"
+workspace="$build_root/workspace"
+publish="$build_root/publish"
+release="/srv/gjallar/releases/$source_commit"
+current="/srv/gjallar/current"
+state_root="/var/lib/gamecult/gjallar"
+
+cleanup() { rm -rf -- "$build_root"; }
+trap cleanup EXIT
+[ ! -e "$build_root" ] || { echo "Gjallar build attempt already exists: $build_root" >&2; exit 1; }
+install -d -o root -g root -m 0755 "$workspace" "$publish"
+
+archive_repo() {
+ name="$1"
+ commit="$2"
+ repo="/srv/build/$name"
+ [ -d "$repo/.git" ] || { echo "Missing Idunn build mirror: $repo" >&2; exit 1; }
+ sudo -H -u idunn git -C "$repo" fetch --prune origin
+ sudo -H -u idunn git -C "$repo" cat-file -e "$commit^{commit}"
+ install -d -o root -g root -m 0755 "$workspace/$name"
+ git -C "$repo" archive "$commit" | tar -x -C "$workspace/$name"
+}
+
+archive_repo Gjallar "$source_commit"
+cultmath_commit="$(tr -d '\r\n' < "$workspace/Gjallar/deploy/cultmath.commit")"
+cultlib_commit="$(tr -d '\r\n' < "$workspace/Gjallar/deploy/cultlib.commit")"
+eve_commit="$(tr -d '\r\n' < "$workspace/Gjallar/deploy/eve.commit")"
+archive_repo CultMath "$cultmath_commit"
+archive_repo CultLib "$cultlib_commit"
+archive_repo Eve "$eve_commit"
+
+docker run --rm \
+ -v "$workspace:/workspace" \
+ -v "$publish:/publish" \
+ -w /workspace/Gjallar \
+ "$sdk_image" \
+ dotnet publish src/Gjallar/Gjallar.csproj -c Release -r linux-x64 --self-contained true -o /publish
+
+docker run --rm --network none \
+ -v "$publish:/app:ro" \
+ -v "$build_root:/smoke" \
+ "$sdk_image" \
+ /app/Gjallar --headless --frames 1 --url ws://127.0.0.1:1/eve/deck --stats-path /smoke/status.json --cultcache-path /smoke/gjallar.service.cc
+grep -Fq '"mode": "yggdrasil-composition-daemon"' "$build_root/status.json"
+grep -Fq '"enabled": false' "$build_root/status.json"
+
+if [ ! -d "$release" ]; then
+ install -d -o root -g root -m 0755 "$release"
+ cp -a "$publish/." "$release/"
+ printf '%s\n' \
+ 'schema_version=gamecult.gjallar.deployment_manifest.v2' \
+ "source_commit=$source_commit" \
+ "cultmath_commit=$cultmath_commit" \
+ "cultlib_commit=$cultlib_commit" \
+ "eve_commit=$eve_commit" \
+ "sdk_image=$sdk_image" \
+ "idunn_deployment_request_id=$request_id" > "$release/deployment.env"
+ chmod -R a-w "$release"
+fi
+
+previous="$(readlink -f "$current" 2>/dev/null || true)"
+ln -sfn "$release" "$current.next"
+mv -Tf "$current.next" "$current"
+install -d -o gjallar -g gjallar -m 0750 "$state_root"
+systemctl daemon-reload
+if ! systemctl enable --now gjallar-yggdrasil.service || ! systemctl is-active --quiet gjallar-yggdrasil.service; then
+ if [ -n "$previous" ] && [ -d "$previous" ]; then
+ ln -sfn "$previous" "$current.next"
+ mv -Tf "$current.next" "$current"
+ systemctl restart gjallar-yggdrasil.service || true
+ fi
+ echo "Gjallar Yggdrasil activation failed." >&2
+ exit 1
+fi
+
+install -d -o root -g root -m 0755 /srv/gjallar/deploy
+cp "$release/deployment.env" /srv/gjallar/deploy/deployment.env
+echo "Gjallar $source_commit is active on Yggdrasil without a framebuffer backend."
diff --git a/deploy/eve.commit b/deploy/eve.commit
new file mode 100644
index 0000000..cc2e3db
--- /dev/null
+++ b/deploy/eve.commit
@@ -0,0 +1 @@
+e5a81185bb790906bea5eeba40507673b0d9b5bb
diff --git a/docs/architecture.md b/docs/architecture.md
index e092a36..253ac26 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -2,34 +2,30 @@
## Objective
-Gjallar owns framebuffer-native, multi-scale TUI composition: turning many
-surface inputs into one dense, inspectable operator display where different
-regions may use different text resolutions.
+Gjallar owns renderer-neutral aggregation and tiling: turning every Odin-visible
+provider surface into one typed Eve composition that GUI, TUI, framebuffer, and
+agent clients can lower without recreating membership or layout authority.
## Authority Map
-- Owner: Gjallar owns display composition, pane partitioning, per-pane virtual
- text grids, font-scale selection, framebuffer lowering, and future sparse
- refresh output. Gjallar also owns local cursor state, title-bar hit testing,
- and minimized/restored panel state.
+- Owner: Gjallar owns aggregate membership, pane partitioning, layout intent,
+ visibility, and the versioned `gjallar.overview` surface.
- Inputs: provider catalogs, provider-owned surface trees, display dimensions,
font assets, runtime flags, mouse input, Odin's canonical slash-delimited
marquee tape, and Odin's accepted provider-state snapshot over CultNet/RUDP.
-- Outputs: `gjallar.overview` composition state, visible framebuffer frames,
- cursor presentation, top-tab minimized panel presentation, renderer
- telemetry, a local CultCache witness, and a one-shot provider advertisement
- into Odin's CultMesh/RUDP document ingress.
+- Outputs: typed `gjallar.overview` composition state, a local CultCache witness,
+ continuous CultMesh/CultNet surface publication, and composition health.
- Derived state: pane weights, pixel rectangles, chosen font sizes, one-row
gutter blocks, an ECS-style structure-of-arrays gutter ribbon, ordered
marquee queue objects, ribbon occupancy, dirty rectangles, glyph runs,
title-bar hit regions, top-tab restore affordances, local
minimized-panel keys, and compact agent-readable projections.
- Forbidden writers: discovery systems do not decide Gjallar layout; providers
- do not tune themselves for Nightwing; framebuffer backends do not invent
- provider truth; Gjallar does not derive marquee content from provider status
- summaries; Odin and providers do not own Nightwing-local minimized state.
-- Shared paths: full-frame rendering, sparse refresh, future browser previews,
- and agent text capture should lower the same overview model.
+ do not tune themselves for clients; Gjallar daemon mode does not open
+ `/dev/fb0`, capture local input, or own client pixels; client lowerers do not
+ decide aggregate membership or provider truth.
+- Shared paths: EveCanvas, browser, TUI/framebuffer, sparse refresh, and agent
+ capture all lower the same published overview model.
- Deletion line: any code that both discovers provider truth and decides final
screen composition belongs on one side or the other. Gjallar shows; discovery
systems see.
@@ -37,13 +33,21 @@ regions may use different text resolutions.
## Current Pipeline
```text
-Gjallar typed CultCache witness
+Odin accepted provider-state snapshot on Yggdrasil
-> one startup provider advertisement to Odin over CultMesh/RUDP
-> Odin accepted provider-state snapshot over CultNet/RUDP
-> display provider selection
-> provider surface extraction
-> canonical marquee tape passthrough
- -> synthetic gjallar.overview surface
+ -> typed gjallar.overview Eve surface
+ -> CultCache + CultMesh/CultNet publication
+ -> Eve GUI/TUI/agent clients
+```
+
+Optional framebuffer lowering continues from the published overview:
+
+```text
+gjallar.overview
-> EveNode panel extraction
-> weighted AABB packing
-> local minimized-state application
diff --git a/src/Gjallar/Gjallar.csproj b/src/Gjallar/Gjallar.csproj
index 494d85a..c5d60d1 100644
--- a/src/Gjallar/Gjallar.csproj
+++ b/src/Gjallar/Gjallar.csproj
@@ -11,6 +11,7 @@
+
diff --git a/src/Gjallar/Program.cs b/src/Gjallar/Program.cs
index 499144c..f849d1f 100644
--- a/src/Gjallar/Program.cs
+++ b/src/Gjallar/Program.cs
@@ -10,6 +10,7 @@ using System.Text;
using System.Text.Json;
using CultMath;
using GameCult.Caching;
+using GameCult.Eve.Surface;
using GameCult.Networking;
using MessagePack;
@@ -36,7 +37,8 @@ internal sealed record GjallarConfig(
string OdinCultMeshRudpEndpoint,
string IdunnRudpHealth,
string IdunnDaemon,
- string IdunnHealthContract)
+ string IdunnHealthContract,
+ bool Headless)
{
public static GjallarConfig Parse(IReadOnlyList args)
{
@@ -59,8 +61,9 @@ internal sealed record GjallarConfig(
StringArg(args, "--cultcache-path", Environment.GetEnvironmentVariable("GJALLAR_CULTCACHE_PATH") ?? ""),
StringArg(args, "--odin-cultmesh-rudp", Environment.GetEnvironmentVariable("GJALLAR_ODIN_CULTMESH_RUDP") ?? odinCultNetRudp),
StringArg(args, "--idunn-rudp-health", Environment.GetEnvironmentVariable("GJALLAR_IDUNN_RUDP_HEALTH") ?? ""),
- StringArg(args, "--idunn-daemon", Environment.GetEnvironmentVariable("GJALLAR_IDUNN_DAEMON") ?? "nightwing-gjallar"),
- StringArg(args, "--idunn-health-contract", Environment.GetEnvironmentVariable("GJALLAR_IDUNN_HEALTH_CONTRACT") ?? "gjallar.cultnet-rudp-framebuffer-composition-health"));
+ StringArg(args, "--idunn-daemon", Environment.GetEnvironmentVariable("GJALLAR_IDUNN_DAEMON") ?? "yggdrasil-gjallar"),
+ StringArg(args, "--idunn-health-contract", Environment.GetEnvironmentVariable("GJALLAR_IDUNN_HEALTH_CONTRACT") ?? "gjallar.cultnet-rudp-composition-health"),
+ FlagArg(args, "--headless") || string.Equals(Environment.GetEnvironmentVariable("GJALLAR_HEADLESS"), "1", StringComparison.Ordinal));
}
public bool UsesOdinCultNetRudp =>
@@ -87,6 +90,9 @@ internal sealed record GjallarConfig(
private static int IntArg(IReadOnlyList args, string name, int fallback) =>
int.TryParse(StringArg(args, name, ""), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : fallback;
+ private static bool FlagArg(IReadOnlyList args, string name) =>
+ args.Any(value => string.Equals(value, name, StringComparison.OrdinalIgnoreCase));
+
private static string ResolveSpecimenText(IReadOnlyList args)
{
var file = StringArg(args, "--specimen-text-file", "");
@@ -105,12 +111,13 @@ internal sealed class GjallarRenderer : IDisposable
private static readonly JsonSerializerOptions StatusJson = new() { WriteIndented = true };
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
private readonly GjallarConfig config;
- private readonly FramebufferDevice framebuffer;
- private readonly FontAtlas fonts;
+ private readonly FramebufferDevice? framebuffer;
+ private readonly FontAtlas? fonts;
private readonly CancellationTokenSource stopping = new();
private readonly PeriodicTimer timer;
private readonly TimeSpan frameInterval;
private byte[]? latestStateJson;
+ private EveSurfaceDocument? latestOverviewSurface;
private int frameIndex;
private long lastStatusTick;
private int lastStatusFrame;
@@ -150,6 +157,7 @@ internal sealed class GjallarRenderer : IDisposable
private string cultCachePublishObservedAt = "";
private readonly object odinStartupRespectLock = new();
private bool odinStartupRespectQueued;
+ private DateTimeOffset lastOdinPublicationAttemptAt = DateTimeOffset.MinValue;
private string odinStartupRespectStatus = "disabled";
private string odinStartupRespectError = "";
private string odinStartupRespectEndpoint = "";
@@ -158,13 +166,16 @@ internal sealed class GjallarRenderer : IDisposable
public GjallarRenderer(GjallarConfig config)
{
this.config = config;
- framebuffer = FramebufferDevice.Open(config.FramebufferPath, config.Width, config.Height);
- fonts = FontAtlas.Load(config.FontPath);
+ if (!config.Headless)
+ {
+ framebuffer = FramebufferDevice.Open(config.FramebufferPath, config.Width, config.Height);
+ fonts = FontAtlas.Load(config.FontPath);
+ }
frameInterval = TimeSpan.FromSeconds(1.0 / Math.Max(1, config.RefreshHz));
timer = new PeriodicTimer(frameInterval);
lastStatusTick = Stopwatch.GetTimestamp();
- cursorX = framebuffer.Width / 2;
- cursorY = framebuffer.Height / 2;
+ cursorX = framebuffer?.Width / 2 ?? 0;
+ cursorY = framebuffer?.Height / 2 ?? 0;
if (!string.IsNullOrWhiteSpace(config.CultCachePath))
{
verseRuntime = GjallarVerseRuntime.Create(config);
@@ -174,12 +185,31 @@ internal sealed class GjallarRenderer : IDisposable
public async Task RunAsync()
{
+ if (config.Headless)
+ {
+ var headlessReceiveTask = Task.Run(() => ConnectReceiveLoopAsync(stopping.Token));
+ var pulses = 0;
+ while (config.Frames <= 0 || pulses < config.Frames)
+ {
+ var started = DateTimeOffset.UtcNow;
+ pulses++;
+ WriteStatus("composing", pulses, 0, 0, default, started);
+ if (config.Frames > 0 && pulses >= config.Frames) break;
+ await timer.WaitForNextTickAsync(stopping.Token).ConfigureAwait(false);
+ }
+ stopping.Cancel();
+ try { await headlessReceiveTask.ConfigureAwait(false); }
+ catch (OperationCanceledException) when (stopping.IsCancellationRequested) { }
+ return;
+ }
+
+ var renderFramebuffer = framebuffer ?? throw new InvalidOperationException("Framebuffer backend is unavailable.");
if (!string.IsNullOrWhiteSpace(config.SpecimenText))
{
lastTimings = RenderSpecimen(config.SpecimenText);
if (!string.IsNullOrWhiteSpace(config.FrameDumpPath))
{
- framebuffer.WritePpm(config.FrameDumpPath);
+ renderFramebuffer.WritePpm(config.FrameDumpPath);
}
WriteStatus("specimen-rendered", 1, lastTimings.CopyMs + lastTimings.DecorMs + lastTimings.GutterMs + lastTimings.PresentMs, 0, lastTimings, DateTimeOffset.UtcNow);
@@ -199,7 +229,7 @@ internal sealed class GjallarRenderer : IDisposable
WriteStatusIfDue("rendered", rendered, paintMs, lastTimings, started);
if (!string.IsNullOrWhiteSpace(config.FrameDumpPath))
{
- framebuffer.WritePpm(config.FrameDumpPath);
+ renderFramebuffer.WritePpm(config.FrameDumpPath);
}
if (config.Frames > 0 && rendered >= config.Frames)
@@ -255,7 +285,7 @@ internal sealed class GjallarRenderer : IDisposable
lock (interactionLock)
{
cursorActive = true;
- cursorX = Math.Clamp(cursorX + dx, 0, Math.Max(0, framebuffer.Width - 1));
+ cursorX = Math.Clamp(cursorX + dx, 0, Math.Max(0, framebuffer!.Width - 1));
cursorY = Math.Clamp(cursorY - dy, 0, Math.Max(0, framebuffer.Height - 1));
}
@@ -417,7 +447,7 @@ internal sealed class GjallarRenderer : IDisposable
var observedAt = DateTimeOffset.UtcNow;
latestCatalogProviders = catalog.Providers.Count;
latestComposedProviders = frames.Count;
- Interlocked.Exchange(ref latestStateJson, BuildGjallarSurface(catalog, frames));
+ AcceptOverview(BuildGjallarOverview(catalog, frames));
MarkCatalogReceiveSuccess(observedAt);
lastReceiveError = "";
lastProviderFetchError = "";
@@ -456,7 +486,7 @@ internal sealed class GjallarRenderer : IDisposable
}
}
- Interlocked.Exchange(ref latestStateJson, BuildGjallarSurface(catalog, states));
+ AcceptOverview(BuildGjallarOverview(catalog, states));
latestComposedProviders = states.Count;
MarkCatalogReceiveSuccess(DateTimeOffset.UtcNow);
lastReceiveError = "";
@@ -757,8 +787,15 @@ internal sealed class GjallarRenderer : IDisposable
}
}
- private static byte[] BuildGjallarSurface(ProviderCatalog catalog, IReadOnlyList frames)
+ private void AcceptOverview(GjallarOverview overview)
{
+ Interlocked.Exchange(ref latestStateJson, overview.LegacyStateJson);
+ Volatile.Write(ref latestOverviewSurface, overview.Surface);
+ }
+
+ private static GjallarOverview BuildGjallarOverview(ProviderCatalog catalog, IReadOnlyList frames)
+ {
+ var observedAt = DateTimeOffset.UtcNow;
var children = frames.Select(frame => new
{
id = $"gjallar-interface-{StableId(frame.Provider.Id)}",
@@ -785,8 +822,8 @@ internal sealed class GjallarRenderer : IDisposable
schema = "gamecult.gjallar.overview.v1",
providerId = "gjallar.overview",
title = "Gjallar",
- version = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
- updatedAt = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture),
+ version = observedAt.ToUnixTimeMilliseconds(),
+ updatedAt = observedAt.ToString("O", CultureInfo.InvariantCulture),
providerCatalog = catalog.Providers,
surface = new
{
@@ -808,9 +845,85 @@ internal sealed class GjallarRenderer : IDisposable
assets = Array.Empty