Run Gjallar as Yggdrasil aggregate compositor

This commit is contained in:
Meta Vens 2026-08-24 20:40:11 +02:00
commit 7a1a42faa1
13 changed files with 477 additions and 343 deletions

View file

@ -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

View file

@ -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 }
}
```

View file

@ -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."

1
deploy/cultlib.commit Normal file
View file

@ -0,0 +1 @@
75c180782aeba7cfd22d6412877397708a4ed28f

1
deploy/cultmath.commit Normal file
View file

@ -0,0 +1 @@
fcdddda72f6aa33ae6eeff3bce6a2d3e26eb16f3

View file

@ -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."

1
deploy/eve.commit Normal file
View file

@ -0,0 +1 @@
e5a81185bb790906bea5eeba40507673b0d9b5bb

View file

@ -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

View file

@ -11,6 +11,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\CultMath\src\CultMath\CultMath.csproj" />
<ProjectReference Include="..\..\..\CultLib\src\GameCult.Networking\GameCult.Networking.csproj" />
<ProjectReference Include="..\..\..\Eve\packages\org.gamecult.eve.surface\GameCult.Eve.Surface.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="assets\fonts\**\*">

View file

@ -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<string> 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<string> args, string name, int fallback) =>
int.TryParse(StringArg(args, name, ""), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : fallback;
private static bool FlagArg(IReadOnlyList<string> args, string name) =>
args.Any(value => string.Equals(value, name, StringComparison.OrdinalIgnoreCase));
private static string ResolveSpecimenText(IReadOnlyList<string> 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<ProviderFrame> 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<ProviderFrame> 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<object>(),
},
};
return JsonSerializer.SerializeToUtf8Bytes(document, JsonOptions);
var componentChildren = frames.Select(frame => new EveSurfaceComponent(
$"gjallar-interface-{StableId(frame.Provider.Id)}",
"interface",
new Dictionary<string, string>(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<GameCult.Mesh.CultMeshStateBindingDescriptor>(),
Array.Empty<EveEmbeddedDocumentSlot>(),
new Dictionary<string, string>(StringComparer.Ordinal)
{
["density"] = "dense",
["viewportMode"] = "nested-scroll",
})).ToArray();
var root = new EveSurfaceComponent(
"gjallar-overview-root",
"dashboard",
new Dictionary<string, string>(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<GameCult.Mesh.CultMeshStateBindingDescriptor>(),
Array.Empty<EveEmbeddedDocumentSlot>(),
layout,
style);
}
private static Dictionary<string, string> StringMap(JsonElement owner, string property)
{
var values = new Dictionary<string, string>(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<string> 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<PackedPanel> 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<ProviderCatalogEntry> 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)

View file

@ -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
{

View file

@ -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:

View file

@ -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