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(), }, }; - return JsonSerializer.SerializeToUtf8Bytes(document, JsonOptions); + var componentChildren = frames.Select(frame => new EveSurfaceComponent( + $"gjallar-interface-{StableId(frame.Provider.Id)}", + "interface", + new Dictionary(StringComparer.Ordinal) + { + ["title"] = frame.Provider.Title ?? frame.Provider.Id, + ["providerId"] = frame.Provider.Id, + ["status"] = frame.Provider.Status ?? "unknown", + ["source"] = frame.Provider.Endpoint ?? "", + ["updatedAt"] = ResolveFrameUpdatedAt(frame), + }, + [ToEveComponent(frame.Root)], + Array.Empty(), + Array.Empty(), + new Dictionary(StringComparer.Ordinal) + { + ["density"] = "dense", + ["viewportMode"] = "nested-scroll", + })).ToArray(); + var root = new EveSurfaceComponent( + "gjallar-overview-root", + "dashboard", + new Dictionary(StringComparer.Ordinal) + { + ["title"] = "Gjallar", + ["summary"] = $"{frames.Count} provider surfaces", + ["marqueeText"] = catalog.MarqueeText ?? "", + }, + componentChildren); + var surface = new EveSurfaceDocument( + "surface-state", + EveSurfaceDocument.SchemaId, + "gjallar.overview", + "dashboard", + "Gjallar", + observedAt.ToUnixTimeMilliseconds(), + observedAt.ToString("O", CultureInfo.InvariantCulture), + new EveSurfaceTree("gjallar.overview.surface", root, []), + []); + return new GjallarOverview(surface, JsonSerializer.SerializeToUtf8Bytes(document, JsonOptions)); } + private static EveSurfaceComponent ToEveComponent(JsonElement element) + { + var props = StringMap(element, "props"); + var layout = StringMap(element, "layout"); + var style = StringMap(element, "style"); + var children = element.TryGetProperty("children", out var childElements) && childElements.ValueKind == JsonValueKind.Array + ? childElements.EnumerateArray().Select(ToEveComponent).ToArray() + : []; + return new EveSurfaceComponent( + StringProperty(element, "id"), + StringProperty(element, "kind"), + props, + children, + Array.Empty(), + Array.Empty(), + layout, + style); + } + + private static Dictionary StringMap(JsonElement owner, string property) + { + var values = new Dictionary(StringComparer.Ordinal); + if (!owner.TryGetProperty(property, out var map) || map.ValueKind != JsonValueKind.Object) return values; + foreach (var entry in map.EnumerateObject()) values[entry.Name] = JsonString(entry.Value); + return values; + } + + private static string StringProperty(JsonElement owner, string property) => + owner.TryGetProperty(property, out var value) ? JsonString(value) : ""; + + private static string JsonString(JsonElement value) => value.ValueKind switch + { + JsonValueKind.String => value.GetString() ?? "", + JsonValueKind.Null or JsonValueKind.Undefined => "", + _ => value.GetRawText(), + }; + private static string ResolveFrameUpdatedAt(ProviderFrame frame) => frame.State.TryGetProperty("updatedAt", out var updatedAt) ? ValueString(updatedAt) @@ -870,6 +983,8 @@ internal sealed class GjallarRenderer : IDisposable private FrameTimings RenderOnce() { + var target = framebuffer!; + var atlas = fonts!; var stateJson = Volatile.Read(ref latestStateJson); HashSet minimizedSnapshot; int minimizedSnapshotVersion; @@ -885,16 +1000,16 @@ internal sealed class GjallarRenderer : IDisposable drawCursor = cursorActive; } - var frame = new FrameDocument(framebuffer.Width, framebuffer.Height, fonts, minimizedSnapshot); + var frame = new FrameDocument(target.Width, target.Height, atlas, minimizedSnapshot); var copyMs = 0.0; var decorMs = 0.0; var gutterMs = 0.0; var presentMs = 0.0; if (stateJson is null) { - frame.Clear(ToBgra(Voronoi.SampleTone(0, 0, framebuffer.Height, frameIndex, CultMathTone.Background, Math.Max(framebuffer.Width, framebuffer.Height)))); - var statusFont = fonts.HeaderFor(framebuffer.Height); - frame.DrawText(fonts.IndexOf(statusFont), 16, 16, "Waiting for Odin Eve surface...", ToBgra(Voronoi.SampleTone(16, 16, framebuffer.Height, frameIndex, CultMathTone.Body, statusFont.Width))); + frame.Clear(ToBgra(Voronoi.SampleTone(0, 0, target.Height, frameIndex, CultMathTone.Background, Math.Max(target.Width, target.Height)))); + var statusFont = atlas.HeaderFor(target.Height); + frame.DrawText(atlas.IndexOf(statusFont), 16, 16, "Waiting for Odin Eve surface...", ToBgra(Voronoi.SampleTone(16, 16, target.Height, frameIndex, CultMathTone.Body, statusFont.Width))); } else { @@ -925,10 +1040,10 @@ internal sealed class GjallarRenderer : IDisposable latestTitleHits = frame.TitleHitRegions.ToArray(); } - lastPanelFontUsage = frame.PanelFontUsage(fonts); + lastPanelFontUsage = frame.PanelFontUsage(atlas); var presentStarted = Stopwatch.GetTimestamp(); - framebuffer.Present(frame.Pixels); + target.Present(frame.Pixels); presentMs = ElapsedMilliseconds(presentStarted, Stopwatch.GetTimestamp()); frameIndex++; return new FrameTimings(copyMs, decorMs, gutterMs, presentMs); @@ -936,18 +1051,20 @@ internal sealed class GjallarRenderer : IDisposable private FrameTimings RenderSpecimen(string specimenText) { - var frame = new FrameDocument(framebuffer.Width, framebuffer.Height, fonts); + var target = framebuffer!; + var atlas = fonts!; + var frame = new FrameDocument(target.Width, target.Height, atlas); var started = Stopwatch.GetTimestamp(); frame.Clear(ColorBgra.Black); - var titleFont = fonts.Edge; - frame.DrawText(fonts.IndexOf(titleFont), 24, 24, "Gjallar Bitmap Specimen", new ColorBgra(255, 170, 90)); + var titleFont = atlas.Edge; + frame.DrawText(atlas.IndexOf(titleFont), 24, 24, "Gjallar Bitmap Specimen", new ColorBgra(255, 170, 90)); var sample = specimenText; var baselineY = 70; - foreach (var font in fonts.All) + foreach (var font in atlas.All) { - var index = fonts.IndexOf(font); + var index = atlas.IndexOf(font); var header = $"{font.Width}x{font.Height} kana:{(font.SupportsKana ? "yes" : "no")}"; frame.DrawText(index, 24, baselineY, header, new ColorBgra(255, 140, 80)); baselineY += font.LineHeight + 6; @@ -958,7 +1075,7 @@ internal sealed class GjallarRenderer : IDisposable } var presentStarted = Stopwatch.GetTimestamp(); - framebuffer.Present(frame.Pixels); + target.Present(frame.Pixels); var presentMs = ElapsedMilliseconds(presentStarted, Stopwatch.GetTimestamp()); frameIndex++; return new FrameTimings(ElapsedMilliseconds(started, presentStarted), 0, 0, presentMs); @@ -1030,19 +1147,14 @@ internal sealed class GjallarRenderer : IDisposable statusCursorError, statusLastClick, visibleMarqueeRows); - var document = new - { - schema = "gamecult.gjallar.frame.v1", - service = "gjallar", - mode = "native-csharp-odin-eve-framebuffer", - presentMode = "single-contiguous-framebuffer-write", - status, - frames, - framebuffer = new { framebuffer.Width, framebuffer.Height, bytes = framebuffer.BufferBytes }, - refreshHz = config.RefreshHz, - measuredFps = Math.Round(measuredFps, 2), - fonts = new + object framebufferStatus = framebuffer is null + ? new { enabled = false } + : new { enabled = true, framebuffer.Width, framebuffer.Height, bytes = framebuffer.BufferBytes }; + object fontStatus = fonts is null + ? new { enabled = false } + : new { + enabled = true, requestedPath = config.FontPath, packagedFonts = new[] { @@ -1055,7 +1167,19 @@ internal sealed class GjallarRenderer : IDisposable defaultFont = new { width = fonts.Default.Width, height = fonts.Default.Height, kana = fonts.Default.SupportsKana }, edgeFont = new { width = fonts.Edge.Width, height = fonts.Edge.Height, kana = fonts.Edge.SupportsKana }, loaded = fonts.Describe(), - }, + }; + var document = new + { + schema = "gamecult.gjallar.frame.v1", + service = "gjallar", + mode = config.Headless ? "yggdrasil-composition-daemon" : "framebuffer-lowering", + presentMode = config.Headless ? "typed-eve-surface-publication" : "single-contiguous-framebuffer-write", + status, + frames, + framebuffer = framebufferStatus, + refreshHz = config.RefreshHz, + measuredFps = Math.Round(measuredFps, 2), + fonts = fontStatus, receive = new { status = EffectiveReceiveStatus(started), @@ -1182,6 +1306,7 @@ internal sealed class GjallarRenderer : IDisposable CatalogProviders = latestCatalogProviders, ComposedProviders = latestComposedProviders, StateBytes = Volatile.Read(ref latestStateJson)?.Length ?? 0, + OverviewSurface = Volatile.Read(ref latestOverviewSurface), Panels = sceneCache?.Panels.Count ?? 0, PanelFontUsage = lastPanelFontUsage, MinimizedPanels = minimizedCount, @@ -1209,7 +1334,10 @@ internal sealed class GjallarRenderer : IDisposable cultCachePublishStatus = "published"; cultCachePublishError = ""; cultCachePublishObservedAt = started.ToString("O", CultureInfo.InvariantCulture); - QueueOdinStartupRespectIfNeeded(providerAdvertisement, started); + if (pulse.OverviewSurface is not null) + { + QueueOdinPublication(providerAdvertisement, pulse.OverviewSurface, started); + } } catch (Exception error) { @@ -1219,7 +1347,7 @@ internal sealed class GjallarRenderer : IDisposable } } - private void QueueOdinStartupRespectIfNeeded(GjallarProviderAdvertisementRecord providerAdvertisement, DateTimeOffset observedAt) + private void QueueOdinPublication(GjallarProviderAdvertisementRecord providerAdvertisement, EveSurfaceDocument surface, DateTimeOffset observedAt) { if (string.IsNullOrWhiteSpace(config.OdinCultMeshRudpEndpoint)) { @@ -1229,12 +1357,13 @@ internal sealed class GjallarRenderer : IDisposable lock (odinStartupRespectLock) { - if (odinStartupRespectQueued) + if (odinStartupRespectQueued || observedAt - lastOdinPublicationAttemptAt < TimeSpan.FromSeconds(Math.Max(1, config.CatalogRefreshSeconds))) { return; } odinStartupRespectQueued = true; + lastOdinPublicationAttemptAt = observedAt; odinStartupRespectStatus = "publishing"; odinStartupRespectError = ""; odinStartupRespectEndpoint = config.OdinCultMeshRudpEndpoint; @@ -1245,7 +1374,7 @@ internal sealed class GjallarRenderer : IDisposable { try { - GjallarOdinProviderPublisher.Publish(config.OdinCultMeshRudpEndpoint, config.IdunnDaemon, providerAdvertisement); + GjallarOdinProviderPublisher.Publish(config.OdinCultMeshRudpEndpoint, config.IdunnDaemon, providerAdvertisement, surface); lock (odinStartupRespectLock) { odinStartupRespectStatus = "published"; @@ -1260,6 +1389,13 @@ internal sealed class GjallarRenderer : IDisposable odinStartupRespectError = error.Message; } } + finally + { + lock (odinStartupRespectLock) + { + odinStartupRespectQueued = false; + } + } }); } @@ -1273,7 +1409,7 @@ internal sealed class GjallarRenderer : IDisposable var state = string.Equals(status, "rendered", StringComparison.Ordinal) ? (receiveStatus == "catalog-composed" ? "healthy" : "dependency-unavailable") : "active"; - var detail = $"Gjallar framebuffer {status}; frames={frames}; catalogProviders={latestCatalogProviders}; composedProviders={latestComposedProviders}; fps={Math.Round(measuredFps, 2)}; receive={receiveStatus}; attempt={lastReceiveAttemptStatus}; failures={consecutiveCatalogReceiveFailures}; lastSuccess={lastSuccessfulCatalogReceiveObservedAt}"; + var detail = $"Gjallar composition {status}; pulses={frames}; catalogProviders={latestCatalogProviders}; composedProviders={latestComposedProviders}; fps={Math.Round(measuredFps, 2)}; receive={receiveStatus}; attempt={lastReceiveAttemptStatus}; failures={consecutiveCatalogReceiveFailures}; lastSuccess={lastSuccessfulCatalogReceiveObservedAt}"; return new IdunnDaemonHealthRecord { DaemonId = config.IdunnDaemon, @@ -1367,12 +1503,12 @@ internal sealed class GjallarRenderer : IDisposable } var providers = root.PanelChildren().Where(node => !node.IsScaffoldOnly()).ToArray(); - var gutter = GutterSize(fonts.Edge); + var gutter = GutterSize(fonts!.Edge); var outerY = gutter; var minimizedProviders = providers.Where(node => minimizedSnapshot.Contains(node.StableKey())).ToArray(); var activeProviders = providers.Where(node => !minimizedSnapshot.Contains(node.StableKey())).ToArray(); var tabHeight = minimizedProviders.Length == 0 ? 0 : Math.Max(fonts.Edge.LineHeight + 10, GutterSize(fonts.Edge)); - var activeRect = new RectI(0, outerY + tabHeight, framebuffer.Width, Math.Max(1, framebuffer.Height - outerY * 2 - tabHeight)); + var activeRect = new RectI(0, outerY + tabHeight, framebuffer!.Width, Math.Max(1, framebuffer.Height - outerY * 2 - tabHeight)); var packed = minimizedProviders.Length == 0 ? [] : MinimizedTabs(minimizedProviders, new RectI(0, outerY, framebuffer.Width, tabHeight), gutter).ToList(); @@ -1403,11 +1539,12 @@ internal sealed class GjallarRenderer : IDisposable stopping.Dispose(); timer.Dispose(); verseRuntime?.Dispose(); - framebuffer.Dispose(); + framebuffer?.Dispose(); } } internal sealed record SceneCache(byte[] StateJson, IReadOnlyList Panels, GutterRibbon GutterRibbon, string MarqueeTape, int MinimizedVersion); +internal sealed record GjallarOverview(EveSurfaceDocument Surface, byte[] LegacyStateJson); internal readonly record struct FrameTimings(double CopyMs, double DecorMs, double GutterMs, double PresentMs); internal sealed record ProviderCatalog(IReadOnlyList Providers, string? MarqueeText = null); internal sealed record ProviderCatalogEntry( @@ -2775,11 +2912,11 @@ internal sealed class ToneBatch(int resolutionY, int frameIndex) [MessagePackObject(AllowPrivate = true)] internal sealed class IdunnDaemonHealthRecord { - [Key(0)] public string DaemonId { get; set; } = "nightwing-gjallar"; + [Key(0)] public string DaemonId { get; set; } = "yggdrasil-gjallar"; [Key(1)] public string State { get; set; } = "active"; [Key(2)] public string Detail { get; set; } = ""; [Key(3)] public string ObservedAt { get; set; } = ""; - [Key(4)] public string HealthContract { get; set; } = "gjallar.cultnet-rudp-framebuffer-composition-health"; + [Key(4)] public string HealthContract { get; set; } = "gjallar.cultnet-rudp-composition-health"; [Key(5)] public string PublicationSource { get; set; } = "daemon-published"; [Key(6)] public string Transport { get; set; } = "cultnet.transport.rudp.v0"; } @@ -2860,7 +2997,7 @@ internal static class GjallarOdinProviderPublisher { private const uint ConnectionId = 0x0d1d0002; - public static void Publish(string endpoint, string runtimeId, GjallarProviderAdvertisementRecord advertisement) + public static void Publish(string endpoint, string runtimeId, GjallarProviderAdvertisementRecord advertisement, EveSurfaceDocument surface) { var remote = ParseEndpoint(endpoint); using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); @@ -2868,7 +3005,7 @@ internal static class GjallarOdinProviderPublisher socket.ReceiveTimeout = 100; using var transport = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { - RuntimeId = string.IsNullOrWhiteSpace(runtimeId) ? "nightwing-gjallar" : runtimeId, + RuntimeId = string.IsNullOrWhiteSpace(runtimeId) ? "yggdrasil-gjallar" : runtimeId, Socket = socket, Mode = CultNetRudpSocketMode.Client, RemoteEndPoint = remote, @@ -2885,7 +3022,7 @@ internal static class GjallarOdinProviderPublisher var storedAt = string.IsNullOrWhiteSpace(advertisement.UpdatedAt) ? DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture) : advertisement.UpdatedAt; - var sourceRuntimeId = string.IsNullOrWhiteSpace(runtimeId) ? "nightwing-gjallar" : runtimeId; + var sourceRuntimeId = string.IsNullOrWhiteSpace(runtimeId) ? "yggdrasil-gjallar" : runtimeId; var message = new CultNetDocumentPutRawMessage { MessageId = $"gjallar-provider:{advertisement.ProviderId}:{storedAt.Replace(':', '-')}", @@ -2902,6 +3039,22 @@ internal static class GjallarOdinProviderPublisher }, }; transport.SendSchema(CultNetSchemaMessageSerialization.Serialize(message)); + var surfaceMessage = new CultNetDocumentPutRawMessage + { + MessageId = $"gjallar-surface:{surface.ProviderId}:{surface.Version}", + Document = new CultNetRawDocumentRecord + { + SchemaId = EveSurfaceDocument.SchemaId, + RecordKey = surface.ProviderId, + StoredAt = surface.UpdatedAtUtc, + PayloadEncoding = "messagepack", + Payload = MessagePackSerializer.Serialize(surface, CultNetSchemaMessageSerialization.Options), + SourceRuntimeId = sourceRuntimeId, + SourceRole = "gjallar.compositor", + Tags = ["aggregate-surface", "odin-visible-providers"], + }, + }; + transport.SendSchema(CultNetSchemaMessageSerialization.Serialize(surfaceMessage)); } private static IPEndPoint ParseEndpoint(string value) diff --git a/src/Gjallar/VerseState.cs b/src/Gjallar/VerseState.cs index d99ffd1..3e38044 100644 --- a/src/Gjallar/VerseState.cs +++ b/src/Gjallar/VerseState.cs @@ -1,17 +1,18 @@ using System.Globalization; using GameCult.Caching; using GameCult.Caching.MessagePack; +using GameCult.Eve.Surface; using MessagePack; internal sealed class GjallarVerseRuntime : IDisposable { - private static readonly CultRecordKey ProviderAdvertisementKey = new("provider:nightwing-gjallar"); - private static readonly CultRecordKey SurfaceStateKey = new("surface:nightwing-gjallar"); + private static readonly CultRecordKey ProviderAdvertisementKey = new("provider:gjallar-overview"); + private static readonly CultRecordKey SurfaceStateKey = new("surface:gjallar-overview"); private static readonly CultRecordKey RuntimeConfigKey = new("gjallar:runtime-config"); private static readonly CultRecordKey FrameStatusKey = new("gjallar:frame-status"); private static readonly CultRecordKey CommandBoundaryKey = new("gjallar:command-boundary"); private static readonly CultRecordKey TransportProfileKey = new("gjallar:transport-profile"); - private static readonly CultRecordKey DaemonHealthKey = new("nightwing-gjallar"); + private static readonly CultRecordKey DaemonHealthKey = new("yggdrasil-gjallar"); private readonly GjallarConfig config; private readonly CultCache cache; @@ -47,7 +48,10 @@ internal sealed class GjallarVerseRuntime : IDisposable { var providerAdvertisement = BuildProviderAdvertisement(pulse); Put(providerAdvertisement, ProviderAdvertisementKey); - Put(BuildSurfaceState(pulse), SurfaceStateKey); + if (pulse.OverviewSurface is not null) + { + Put(pulse.OverviewSurface, SurfaceStateKey); + } Put(BuildRuntimeConfig(pulse), RuntimeConfigKey); Put(BuildFrameStatus(pulse), FrameStatusKey); Put(BuildCommandBoundary(pulse), CommandBoundaryKey); @@ -67,23 +71,23 @@ internal sealed class GjallarVerseRuntime : IDisposable new() { RecordId = ProviderAdvertisementKey.Value, - ProviderId = "nightwing-gjallar", - ServiceId = "gjallar.framebuffer-compositor", - VerseId = "nightwing.local", - Title = "Nightwing Gjallar", - Description = "Nightwing framebuffer compositor that lowers Odin/provider deck state into the shared wall display.", + ProviderId = "gjallar.overview", + ServiceId = "gjallar.compositor", + VerseId = "yggdrasil.local", + Title = "Gjallar", + Description = "Yggdrasil composition daemon that publishes every Odin-visible Eve surface as one tiled aggregate.", CanonicalService = "asgard.gjallar", - LocatedService = "asgard.nightwing.gjallar", - CultMeshAddress = "asgard.nightwing.gjallar/framebuffer", + LocatedService = "asgard.yggdrasil.gjallar", + CultMeshAddress = "asgard.yggdrasil.gjallar/eve/gui", Status = "live", UpdatedAt = pulse.ObservedAt.ToString("O", CultureInfo.InvariantCulture), CapabilityIds = [ - "framebuffer.composition", + "surface.aggregation", + "layout.composition", "cultui-surface", "daemon-health", "cultcache-witness", - "operator.mouse-local", ], Endpoints = new[] { @@ -101,21 +105,21 @@ internal sealed class GjallarVerseRuntime : IDisposable }, new GjallarAdvertisedSchema { - SchemaId = "gamecult.eve.surface_state.v1", + SchemaId = "gamecult.eve.surface.v1", Owner = "gjallar.runtime", - Description = "Gjallar-owned operator surface describing the compositor body, transport debt, and live frame telemetry.", + Description = "Gjallar-owned aggregate containing every currently visible provider surface accepted by Odin.", }, new GjallarAdvertisedSchema { SchemaId = "gjallar.runtime_config.v0", Owner = "gjallar.runtime", - Description = "Framebuffer/deck wiring, witness path, and runtime-owned transport settings.", + Description = "Composition input, witness path, and runtime-owned transport settings.", }, new GjallarAdvertisedSchema { SchemaId = "gjallar.frame_status.v0", Owner = "gjallar.runtime", - Description = "Latest framebuffer composition pulse, deck receive state, and render timings.", + Description = "Latest aggregate composition pulse and input freshness.", }, new GjallarAdvertisedSchema { @@ -147,7 +151,7 @@ internal sealed class GjallarVerseRuntime : IDisposable Schemas = [ "gamecult.eve.provider_advertisement.v1", - "gamecult.eve.surface_state.v1", + "gamecult.eve.surface.v1", "gjallar.runtime_config.v0", "gjallar.frame_status.v0", "gjallar.command_boundary.v0", @@ -160,8 +164,8 @@ internal sealed class GjallarVerseRuntime : IDisposable [ new GjallarAdvertisedSurface { - SurfaceId = "nightwing-gjallar", - Address = "asgard.nightwing.gjallar/framebuffer", + SurfaceId = "gjallar.overview", + Address = "asgard.yggdrasil.gjallar/eve/gui", Kind = "dashboard", Status = pulse.Status, InputTransport = config.InputTransportId, @@ -169,131 +173,13 @@ internal sealed class GjallarVerseRuntime : IDisposable ], }; - private GjallarSurfaceStateRecord BuildSurfaceState(GjallarVersePulse pulse) - { - var updatedAt = pulse.ObservedAt.ToString("O", CultureInfo.InvariantCulture); - return new GjallarSurfaceStateRecord - { - RecordId = "surface:nightwing-gjallar", - ProviderId = "nightwing-gjallar", - Title = "Nightwing Gjallar", - Version = pulse.ObservedAt.ToUnixTimeMilliseconds(), - UpdatedAt = updatedAt, - Surface = new GjallarSurfaceDocument - { - Schema = "gamecult.eve.surface.v1", - Id = "nightwing-gjallar.surface", - Title = "Nightwing Gjallar", - Root = DashboardNode( - "nightwing-gjallar-root", - "Nightwing Gjallar", - $"{pulse.CatalogProviders} catalog / {pulse.ComposedProviders} composed / {pulse.Panels} visible panels", - pulse.Status, - GroupNode( - "nightwing-gjallar-frame", - "Frame", - MetricNode("nightwing-gjallar-status", "status", pulse.Status), - MetricNode("nightwing-gjallar-fps", "fps", pulse.MeasuredFps.ToString("0.0", CultureInfo.InvariantCulture)), - MetricNode("nightwing-gjallar-panels", "panels", pulse.Panels.ToString(CultureInfo.InvariantCulture)), - MetricNode("nightwing-gjallar-catalog", "catalog", pulse.CatalogProviders.ToString(CultureInfo.InvariantCulture)), - MetricNode("nightwing-gjallar-composed", "composed", pulse.ComposedProviders.ToString(CultureInfo.InvariantCulture)) - ), - CardNode( - "nightwing-gjallar-transport", - "Transport", - TextNode("nightwing-gjallar-input-transport", $"input: {config.InputTransportId} ({pulse.ReceiveStatus})"), - TextNode("nightwing-gjallar-input-attempt", $"attempt: {pulse.ReceiveAttemptStatus} / failures: {pulse.ConsecutiveReceiveFailures}"), - TextNode("nightwing-gjallar-last-success", string.IsNullOrWhiteSpace(pulse.LastSuccessfulReceiveAt) ? "last success: none" : $"last success: {pulse.LastSuccessfulReceiveAt}"), - TextNode("nightwing-gjallar-output-transport", "output: daemon-published-rudp-health + daemon-owned-cultcache-service-boundary"), - TextNode("nightwing-gjallar-witness", $"witness: {config.CultCachePath}"), - TextNode("nightwing-gjallar-status-path", $"status: {config.StatusPath}"), - TextNode("nightwing-gjallar-health-endpoint", $"health: {config.IdunnRudpHealth}") - ), - CardNode( - "nightwing-gjallar-cursor", - "Cursor", - TextNode("nightwing-gjallar-cursor-status", $"cursor: {pulse.CursorStatus}"), - TextNode("nightwing-gjallar-cursor-position", $"position: {pulse.CursorX}, {pulse.CursorY}"), - TextNode("nightwing-gjallar-last-click", $"last click: {pulse.LastClick}"), - TextNode("nightwing-gjallar-cursor-error", string.IsNullOrWhiteSpace(pulse.CursorError) ? "cursor error: none" : $"cursor error: {pulse.CursorError}") - ), - CardNode( - "nightwing-gjallar-scene", - "Scene", - TextNode("nightwing-gjallar-minimized", $"minimized: {pulse.MinimizedPanels}"), - TextNode("nightwing-gjallar-title-hits", $"title hits: {pulse.TitleHitRegions}"), - TextNode("nightwing-gjallar-gutters", $"gutter rows/cells: {pulse.GutterRows} / {pulse.GutterCells}"), - TextNode("nightwing-gjallar-marquee", $"marquee chars: {pulse.MarqueeChars}"), - TextNode("nightwing-gjallar-fetch", string.IsNullOrWhiteSpace(pulse.ProviderFetchError) ? "provider fetch: clean" : $"provider fetch: {pulse.ProviderFetchError}") - ) - ), - Assets = [], - }, - }; - } - - private static GjallarSurfaceNode DashboardNode(string id, string title, string summary, string status, params GjallarSurfaceNode[] children) => - new() - { - Id = id, - Kind = "dashboard", - Props = new GjallarSurfaceProps - { - Title = title, - Summary = summary, - Status = status, - }, - Children = children, - }; - - private static GjallarSurfaceNode GroupNode(string id, string title, params GjallarSurfaceNode[] children) => - new() - { - Id = id, - Kind = "group", - Props = new GjallarSurfaceProps { Title = title }, - Children = children, - }; - - private static GjallarSurfaceNode CardNode(string id, string title, params GjallarSurfaceNode[] children) => - new() - { - Id = id, - Kind = "card", - Props = new GjallarSurfaceProps { Title = title }, - Children = children, - }; - - private static GjallarSurfaceNode MetricNode(string id, string label, string value) => - new() - { - Id = id, - Kind = "metric", - Props = new GjallarSurfaceProps - { - Label = label, - Value = value, - Text = $"{label}: {value}", - }, - Children = [], - }; - - private static GjallarSurfaceNode TextNode(string id, string text) => - new() - { - Id = id, - Kind = "text", - Props = new GjallarSurfaceProps { Text = text }, - Children = [], - }; - private GjallarRuntimeConfigRecord BuildRuntimeConfig(GjallarVersePulse pulse) => new() { RecordId = RuntimeConfigKey.Value, - DaemonId = "nightwing-gjallar", - ServiceId = "gjallar.framebuffer-compositor", - VerseId = "nightwing.local", + DaemonId = "yggdrasil-gjallar", + ServiceId = "gjallar.compositor", + VerseId = "yggdrasil.local", FramebufferPath = config.FramebufferPath, Width = config.Width, Height = config.Height, @@ -313,7 +199,7 @@ internal sealed class GjallarVerseRuntime : IDisposable new() { RecordId = FrameStatusKey.Value, - DaemonId = "nightwing-gjallar", + DaemonId = "yggdrasil-gjallar", Status = pulse.Status, Frames = pulse.Frames, PaintMs = Math.Round(pulse.PaintMs, 2), @@ -356,10 +242,10 @@ internal sealed class GjallarVerseRuntime : IDisposable new() { RecordId = CommandBoundaryKey.Value, - DaemonId = "nightwing-gjallar", + DaemonId = "yggdrasil-gjallar", Mode = "read-only-runtime", WritesAccepted = false, - OperatorInputAuthority = "nightwing-local-framebuffer-mouse", + OperatorInputAuthority = "eve-client-command-boundaries", LifecycleAuthority = "idunn.local-command.restart + compatibility.systemd.gjallar.service", AcceptedCommands = [ @@ -379,7 +265,7 @@ internal sealed class GjallarVerseRuntime : IDisposable new() { RecordId = TransportProfileKey.Value, - DaemonId = "nightwing-gjallar", + DaemonId = "yggdrasil-gjallar", CurrentState = config.UsesOdinCultNetRudp ? "rudp-input-health-and-provider-store-live" : "partial-rudp-health-and-provider-store-live", @@ -390,8 +276,8 @@ internal sealed class GjallarVerseRuntime : IDisposable WitnessPath = config.CultCachePath, StatusPath = config.StatusPath, CurrentCutLine = config.UsesOdinCultNetRudp - ? "Gjallar now consumes Odin's accepted provider state over CultNet/RUDP snapshot polling while preserving Gjallar-owned Nightwing composition and daemon-owned health publication." - : "Gjallar now owns the Nightwing CultCache witness and daemon health publication; Odin WebSocket deck input remains the explicit compatibility transport debt until the native C# CultNet/RUDP intake path lands.", + ? "Gjallar consumes Odin's accepted provider state over CultNet/RUDP snapshot polling and publishes the Yggdrasil aggregate with daemon-owned health." + : "Gjallar owns the Yggdrasil aggregate witness and daemon health publication; Odin WebSocket deck input remains explicit compatibility transport debt until native C# CultNet/RUDP intake lands.", UpdatedAt = pulse.ObservedAt.ToString("O", CultureInfo.InvariantCulture), }; } @@ -414,6 +300,7 @@ internal sealed class GjallarVersePulse public int CatalogProviders { get; set; } public int ComposedProviders { get; set; } public int StateBytes { get; set; } + public EveSurfaceDocument? OverviewSurface { get; set; } public int Panels { get; set; } public object[] PanelFontUsage { get; set; } = []; public int MinimizedPanels { get; set; } @@ -571,53 +458,6 @@ internal sealed class GjallarTransportProfileRecord [Key(10)] public string UpdatedAt { get; set; } = string.Empty; } -[CultDocument("gamecult.eve.surface_state", "gamecult.eve.surface_state.v1")] -[MessagePackObject(AllowPrivate = true)] -internal sealed class GjallarSurfaceStateRecord -{ - [Key(0)] - [CultName] - public string RecordId { get; set; } = string.Empty; - - [Key(1)] public string ProviderId { get; set; } = string.Empty; - - [Key(2)] public string Title { get; set; } = string.Empty; - [Key(3)] public long Version { get; set; } - [Key(4)] public string UpdatedAt { get; set; } = string.Empty; - [Key(5)] public GjallarSurfaceDocument Surface { get; set; } = new(); -} - -[MessagePackObject(AllowPrivate = true)] -internal sealed class GjallarSurfaceDocument -{ - [Key(0)] public string Schema { get; set; } = "gamecult.eve.surface.v1"; - [Key(1)] public string Id { get; set; } = string.Empty; - [Key(2)] public string Title { get; set; } = string.Empty; - [Key(3)] public GjallarSurfaceNode Root { get; set; } = new(); - [Key(4)] public object[] Assets { get; set; } = []; -} - -[MessagePackObject(AllowPrivate = true)] -internal sealed class GjallarSurfaceNode -{ - [Key(0)] public string Id { get; set; } = string.Empty; - [Key(1)] public string Kind { get; set; } = string.Empty; - [Key(2)] public GjallarSurfaceProps Props { get; set; } = new(); - [Key(3)] public GjallarSurfaceNode[] Children { get; set; } = []; -} - -[MessagePackObject(AllowPrivate = true)] -internal sealed class GjallarSurfaceProps -{ - [Key(0)] public string Title { get; set; } = string.Empty; - [Key(1)] public string Text { get; set; } = string.Empty; - [Key(2)] public string Label { get; set; } = string.Empty; - [Key(3)] public string Value { get; set; } = string.Empty; - [Key(4)] public string Status { get; set; } = string.Empty; - [Key(5)] public string Summary { get; set; } = string.Empty; - [Key(6)] public string Detail { get; set; } = string.Empty; -} - [MessagePackObject(AllowPrivate = true)] internal sealed class GjallarAdvertisedSchema { diff --git a/state/gjallar-state.md b/state/gjallar-state.md index 7cd753f..cc8ae85 100644 --- a/state/gjallar-state.md +++ b/state/gjallar-state.md @@ -1,15 +1,14 @@ # Gjallar State -Last updated: 2026-06-23 +Last updated: 2026-08-24 ## Identity -Gjallar is the horn and the display body: a framebuffer-native, multi-scale TUI -compositor for dense operational surfaces. +Gjallar is the horn and composition organ: the Yggdrasil daemon that aggregates +every Odin-visible Eve surface into one tiled, renderer-neutral overview. -His job is not merely to view Odin. Odin is the first upstream witness, and -Nightwing is the first physical body. The reusable product kernel is the -compositor itself: +His job is not merely to view Odin. Odin is the upstream witness and Yggdrasil +is the daemon body. The reusable product kernel is the compositor itself: ```text surface sources @@ -22,19 +21,17 @@ surface sources ## Body -- Repo: `E:\Projects\Gjallar` +- Repo: `F:\Projects\Gjallar` - GitHub: `https://github.com/GameCult/Gjallar` - Runtime: `src/Gjallar/Gjallar.csproj` -- Current deployment: Nightwing `gjallar.service` -- Current feed: Odin accepted provider-state snapshot over CultNet/RUDP at - `192.168.1.66:17871`, with the old Odin Eve deck WebSocket path retained as - compatibility fallback. -- Current CultCache witness: `/var/lib/gjallar/gjallar.service.cc` +- Target deployment: Yggdrasil `gjallar-yggdrasil.service`, beside Odin and Idunn. +- Feed: Yggdrasil-local Odin accepted provider-state snapshot over CultNet/RUDP. +- CultCache witness: `/var/lib/gamecult/gjallar/gjallar.service.cc`. - Current startup respect: Gjallar publishes `gamecult.eve.provider_advertisement.v1` once to Odin's CultMesh/RUDP document ingress. `--odin-cultmesh-rudp` can override the document endpoint; otherwise it reuses `--odin-cultnet-rudp`. -- Current status path on Nightwing: `/var/log/gjallar.status` +- Status path: `/var/lib/gamecult/gjallar/status.json`. - Current cursor input: `/dev/input/mice`, rendered as a framebuffer crosshair. - Current minimized-panel presentation: a top tab row. Clicking a panel title minimizes it; clicking its tab restores it. @@ -75,28 +72,22 @@ Observed live status after cursor/minimize cut: ## Authority Map -- Owner: Gjallar owns display composition, pane partitioning, per-pane virtual - grids, font-scale choice, gutter text continuity, frame/cell lowering, and - renderer telemetry. It also owns local cursor position, title-bar hit testing, - and minimized/restored panel state. +- Owner: Gjallar owns aggregate membership, tiling, layout intent, visibility, + marquee placement, and the versioned `gjallar.overview` tree. - Inputs: provider catalogs, provider-owned surface trees, display dimensions, font assets, runtime flags, mouse deltas/buttons, Odin's canonical marquee tape, Odin's accepted provider-state snapshot over CultNet/RUDP, and later native CultMesh subscriptions. -- Outputs: `gjallar.overview` composition state, framebuffer frames, sparse - refresh streams, cursor presentation, top-tab minimized panel presentation, and - agent-readable panel captures. Gjallar also emits a local CultCache witness - and a one-shot provider advertisement to Odin for Verse discovery. +- Outputs: typed `gjallar.overview`, CultCache witness, continuous CultMesh/CultNet + publication, and daemon health. Rendered pixels belong to Eve clients. - Derived state: pane weights, pixel rectangles, selected fonts, 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 text projections. -- Forbidden writers: discovery systems must not decide Gjallar layout; providers - must not tune themselves for Nightwing; framebuffer backends must not invent - provider truth; Gjallar must not synthesize status-noise marquee content from - provider summaries. Odin and providers must not own Nightwing-local minimized - state. +- Forbidden writers: Odin must not decide Gjallar layout; providers must not tune + themselves for clients; the Yggdrasil daemon must not open `/dev/fb0`; Eve + clients must not decide aggregate membership or provider truth. - Cut line: code that discovers what exists belongs upstream. Code that decides what the canonical marquee says belongs upstream. Code that decides how a physical or virtual display shows it, and what the local operator has @@ -115,7 +106,7 @@ Gjallar's claim: The grandiose public phrase is allowed: a new paradigm of TUI. Keep it earned. The implementation must keep moving toward a real compositor model, not just a -pretty Nightwing dashboard. +pretty single-body dashboard. ## Research Ledger @@ -168,8 +159,8 @@ Source: `https://directfb1.org/` The Linux framebuffer/fbdev path is older and often superseded by DRM in modern systems, but it remains useful on consoles, older hardware, embedded-ish bodies, -and local appliance displays. Gjallar's current Nightwing deployment uses this -boring durable body: write pixels to `/dev/fb0`. +and local appliance displays. This remains useful for optional client lowerings; +it is not the Yggdrasil daemon substrate. Source: `https://en.wikipedia.org/wiki/Linux_framebuffer` @@ -221,10 +212,8 @@ Source: `https://en.wikipedia.org/wiki/Linux_framebuffer` ribbon address space, then lowers occupied addresses to screen cells. This removes middle-gutter entry/exit pile-ups caused by overlapping row-local object fragments. -- Transport debt remains explicit: the live Nightwing body still consumes the - compatibility Eve deck bridge. The target input organ is native CultNet / - CultMesh typed state over the real GameCult transport, not a web-shaped - provider fetch loop. +- Transport debt remains explicit: the compatibility Eve deck bridge exists for + local testing. The Yggdrasil daemon uses Odin's typed CultNet/CultMesh state. This is enough to run, but not enough to be proud forever. The next architectural cut is to extract explicit model types: diff --git a/systemd/gjallar-yggdrasil.service b/systemd/gjallar-yggdrasil.service new file mode 100644 index 0000000..85493eb --- /dev/null +++ b/systemd/gjallar-yggdrasil.service @@ -0,0 +1,20 @@ +[Unit] +Description=Gjallar Yggdrasil Eve surface compositor +Wants=network-online.target odin.service idunn-yggdrasil.service +After=network-online.target odin.service idunn-yggdrasil.service + +[Service] +Type=simple +User=gjallar +Group=gjallar +ExecStart=/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 --refresh-hz 2 --catalog-refresh-seconds 5 --idunn-rudp-health 10.77.0.1:17870 --idunn-daemon yggdrasil-gjallar --idunn-health-contract gjallar.cultnet-rudp-composition-health +Restart=always +RestartSec=3 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/gamecult/gjallar + +[Install] +WantedBy=multi-user.target