Run Gjallar as Yggdrasil aggregate compositor
This commit is contained in:
parent
c36b8785d0
commit
7a1a42faa1
13 changed files with 477 additions and 343 deletions
22
AGENTS.md
22
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
|
||||
|
||||
|
|
|
|||
60
README.md
60
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 }
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
32
deploy/bootstrap-yggdrasil.sh
Normal file
32
deploy/bootstrap-yggdrasil.sh
Normal 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
1
deploy/cultlib.commit
Normal file
|
|
@ -0,0 +1 @@
|
|||
75c180782aeba7cfd22d6412877397708a4ed28f
|
||||
1
deploy/cultmath.commit
Normal file
1
deploy/cultmath.commit
Normal file
|
|
@ -0,0 +1 @@
|
|||
fcdddda72f6aa33ae6eeff3bce6a2d3e26eb16f3
|
||||
92
deploy/deploy-yggdrasil.sh
Normal file
92
deploy/deploy-yggdrasil.sh
Normal 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
1
deploy/eve.commit
Normal file
|
|
@ -0,0 +1 @@
|
|||
e5a81185bb790906bea5eeba40507673b0d9b5bb
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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\**\*">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
20
systemd/gjallar-yggdrasil.service
Normal file
20
systemd/gjallar-yggdrasil.service
Normal 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
|
||||
Reference in a new issue