aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/src/Godosa.Desktop
diff options
context:
space:
mode:
authorgodosa <godosa@godosa.eu>2026-10-06 23:39:36 +0200
committergodosa <godosa@godosa.eu>2026-10-06 23:39:36 +0200
commit39066900773e7857faf2d02e7ed51b71d219d97d (patch)
treeffab8e4ddd971626776c3b1f4ce1da2c186c2187 /src/Godosa.Desktop
downloadgodosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.tar.gz
godosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.zip
godosa-engine: initial public history
Diffstat (limited to 'src/Godosa.Desktop')
-rw-r--r--src/Godosa.Desktop/Buttons.cs15
-rw-r--r--src/Godosa.Desktop/CursorWarp.cs66
-rw-r--r--src/Godosa.Desktop/DisplayMode.cs19
-rw-r--r--src/Godosa.Desktop/FrameTimeGraph.cs65
-rw-r--r--src/Godosa.Desktop/GlProgram.cs35
-rw-r--r--src/Godosa.Desktop/Godosa.Desktop.csproj12
-rw-r--r--src/Godosa.Desktop/GpuTimer.cs71
-rw-r--r--src/Godosa.Desktop/InputScriptPlayer.cs124
-rw-r--r--src/Godosa.Desktop/PadNavigator.cs85
9 files changed, 492 insertions, 0 deletions
diff --git a/src/Godosa.Desktop/Buttons.cs b/src/Godosa.Desktop/Buttons.cs
new file mode 100644
index 0000000..34a89e0
--- /dev/null
+++ b/src/Godosa.Desktop/Buttons.cs
@@ -0,0 +1,15 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Interactivity;
+
+namespace Godosa.Desktop;
+
+public static class Buttons
+{
+ /// <summary>Presses a button as a click would: toggles a ToggleButton, else raises Click.</summary>
+ public static void Press(Button button)
+ {
+ if (button is ToggleButton toggle) toggle.IsChecked = toggle.IsChecked != true;
+ else button.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
+ }
+}
diff --git a/src/Godosa.Desktop/CursorWarp.cs b/src/Godosa.Desktop/CursorWarp.cs
new file mode 100644
index 0000000..c44342d
--- /dev/null
+++ b/src/Godosa.Desktop/CursorWarp.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Runtime.InteropServices;
+using Avalonia;
+using Avalonia.Controls;
+using Godosa.Core.Diagnostics;
+
+namespace Godosa.Desktop;
+
+/// <summary>
+/// Moves the OS pointer to a point in a visual (free look recentering, camera
+/// spec §3: Avalonia 12.1.2 has no pointer lock). X11/XWayland via libX11
+/// (XWayland turns a warp under a hidden cursor into a pointer lock), Windows
+/// via user32. Returns false when unsupported or on any failure.
+/// </summary>
+public static partial class CursorWarp
+{
+ private static IntPtr _display;
+
+ public static bool TryWarp(Visual visual, Point viewPoint)
+ {
+ try
+ {
+ var topLevel = TopLevel.GetTopLevel(visual);
+ var handle = topLevel?.TryGetPlatformHandle();
+ if (topLevel is null || handle is null) return false;
+
+ if (OperatingSystem.IsWindows())
+ {
+ var screen = visual.PointToScreen(viewPoint);
+ return SetCursorPos(screen.X, screen.Y);
+ }
+ if (OperatingSystem.IsLinux() && handle.HandleDescriptor == "XID")
+ {
+ if (visual.TranslatePoint(viewPoint, topLevel) is not { } p) return false;
+ if (_display == IntPtr.Zero) _display = XOpenDisplay(IntPtr.Zero);
+ if (_display == IntPtr.Zero) return false;
+ double scaling = topLevel.RenderScaling;
+ XWarpPointer(_display, 0, (nuint)handle.Handle, 0, 0, 0, 0,
+ (int)Math.Round(p.X * scaling), (int)Math.Round(p.Y * scaling));
+ XFlush(_display);
+ return true;
+ }
+ if (EngineDebug.Input) EngineDebug.Write("input", $"cursor warp unsupported (handle {handle.HandleDescriptor})");
+ return false;
+ }
+ catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException)
+ {
+ if (EngineDebug.Input) EngineDebug.Write("input", $"cursor warp failed: {ex.Message}");
+ return false;
+ }
+ }
+
+ [LibraryImport("libX11.so.6")]
+ private static partial IntPtr XOpenDisplay(IntPtr displayName);
+
+ [LibraryImport("libX11.so.6")]
+ private static partial int XWarpPointer(IntPtr display, nuint srcWindow, nuint destWindow,
+ int srcX, int srcY, uint srcWidth, uint srcHeight, int destX, int destY);
+
+ [LibraryImport("libX11.so.6")]
+ private static partial int XFlush(IntPtr display);
+
+ [LibraryImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static partial bool SetCursorPos(int x, int y);
+}
diff --git a/src/Godosa.Desktop/DisplayMode.cs b/src/Godosa.Desktop/DisplayMode.cs
new file mode 100644
index 0000000..527fa43
--- /dev/null
+++ b/src/Godosa.Desktop/DisplayMode.cs
@@ -0,0 +1,19 @@
+using Avalonia.Controls;
+using Avalonia.Media;
+
+namespace Godosa.Desktop;
+
+/// <summary>Window fullscreen and a UI layer's scale (the 3D view stays outside that layer).</summary>
+public static class DisplayMode
+{
+ public static void SetFullscreen(Window? window, bool fullscreen)
+ {
+ if (window is null) return;
+ bool full = window.WindowState == WindowState.FullScreen;
+ if (fullscreen && !full) window.WindowState = WindowState.FullScreen;
+ else if (!fullscreen && full) window.WindowState = WindowState.Normal;
+ }
+
+ public static void SetUiScale(LayoutTransformControl layer, double factor) =>
+ layer.LayoutTransform = factor == 1.0 ? null : new ScaleTransform(factor, factor);
+}
diff --git a/src/Godosa.Desktop/FrameTimeGraph.cs b/src/Godosa.Desktop/FrameTimeGraph.cs
new file mode 100644
index 0000000..7e59a17
--- /dev/null
+++ b/src/Godosa.Desktop/FrameTimeGraph.cs
@@ -0,0 +1,65 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+using Godosa.Core.Diagnostics;
+
+namespace Godosa.Desktop;
+
+/// <summary>
+/// Frame-time bar graph (game-loop spec §5): one 1-px bar per frame in the
+/// stats window, newest at the right, 0–50 ms scale, dashed guides at 16.7 ms
+/// (60 fps) and 33.3 ms (30 fps). Bars are colored relative to the window
+/// median (green ≤ 1.5×, yellow ≤ 2×, red = hitch) so ordinary vsync jitter
+/// stays green whatever the refresh rate. Drawn by Avalonia, not GL, so it
+/// doesn't perturb the GL frames it measures. Presentation only.
+/// </summary>
+public sealed class FrameTimeGraph : Control
+{
+ private const float ScaleMs = 50f;
+ private const float Ms60 = 1000f / 60f;
+ private const float Ms30 = 1000f / 30f;
+
+ private static readonly IBrush Backdrop = new SolidColorBrush(Color.FromArgb(90, 255, 255, 255));
+ private static readonly IBrush Good = new SolidColorBrush(Color.FromRgb(80, 200, 120));
+ private static readonly IBrush Slow = new SolidColorBrush(Color.FromRgb(230, 200, 60));
+ private static readonly IBrush Bad = new SolidColorBrush(Color.FromRgb(230, 70, 60));
+ private static readonly IPen Guide = new Pen(new SolidColorBrush(Color.FromArgb(170, 255, 255, 255)), 1, DashStyle.Dash);
+
+ private readonly FrameTimeStats _stats;
+ private readonly float[] _buffer;
+
+ public FrameTimeGraph(FrameTimeStats stats)
+ {
+ _stats = stats;
+ _buffer = new float[stats.Capacity];
+ Width = stats.Capacity;
+ Height = 60;
+ }
+
+ public override void Render(DrawingContext context)
+ {
+ double w = Bounds.Width, h = Bounds.Height;
+ context.FillRectangle(Backdrop, new Rect(0, 0, w, h));
+
+ int n = _stats.CopyIntervalsMs(_buffer);
+ float median = (float)_stats.Summarize().P50Ms;
+ double x0 = w - n;
+ for (int i = 0; i < n; i++)
+ {
+ float ms = _buffer[i];
+ double barHeight = Math.Min(ms / ScaleMs, 1f) * h;
+ var brush = ms > 2f * median ? Bad : ms > 1.5f * median ? Slow : Good;
+ context.FillRectangle(brush, new Rect(x0 + i, h - barHeight, 1, barHeight));
+ }
+
+ DrawGuide(context, Ms60, w, h);
+ DrawGuide(context, Ms30, w, h);
+ }
+
+ private static void DrawGuide(DrawingContext context, float ms, double w, double h)
+ {
+ double y = h - ms / ScaleMs * h;
+ context.DrawLine(Guide, new Point(0, y), new Point(w, y));
+ }
+}
diff --git a/src/Godosa.Desktop/GlProgram.cs b/src/Godosa.Desktop/GlProgram.cs
new file mode 100644
index 0000000..25ebe5c
--- /dev/null
+++ b/src/Godosa.Desktop/GlProgram.cs
@@ -0,0 +1,35 @@
+using Silk.NET.OpenGL;
+
+namespace Godosa.Desktop;
+
+/// <summary>Shader program helpers: compile + link with the driver's info log in the
+/// exception, so a bad shader fails loudly at startup instead of drawing nothing.</summary>
+public static class GlProgram
+{
+ public static uint Link(GL gl, string vertexSource, string fragmentSource)
+ {
+ uint vs = Compile(gl, ShaderType.VertexShader, vertexSource);
+ uint fs = Compile(gl, ShaderType.FragmentShader, fragmentSource);
+ uint program = gl.CreateProgram();
+ gl.AttachShader(program, vs);
+ gl.AttachShader(program, fs);
+ gl.LinkProgram(program);
+ gl.GetProgram(program, ProgramPropertyARB.LinkStatus, out int linkStatus);
+ if (linkStatus == 0)
+ throw new InvalidOperationException($"Shader link failed: {gl.GetProgramInfoLog(program)}");
+ gl.DeleteShader(vs);
+ gl.DeleteShader(fs);
+ return program;
+ }
+
+ public static uint Compile(GL gl, ShaderType type, string source)
+ {
+ uint shader = gl.CreateShader(type);
+ gl.ShaderSource(shader, source);
+ gl.CompileShader(shader);
+ gl.GetShader(shader, ShaderParameterName.CompileStatus, out int status);
+ if (status == 0)
+ throw new InvalidOperationException($"Shader compile failed: {gl.GetShaderInfoLog(shader)}");
+ return shader;
+ }
+}
diff --git a/src/Godosa.Desktop/Godosa.Desktop.csproj b/src/Godosa.Desktop/Godosa.Desktop.csproj
new file mode 100644
index 0000000..0be77f4
--- /dev/null
+++ b/src/Godosa.Desktop/Godosa.Desktop.csproj
@@ -0,0 +1,12 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
+ </PropertyGroup>
+ <ItemGroup>
+ <PackageReference Include="Avalonia" />
+ <PackageReference Include="Silk.NET.OpenGL" />
+ </ItemGroup>
+ <ItemGroup>
+ <ProjectReference Include="../Godosa.Core/Godosa.Core.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/src/Godosa.Desktop/GpuTimer.cs b/src/Godosa.Desktop/GpuTimer.cs
new file mode 100644
index 0000000..11076e8
--- /dev/null
+++ b/src/Godosa.Desktop/GpuTimer.cs
@@ -0,0 +1,71 @@
+using System;
+using Silk.NET.OpenGL;
+
+namespace Godosa.Desktop;
+
+/// <summary>GPU time per frame from GL_TIME_ELAPSED queries (game-loop spec §6). A small
+/// ring so reading a result never stalls on the frame still in flight; disables itself
+/// when the context has no timer queries (e.g. GLES without the extension).</summary>
+public sealed class GpuTimer
+{
+ private const int Ring = 4;
+ private readonly uint[] _queries = new uint[Ring];
+ private readonly bool[] _pending = new bool[Ring];
+ private int _current;
+ private bool _open;
+
+ public bool Supported { get; private set; }
+
+ public void Init(GL gl)
+ {
+ try
+ {
+ string version = gl.GetStringS(StringName.Version) ?? "";
+ if (version.Contains("OpenGL ES", StringComparison.Ordinal)) return;
+ for (int i = 0; i < Ring; i++) _queries[i] = gl.GenQuery();
+ Supported = gl.GetError() == GLEnum.NoError;
+ }
+ catch (Exception)
+ {
+ Supported = false;
+ }
+ }
+
+ public void Begin(GL gl)
+ {
+ if (!Supported || _pending[_current]) return; // slot still in flight: skip this frame
+ gl.BeginQuery(QueryTarget.TimeElapsed, _queries[_current]);
+ _open = true;
+ }
+
+ public void End(GL gl)
+ {
+ if (!_open) return;
+ gl.EndQuery(QueryTarget.TimeElapsed);
+ _pending[_current] = true;
+ _open = false;
+ _current = (_current + 1) % Ring;
+ }
+
+ /// <summary>Seconds for each finished query since the last call.</summary>
+ public void Collect(GL gl, Action<double> onResult)
+ {
+ if (!Supported) return;
+ for (int i = 0; i < Ring; i++)
+ {
+ if (!_pending[i]) continue;
+ gl.GetQueryObject(_queries[i], QueryObjectParameterName.ResultAvailable, out int available);
+ if (available == 0) continue;
+ gl.GetQueryObject(_queries[i], QueryObjectParameterName.Result, out ulong nanoseconds);
+ _pending[i] = false;
+ onResult(nanoseconds / 1e9);
+ }
+ }
+
+ public void Delete(GL gl)
+ {
+ if (!Supported) return;
+ for (int i = 0; i < Ring; i++) gl.DeleteQuery(_queries[i]);
+ Supported = false;
+ }
+}
diff --git a/src/Godosa.Desktop/InputScriptPlayer.cs b/src/Godosa.Desktop/InputScriptPlayer.cs
new file mode 100644
index 0000000..2aad679
--- /dev/null
+++ b/src/Godosa.Desktop/InputScriptPlayer.cs
@@ -0,0 +1,124 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using Godosa.Core.Diagnostics;
+using Godosa.Core.Input;
+
+namespace Godosa.Desktop;
+
+/// <summary>Replays the game's input-script file (Core <see cref="InputScript"/>, path in the
+/// variable given to <see cref="Configure"/>) as real
+/// routed key events to the focused control and clicks on visible buttons, so smoke runs
+/// can drive the game without X key injection (which never reaches a focused window on
+/// KDE Wayland). Log category <c>script</c> (<see cref="EngineDebug"/>).</summary>
+public sealed class InputScriptPlayer
+{
+ private readonly Window _window;
+ private readonly InputScript _script;
+ private readonly Stopwatch _clock = Stopwatch.StartNew();
+ private readonly List<ScriptEvent> _due = new();
+ private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromMilliseconds(10) };
+
+ private static string? _variable;
+ private readonly Func<Window, Interactive?>? _keyTarget;
+
+ /// <summary>True while a script drives the game: window focus no longer pauses or
+ /// releases input.</summary>
+ public static bool Active { get; private set; }
+
+ /// <summary>Scripted gamepad (<c>PadA … PadRT</c>, see <see cref="GamepadScript"/>).</summary>
+ public static GamepadState Pad { get; private set; }
+
+ /// <summary>Call first in <c>Main</c>: names the game's script variable.</summary>
+ public static void Configure(string environmentVariable)
+ {
+ _variable = environmentVariable;
+ Active = Environment.GetEnvironmentVariable(environmentVariable) is { Length: > 0 };
+ }
+
+ private InputScriptPlayer(Window window, InputScript script, Func<Window, Interactive?>? keyTarget)
+ {
+ _window = window;
+ _script = script;
+ _keyTarget = keyTarget;
+ _timer.Tick += (_, _) => Step();
+ _timer.Start();
+ }
+
+ /// <param name="keyTarget">Where keys go when nothing has focus (e.g. the game view); the window otherwise.</param>
+ public static InputScriptPlayer? Start(Window window, Func<Window, Interactive?>? keyTarget = null)
+ {
+ if (!Active) return null;
+ string path = Environment.GetEnvironmentVariable(_variable!)!;
+ var script = InputScript.Parse(File.ReadAllText(path));
+ if (EngineDebug.Script) EngineDebug.Write("script", $"{script.Events.Count} events from {path}");
+ return new InputScriptPlayer(window, script, keyTarget);
+ }
+
+ private void Step()
+ {
+ _script.Due(_clock.Elapsed.TotalSeconds, _due);
+ foreach (var e in _due)
+ {
+ bool ok = e.Action switch
+ {
+ ScriptAction.Click => Click(e.Arg),
+ ScriptAction.Type => Type(e.Arg),
+ _ => Key(e.Action == ScriptAction.Down, e.Arg),
+ };
+ if (EngineDebug.Script) EngineDebug.Write("script", $"t={_clock.Elapsed.TotalSeconds:F2} {e.Action} {e.Arg}{(ok ? "" : " (FAILED)")}");
+ }
+ _due.Clear();
+ if (_script.Finished) _timer.Stop();
+ }
+
+ private bool Key(bool down, string name)
+ {
+ if (name.StartsWith("Pad", StringComparison.OrdinalIgnoreCase))
+ {
+ if (GamepadScript.Apply(Pad, name, down) is not { } next) return false;
+ Pad = next;
+ return true;
+ }
+ if (!Enum.TryParse(name, ignoreCase: true, out PhysicalKey physical)) return false;
+ Enum.TryParse(name, ignoreCase: true, out Key key);
+ var target = _window.FocusManager?.GetFocusedElement() as Interactive
+ ?? _keyTarget?.Invoke(_window)
+ ?? _window;
+ target.RaiseEvent(new KeyEventArgs
+ {
+ RoutedEvent = down ? InputElement.KeyDownEvent : InputElement.KeyUpEvent,
+ PhysicalKey = physical,
+ Key = key,
+ Source = target,
+ });
+ return true;
+ }
+
+ private bool Type(string text)
+ {
+ if (_window.FocusManager?.GetFocusedElement() is not TextBox box) return false;
+ box.Text = text;
+ return true;
+ }
+
+ private bool Click(string label)
+ {
+ var buttons = _window.GetVisualDescendants().OfType<Button>()
+ .Where(b => b.IsEffectivelyVisible && b.IsEffectivelyEnabled).ToList();
+ string Text(Button b) => b.Content?.ToString()?.Trim() ?? "";
+ var button = buttons.FirstOrDefault(b => Text(b).Equals(label, StringComparison.OrdinalIgnoreCase)) // exact label first
+ ?? buttons.FirstOrDefault(b => b.Name == label) // steppers: fov+ / fov-
+ ?? buttons.FirstOrDefault(b => Text(b).Contains(label, StringComparison.OrdinalIgnoreCase));
+ if (button is not null) Buttons.Press(button);
+ return button is not null;
+ }
+}
diff --git a/src/Godosa.Desktop/PadNavigator.cs b/src/Godosa.Desktop/PadNavigator.cs
new file mode 100644
index 0000000..b4b59d5
--- /dev/null
+++ b/src/Godosa.Desktop/PadNavigator.cs
@@ -0,0 +1,85 @@
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.VisualTree;
+using Avalonia.Media;
+using Godosa.Core.Input;
+
+namespace Godosa.Desktop;
+
+/// <summary>Gamepad control of an open window: D-pad moves a highlight through its enabled
+/// buttons (wrapping), A presses (the game decides B/Start).</summary>
+public sealed class PadNavigator
+{
+ private static readonly Thickness HighlightThickness = new(2);
+ private readonly MenuCursor _cursor = new();
+ private readonly List<Button> _buttons = new();
+ private Control? _container;
+ private Button? _highlighted;
+ private readonly IBrush _highlight;
+
+ public PadNavigator(IBrush highlight) => _highlight = highlight;
+
+ /// <summary>The window pad input should drive now, or null (pad drives the character).</summary>
+ public Control? Active { get; private set; }
+
+ /// <summary>Highlight only while the gamepad is the device in use (keyboard/mouse users
+ /// get no stray gold border).</summary>
+ public bool ShowHighlight { get; set; }
+
+ public void Refresh(Control? active)
+ {
+ Active = active;
+ if (!ReferenceEquals(active, _container))
+ {
+ _container = active;
+ _cursor.Clamp(0);
+ }
+ Collect();
+ _cursor.Clamp(_buttons.Count);
+ Highlight();
+ }
+
+ public void Move(int delta)
+ {
+ Collect();
+ _cursor.Clamp(_buttons.Count);
+ _cursor.Move(delta, _buttons.Count);
+ Highlight();
+ }
+
+ public void Press()
+ {
+ Collect();
+ _cursor.Clamp(_buttons.Count);
+ if (_cursor.Index >= 0) Buttons.Press(_buttons[_cursor.Index]);
+ }
+
+ private void Collect()
+ {
+ _buttons.Clear();
+ if (_container is null) return;
+ _buttons.AddRange(_container.GetVisualDescendants().OfType<Button>()
+ .Where(b => b.IsEffectivelyVisible && b.IsEffectivelyEnabled));
+ }
+
+ private void Highlight()
+ {
+ var target = ShowHighlight && _cursor.Index >= 0 && _cursor.Index < _buttons.Count ? _buttons[_cursor.Index] : null;
+ if (ReferenceEquals(target, _highlighted)) return;
+ if (_highlighted is not null)
+ {
+ _highlighted.ClearValue(Button.BorderBrushProperty);
+ _highlighted.ClearValue(Button.BorderThicknessProperty);
+ }
+ _highlighted = target;
+ if (target is not null)
+ {
+ target.BorderBrush = _highlight;
+ target.BorderThickness = HighlightThickness;
+ target.BringIntoView(); // long option pages scroll
+ }
+ }
+}