From 022567da43ea3bfb287e25672d41d3636ca7b564 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 25 May 2026 22:45:46 +0100 Subject: [PATCH] Add H264 Eve dashboard relay --- Mimir.slnx | 1 + docs/eve-program-output.md | 30 +- scripts/start-eve-dashboard-stream.ps1 | 135 ++++ src/Mimir.EveRelay/Mimir.EveRelay.csproj | 31 + src/Mimir.EveRelay/Program.cs | 827 +++++++++++++++++++++++ 5 files changed, 1018 insertions(+), 6 deletions(-) create mode 100644 scripts/start-eve-dashboard-stream.ps1 create mode 100644 src/Mimir.EveRelay/Mimir.EveRelay.csproj create mode 100644 src/Mimir.EveRelay/Program.cs diff --git a/Mimir.slnx b/Mimir.slnx index 84ccf97..dc580d3 100644 --- a/Mimir.slnx +++ b/Mimir.slnx @@ -1,6 +1,7 @@ + diff --git a/docs/eve-program-output.md b/docs/eve-program-output.md index 2e8db5b..3f0f05e 100644 --- a/docs/eve-program-output.md +++ b/docs/eve-program-output.md @@ -27,19 +27,36 @@ dotnet run --project .\src\Mimir.App\Mimir.App.csproj ``` Fensalir creates a shareable D3D12 texture with that name and copies the finished -backbuffer into it during the present pass. A hardware encoder process should -open that named texture, encode H.264/HEVC with GPU hardware, and send frames to -EVE's native receiver. +backbuffer into it during the present pass. `Mimir.EveRelay` opens that named +texture, reads the completed program frame, feeds a low-latency H.264 encoder, +and sends complete Annex-B access units to EveCanvas over the existing +`/stream` WebSocket. EveCanvas treats JPEG frames as the old VoidBot dashboard +fallback only; Mimir dashboard streaming is `h264-annexb` and displays through a +native `AVSampleBufferDisplayLayer` so the iPad owns hardware decode/composite. -`scripts/start-eve-program-output.ps1` starts the Mimir/Fensalir app with the -shared-texture output enabled and verifies the `eve` SSH target before launch. +`scripts/start-eve-dashboard-stream.ps1` starts the Mimir/Fensalir app with the +shared-texture output enabled, starts the H.264 relay, verifies the `eve` SSH +target, opens a reverse SSH tunnel from Eve's `127.0.0.1:8792` back to the +local relay when direct inbound TCP is blocked, and launches EveCanvas: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\start-eve-dashboard-stream.ps1 +``` + +The relay defaults to `h264_nvenc`. Use `-Encoder libx264` only as a diagnostic +fallback when no NVENC-capable FFmpeg is installed. + +EveCanvas tries `ws://127.0.0.1:8792/stream` first, then falls back to +`ws://192.168.1.66:8792/stream`. The localhost path is not JPEG tunneling; it is +the same H.264 Annex-B stream carried through SSH port forwarding so a Windows +firewall rule cannot silently turn the dashboard into a wall. ## Receiver Contract EVE owns: - network receive; -- hardware decode; +- hardware decode of H.264 access units; - texture upload/import; - final fullscreen composite; - touch/Pencil/sensor uplink later. @@ -56,4 +73,5 @@ EVE does not own: ```powershell dotnet run --project .\src\Mimir.BufferSmoke\Mimir.BufferSmoke.csproj -- --eve-program-output-contract-smoke +dotnet build .\src\Mimir.EveRelay\Mimir.EveRelay.csproj --no-restore --disable-build-servers ``` diff --git a/scripts/start-eve-dashboard-stream.ps1 b/scripts/start-eve-dashboard-stream.ps1 new file mode 100644 index 0000000..14c32e6 --- /dev/null +++ b/scripts/start-eve-dashboard-stream.ps1 @@ -0,0 +1,135 @@ +param( + [string] $Target = "eve", + [string] $SharedTextureName = "Global\MimirFensalirProgramTexture", + [string] $Configuration = "Debug", + [int] $Width = 1620, + [int] $Height = 2160, + [int] $Fps = 30, + [int] $Port = 8792, + [string] $LanHost = "192.168.1.66", + [string] $Encoder = "h264_nvenc", + [int] $Quality = 26, + [string] $FfmpegPath = "", + [switch] $NoReverseTunnel, + [string] $MimirLogPath = "logs\eve-dashboard-mimir.log", + [string] $RelayLogPath = "logs\eve-dashboard-relay.log", + [string] $TunnelLogPath = "logs\eve-dashboard-ssh-tunnel.log", + [string[]] $AppArguments = @() +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$mimirLog = Join-Path $repoRoot $MimirLogPath +$mimirErr = [System.IO.Path]::ChangeExtension($mimirLog, ".err.log") +$relayLog = Join-Path $repoRoot $RelayLogPath +$relayErr = [System.IO.Path]::ChangeExtension($relayLog, ".err.log") +$tunnelLog = Join-Path $repoRoot $TunnelLogPath +$tunnelErr = [System.IO.Path]::ChangeExtension($tunnelLog, ".err.log") +New-Item -ItemType Directory -Path (Split-Path -Parent $mimirLog) -Force | Out-Null +New-Item -ItemType Directory -Path (Split-Path -Parent $relayLog) -Force | Out-Null +New-Item -ItemType Directory -Path (Split-Path -Parent $tunnelLog) -Force | Out-Null + +Write-Host "Checking EVE SSH target: $Target" +ssh $Target "uname -a >/tmp/mimir-eve-dashboard-check.txt && cat /tmp/mimir-eve-dashboard-check.txt" + +$env:FENSALIR_PROGRAM_OUTPUT_D3D12 = "1" +$env:FENSALIR_PROGRAM_OUTPUT_NAME = $SharedTextureName + +$mimirProject = Join-Path $repoRoot "src\Mimir.App\Mimir.App.csproj" +$mimirArgs = @( + "run", + "--project", + $mimirProject, + "-c", + $Configuration, + "--" +) + $AppArguments + +Write-Host "Starting Mimir/Fensalir program output: $SharedTextureName" +$mimirProcess = Start-Process -FilePath "dotnet" ` + -ArgumentList $mimirArgs ` + -WorkingDirectory $repoRoot ` + -RedirectStandardOutput $mimirLog ` + -RedirectStandardError $mimirErr ` + -WindowStyle Hidden ` + -PassThru + +Write-Host "Mimir PID $($mimirProcess.Id); waiting for shared texture publication..." +$deadline = [DateTimeOffset]::Now.AddSeconds(30) +do { + Start-Sleep -Milliseconds 250 + $mimirText = if (Test-Path $mimirLog) { Get-Content -Raw $mimirLog } else { "" } + if ($mimirText -match "D3D12 program output shared texture") { + break + } +} while ([DateTimeOffset]::Now -lt $deadline -and -not $mimirProcess.HasExited) + +if ($mimirProcess.HasExited) { + throw "Mimir exited before publishing the shared program texture. Check $mimirLog and $mimirErr." +} + +$relayProject = Join-Path $repoRoot "src\Mimir.EveRelay\Mimir.EveRelay.csproj" +$relayArgs = @( + "run", + "--project", + $relayProject, + "-c", + $Configuration, + "--no-restore", + "--", + "--shared-texture", $SharedTextureName, + "--width", $Width, + "--height", $Height, + "--fps", $Fps, + "--port", $Port, + "--lan-host", $LanHost, + "--encoder", $Encoder, + "--quality", $Quality +) +if (-not [string]::IsNullOrWhiteSpace($FfmpegPath)) { + $relayArgs += @("--ffmpeg", $FfmpegPath) +} + +Write-Host "Starting H.264 Eve relay on port $Port" +$relayProcess = Start-Process -FilePath "dotnet" ` + -ArgumentList $relayArgs ` + -WorkingDirectory $repoRoot ` + -RedirectStandardOutput $relayLog ` + -RedirectStandardError $relayErr ` + -WindowStyle Hidden ` + -PassThru + +if (-not $NoReverseTunnel) { + $forwardPattern = "-R ${Port}:127.0.0.1:${Port}" + $existingTunnel = Get-CimInstance Win32_Process -Filter "name = 'ssh.exe'" | + Where-Object { $_.CommandLine -like "*$forwardPattern*" -and $_.CommandLine -like "* $Target*" } | + Select-Object -First 1 + + if ($existingTunnel) { + Write-Host "Using existing EVE reverse tunnel PID $($existingTunnel.ProcessId)." + } else { + Write-Host "Starting EVE reverse tunnel: $Target 127.0.0.1:$Port -> Starfire 127.0.0.1:$Port" + $tunnelProcess = Start-Process -FilePath "ssh" ` + -ArgumentList @("-N", "-R", "${Port}:127.0.0.1:${Port}", $Target) ` + -RedirectStandardOutput $tunnelLog ` + -RedirectStandardError $tunnelErr ` + -WindowStyle Hidden ` + -PassThru + Write-Host "Tunnel PID $($tunnelProcess.Id)." + } + + Start-Sleep -Milliseconds 500 + ssh $Target "curl -s --max-time 3 http://127.0.0.1:$Port/health || true" +} + +Write-Host "Relay PID $($relayProcess.Id). Launching EveCanvas." +ssh $Target "uiopen --bundleid org.gamecult.evecanvas" + +Write-Host "Poll with:" +Write-Host " Get-Content -Tail 40 '$mimirLog'" +Write-Host " Get-Content -Tail 40 '$mimirErr'" +Write-Host " Get-Content -Tail 40 '$relayLog'" +Write-Host " Get-Content -Tail 40 '$relayErr'" +Write-Host " Get-Content -Tail 40 '$tunnelLog'" +Write-Host " Get-Content -Tail 40 '$tunnelErr'" diff --git a/src/Mimir.EveRelay/Mimir.EveRelay.csproj b/src/Mimir.EveRelay/Mimir.EveRelay.csproj new file mode 100644 index 0000000..1fc5ca4 --- /dev/null +++ b/src/Mimir.EveRelay/Mimir.EveRelay.csproj @@ -0,0 +1,31 @@ + + + + Exe + net10.0 + Mimir.EveRelay + Mimir.EveRelay + enable + enable + true + + + + + $(UserProfile)\.nuget\packages\vortice.direct3d12\3.8.3\lib\net10.0\Vortice.Direct3D12.dll + + + $(UserProfile)\.nuget\packages\vortice.dxgi\3.8.3\lib\net10.0\Vortice.DXGI.dll + + + $(UserProfile)\.nuget\packages\vortice.directx\3.8.3\lib\net10.0\Vortice.DirectX.dll + + + $(UserProfile)\.nuget\packages\vortice.mathematics\2.1.1\lib\net10.0\Vortice.Mathematics.dll + + + $(UserProfile)\.nuget\packages\sharpgen.runtime\2.4.2-beta\lib\net9.0\SharpGen.Runtime.dll + + + + diff --git a/src/Mimir.EveRelay/Program.cs b/src/Mimir.EveRelay/Program.cs new file mode 100644 index 0000000..9a2fd79 --- /dev/null +++ b/src/Mimir.EveRelay/Program.cs @@ -0,0 +1,827 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Net.WebSockets; +using System.Runtime.InteropServices; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Vortice.Direct3D; +using Vortice.Direct3D12; +using Vortice.DXGI; + +var config = EveRelayConfig.Parse(args); +using var relay = await MimirEveRelay.StartAsync(config); +await relay.RunAsync(); + +internal sealed class MimirEveRelay : IDisposable +{ + private readonly EveRelayConfig config; + private readonly D3D12SharedTextureReader textureReader; + private readonly HardwareH264Encoder encoder; + private readonly EveWebSocketServer server; + private readonly CancellationTokenSource stopping = new(); + + private MimirEveRelay( + EveRelayConfig config, + D3D12SharedTextureReader textureReader, + HardwareH264Encoder encoder, + EveWebSocketServer server) + { + this.config = config; + this.textureReader = textureReader; + this.encoder = encoder; + this.server = server; + } + + public static async Task StartAsync(EveRelayConfig config) + { + var textureReader = D3D12SharedTextureReader.Open(config.SharedTextureName, config.Width, config.Height); + var server = new EveWebSocketServer(config, textureReader.Width, textureReader.Height); + var encoder = HardwareH264Encoder.Start(config, textureReader.Width, textureReader.Height, server.BroadcastBinaryAsync); + var relay = new MimirEveRelay(config, textureReader, encoder, server); + await server.StartAsync(); + Console.WriteLine($"Mimir Eve relay listening: ws://{config.LanHost}:{config.Port}/stream"); + Console.WriteLine($"Source: {config.SharedTextureName} {textureReader.Width}x{textureReader.Height}"); + Console.WriteLine($"Codec: h264-annexb via {config.Encoder}"); + return relay; + } + + public async Task RunAsync() + { + var frameInterval = TimeSpan.FromSeconds(1.0 / Math.Clamp(config.Fps, 1, 120)); + var nextFrame = Stopwatch.GetTimestamp(); + var stopwatchFrequency = Stopwatch.Frequency; + var frames = 0L; + var lastReport = Stopwatch.GetTimestamp(); + + while (!stopping.IsCancellationRequested) + { + var now = Stopwatch.GetTimestamp(); + if (now < nextFrame) + { + var delay = TimeSpan.FromSeconds((nextFrame - now) / (double)stopwatchFrequency); + await Task.Delay(delay, stopping.Token).ConfigureAwait(false); + } + + textureReader.CopyNextFrameTo(encoder.Input); + await encoder.Input.FlushAsync(stopping.Token).ConfigureAwait(false); + frames++; + nextFrame += (long)(frameInterval.TotalSeconds * stopwatchFrequency); + + var reportNow = Stopwatch.GetTimestamp(); + if ((reportNow - lastReport) / (double)stopwatchFrequency >= 2.0) + { + Console.WriteLine($"Mimir Eve relay: frames={frames} clients={server.ClientCount} encodedBytes={encoder.EncodedBytes}"); + lastReport = reportNow; + } + } + } + + public void Dispose() + { + stopping.Cancel(); + encoder.Dispose(); + server.Dispose(); + textureReader.Dispose(); + stopping.Dispose(); + } +} + +internal sealed class HardwareH264Encoder : IDisposable +{ + private readonly Process process; + private readonly Task outputTask; + private readonly Task errorTask; + private readonly H264AnnexBAccessUnitFramer framer = new(); + private long encodedBytes; + + private HardwareH264Encoder(Process process, Func broadcast) + { + this.process = process; + Input = process.StandardInput.BaseStream; + outputTask = Task.Run(async () => + { + var buffer = new byte[64 * 1024]; + while (!process.HasExited) + { + var read = await process.StandardOutput.BaseStream.ReadAsync(buffer).ConfigureAwait(false); + if (read <= 0) + { + break; + } + + foreach (var accessUnit in framer.Push(buffer.AsSpan(0, read).ToArray())) + { + Interlocked.Add(ref encodedBytes, accessUnit.Length); + await broadcast(accessUnit).ConfigureAwait(false); + } + } + }); + errorTask = Task.Run(async () => + { + while (!process.StandardError.EndOfStream) + { + var line = await process.StandardError.ReadLineAsync().ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(line)) + { + Console.Error.WriteLine($"ffmpeg: {line}"); + } + } + }); + } + + public Stream Input { get; } + + public long EncodedBytes => Interlocked.Read(ref encodedBytes); + + public static HardwareH264Encoder Start( + EveRelayConfig config, + int width, + int height, + Func broadcast) + { + var ffmpeg = ResolveFfmpeg(config.FfmpegPath); + var arguments = BuildArguments(config, width, height); + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = ffmpeg, + Arguments = arguments, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }, + EnableRaisingEvents = true, + }; + process.Start(); + return new HardwareH264Encoder(process, broadcast); + } + + public void Dispose() + { + try + { + Input.Dispose(); + } + catch + { + } + + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + + Task.WaitAll([outputTask, errorTask], TimeSpan.FromSeconds(1)); + process.Dispose(); + } + + private static string BuildArguments(EveRelayConfig config, int width, int height) + { + var common = $"-hide_banner -loglevel warning -f rawvideo -pix_fmt bgra -s {width}x{height} -r {config.Fps} -i pipe:0 -an "; + return config.Encoder.Equals("libx264", StringComparison.OrdinalIgnoreCase) + ? common + $"-c:v libx264 -preset ultrafast -tune zerolatency -x264-params keyint={config.Fps}:min-keyint={config.Fps}:scenecut=0 -pix_fmt yuv420p -f h264 pipe:1" + : common + $"-c:v h264_nvenc -preset p1 -tune ull -rc constqp -qp {config.Quality} -g {config.Fps} -bf 0 -pix_fmt yuv420p -f h264 pipe:1"; + } + + private static string ResolveFfmpeg(string? configuredPath) + { + if (!string.IsNullOrWhiteSpace(configuredPath) && File.Exists(configuredPath)) + { + return configuredPath; + } + + var pathValue = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + foreach (var directory in pathValue.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + var candidate = Path.Combine(directory, "ffmpeg.exe"); + if (File.Exists(candidate)) + { + return candidate; + } + } + + foreach (var candidate in new[] + { + @"C:\ffmpeg\bin\ffmpeg.exe", + @"C:\ProgramData\chocolatey\bin\ffmpeg.exe", + @"C:\Users\Meta\scoop\shims\ffmpeg.exe", + }) + { + if (File.Exists(candidate)) + { + return candidate; + } + } + + throw new FileNotFoundException("ffmpeg.exe was not found. Install FFmpeg or pass --ffmpeg ."); + } +} + +internal sealed unsafe class D3D12SharedTextureReader : IDisposable +{ + private const int BytesPerPixel = 4; + + private readonly ID3D12Device device; + private readonly ID3D12CommandQueue queue; + private readonly ID3D12CommandAllocator allocator; + private readonly ID3D12GraphicsCommandList commandList; + private readonly ID3D12Fence fence; + private readonly AutoResetEvent fenceEvent = new(false); + private readonly ID3D12Resource texture; + private readonly ID3D12Resource readback; + private readonly int sourceRowBytes; + private readonly int readbackRowPitch; + private ulong fenceValue; + + private D3D12SharedTextureReader( + ID3D12Device device, + ID3D12CommandQueue queue, + ID3D12CommandAllocator allocator, + ID3D12GraphicsCommandList commandList, + ID3D12Fence fence, + ID3D12Resource texture, + ID3D12Resource readback, + int width, + int height, + int readbackRowPitch) + { + this.device = device; + this.queue = queue; + this.allocator = allocator; + this.commandList = commandList; + this.fence = fence; + this.texture = texture; + this.readback = readback; + Width = width; + Height = height; + this.readbackRowPitch = readbackRowPitch; + sourceRowBytes = checked(width * BytesPerPixel); + } + + public int Width { get; } + + public int Height { get; } + + public static D3D12SharedTextureReader Open(string sharedTextureName, int configuredWidth, int configuredHeight) + { + var device = D3D12.D3D12CreateDevice(IntPtr.Zero, FeatureLevel.Level_11_0); + device.Name = "Mimir Eve Relay D3D12 Device"; + var queue = device.CreateCommandQueue(new CommandQueueDescription(CommandListType.Direct)); + var allocator = device.CreateCommandAllocator(CommandListType.Direct); + var commandList = device.CreateCommandList(0, CommandListType.Direct, allocator, null); + commandList.Close(); + var fence = device.CreateFence(0); + var sharedHandle = device.OpenSharedHandleByName(sharedTextureName); + var texture = device.OpenSharedHandle(sharedHandle); + var description = texture.Description; + var width = checked((int)description.Width); + var height = checked((int)description.Height); + var rowPitch = Align(checked(width * BytesPerPixel), D3D12.TextureDataPitchAlignment); + var readback = device.CreateCommittedResource( + HeapType.Readback, + ResourceDescription.Buffer((ulong)(rowPitch * height)), + ResourceStates.CopyDest, + null); + readback.Name = "Mimir Eve Relay Readback"; + return new D3D12SharedTextureReader(device, queue, allocator, commandList, fence, texture, readback, width, height, rowPitch); + } + + public void CopyNextFrameTo(Stream destination) + { + allocator.Reset(); + commandList.Reset(allocator, null); + var source = new TextureCopyLocation(texture, 0); + var dest = new TextureCopyLocation( + readback, + new PlacedSubresourceFootPrint + { + Offset = 0, + Footprint = new SubresourceFootPrint(Format.B8G8R8A8_UNorm, (uint)Width, (uint)Height, 1, (uint)readbackRowPitch), + }); + commandList.CopyTextureRegion(dest, 0, 0, 0, source, null); + commandList.Close(); + queue.ExecuteCommandList(commandList); + WaitForGpu(); + + var mapped = readback.Map(0); + try + { + for (var row = 0; row < Height; row++) + { + destination.Write(new ReadOnlySpan(mapped + (row * readbackRowPitch), sourceRowBytes)); + } + } + finally + { + readback.Unmap(0, null); + } + } + + public void Dispose() + { + WaitForGpu(); + readback.Dispose(); + texture.Dispose(); + fence.Dispose(); + commandList.Dispose(); + allocator.Dispose(); + queue.Dispose(); + device.Dispose(); + fenceEvent.Dispose(); + } + + private void WaitForGpu() + { + var signalValue = ++fenceValue; + queue.Signal(fence, signalValue); + if (fence.CompletedValue < signalValue) + { + fence.SetEventOnCompletion(signalValue, fenceEvent.SafeWaitHandle.DangerousGetHandle()); + fenceEvent.WaitOne(); + } + } + + private static int Align(int value, int alignment) + { + return (value + alignment - 1) & ~(alignment - 1); + } +} + +internal sealed class EveWebSocketServer : IDisposable +{ + private readonly EveRelayConfig config; + private readonly int width; + private readonly int height; + private readonly TcpListener listener; + private readonly ConcurrentDictionary clients = new(); + private readonly CancellationTokenSource stopping = new(); + + public EveWebSocketServer(EveRelayConfig config, int width, int height) + { + this.config = config; + this.width = width; + this.height = height; + listener = new TcpListener(IPAddress.Any, config.Port); + } + + public int ClientCount => clients.Count; + + public Task StartAsync() + { + listener.Start(); + _ = Task.Run(AcceptLoopAsync); + return Task.CompletedTask; + } + + public async Task BroadcastBinaryAsync(byte[] payload) + { + foreach (var (id, client) in clients) + { + try + { + await client.SendBinaryAsync(payload).ConfigureAwait(false); + } + catch + { + clients.TryRemove(id, out _); + client.Dispose(); + } + } + } + + public void Dispose() + { + stopping.Cancel(); + listener.Stop(); + foreach (var socket in clients.Values) + { + socket.Dispose(); + } + + stopping.Dispose(); + } + + private async Task AcceptLoopAsync() + { + while (!stopping.IsCancellationRequested) + { + var tcpClient = await listener.AcceptTcpClientAsync(stopping.Token).ConfigureAwait(false); + _ = Task.Run(() => HandleAsync(tcpClient)); + } + } + + private async Task HandleAsync(TcpClient tcpClient) + { + await using var stream = tcpClient.GetStream(); + var request = await ReadHttpRequestAsync(stream).ConfigureAwait(false); + if (request.Path == "/health") + { + var health = JsonSerializer.Serialize(new + { + ok = true, + codec = "h264-annexb", + width, + height, + config.DeviceScaleFactor, + clients = clients.Count, + }); + await WriteHttpResponseAsync(stream, "200 OK", "application/json", Encoding.UTF8.GetBytes(health)).ConfigureAwait(false); + return; + } + + if (request.Path != "/stream" || !request.Headers.TryGetValue("Sec-WebSocket-Key", out var key)) + { + await WriteHttpResponseAsync(stream, "404 Not Found", "text/plain", Encoding.UTF8.GetBytes("not found")).ConfigureAwait(false); + return; + } + + await WriteWebSocketHandshakeAsync(stream, key).ConfigureAwait(false); + var id = Guid.NewGuid(); + using var client = new EveSocketClient(tcpClient, stream); + clients[id] = client; + await SendConfigAsync(client).ConfigureAwait(false); + await ReceivePointerLoopAsync(id, client).ConfigureAwait(false); + } + + private Task SendConfigAsync(EveSocketClient socket) + { + var json = JsonSerializer.Serialize(new + { + type = "config", + codec = "h264-annexb", + width, + height, + config.DeviceScaleFactor, + fps = config.Fps, + }); + return socket.SendTextAsync(json); + } + + private async Task ReceivePointerLoopAsync(Guid id, EveSocketClient socket) + { + try + { + while (true) + { + var message = await socket.ReceiveAsync().ConfigureAwait(false); + if (message.Opcode == 0x8) + { + break; + } + + if (message.Opcode == 0x1) + { + var json = Encoding.UTF8.GetString(message.Payload); + Console.WriteLine($"EVE input: {json}"); + } + } + } + finally + { + clients.TryRemove(id, out _); + } + } + + private static async Task ReadHttpRequestAsync(NetworkStream stream) + { + var bytes = new List(); + var lastFour = new Queue(4); + while (true) + { + var value = stream.ReadByte(); + if (value < 0) + { + throw new IOException("Client closed before HTTP headers completed."); + } + + var b = (byte)value; + bytes.Add(b); + lastFour.Enqueue(b); + if (lastFour.Count > 4) + { + lastFour.Dequeue(); + } + + if (lastFour.Count == 4 && lastFour.SequenceEqual(new byte[] { 13, 10, 13, 10 })) + { + break; + } + } + + var text = Encoding.ASCII.GetString(bytes.ToArray()); + var lines = text.Split("\r\n", StringSplitOptions.None); + var first = lines[0].Split(' '); + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var line in lines.Skip(1)) + { + var colon = line.IndexOf(':'); + if (colon > 0) + { + headers[line[..colon].Trim()] = line[(colon + 1)..].Trim(); + } + } + + await Task.CompletedTask.ConfigureAwait(false); + return new HttpRequest(first.Length > 1 ? first[1] : "/", headers); + } + + private static async Task WriteHttpResponseAsync(NetworkStream stream, string status, string contentType, byte[] body) + { + var header = Encoding.ASCII.GetBytes( + $"HTTP/1.1 {status}\r\nContent-Type: {contentType}\r\nContent-Length: {body.Length}\r\nConnection: close\r\n\r\n"); + await stream.WriteAsync(header).ConfigureAwait(false); + await stream.WriteAsync(body).ConfigureAwait(false); + } + + private static async Task WriteWebSocketHandshakeAsync(NetworkStream stream, string key) + { + var acceptBytes = SHA1.HashData(Encoding.ASCII.GetBytes(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")); + var accept = Convert.ToBase64String(acceptBytes); + var response = Encoding.ASCII.GetBytes( + "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + $"Sec-WebSocket-Accept: {accept}\r\n\r\n"); + await stream.WriteAsync(response).ConfigureAwait(false); + } +} + +internal sealed class EveSocketClient : IDisposable +{ + private readonly TcpClient tcpClient; + private readonly NetworkStream stream; + private readonly SemaphoreSlim sendLock = new(1, 1); + + public EveSocketClient(TcpClient tcpClient, NetworkStream stream) + { + this.tcpClient = tcpClient; + this.stream = stream; + } + + public Task SendTextAsync(string text) + { + return SendFrameAsync(0x1, Encoding.UTF8.GetBytes(text)); + } + + public Task SendBinaryAsync(byte[] payload) + { + return SendFrameAsync(0x2, payload); + } + + public async Task ReceiveAsync() + { + var header0 = stream.ReadByte(); + var header1 = stream.ReadByte(); + if (header0 < 0 || header1 < 0) + { + throw new IOException("WebSocket closed."); + } + + var opcode = header0 & 0x0f; + var masked = (header1 & 0x80) != 0; + ulong length = (ulong)(header1 & 0x7f); + if (length == 126) + { + var ext = await ReadExactAsync(2).ConfigureAwait(false); + length = (ulong)((ext[0] << 8) | ext[1]); + } + else if (length == 127) + { + var ext = await ReadExactAsync(8).ConfigureAwait(false); + length = 0; + foreach (var b in ext) + { + length = (length << 8) | b; + } + } + + var mask = masked ? await ReadExactAsync(4).ConfigureAwait(false) : []; + var payload = await ReadExactAsync(checked((int)length)).ConfigureAwait(false); + if (masked) + { + for (var i = 0; i < payload.Length; i++) + { + payload[i] ^= mask[i & 3]; + } + } + + return new WebSocketFrame(opcode, payload); + } + + public void Dispose() + { + sendLock.Dispose(); + stream.Dispose(); + tcpClient.Dispose(); + } + + private async Task SendFrameAsync(byte opcode, byte[] payload) + { + await sendLock.WaitAsync().ConfigureAwait(false); + try + { + var header = new List { (byte)(0x80 | opcode) }; + if (payload.Length <= 125) + { + header.Add((byte)payload.Length); + } + else if (payload.Length <= ushort.MaxValue) + { + header.Add(126); + header.Add((byte)((payload.Length >> 8) & 0xff)); + header.Add((byte)(payload.Length & 0xff)); + } + else + { + header.Add(127); + var length = (ulong)payload.Length; + for (var shift = 56; shift >= 0; shift -= 8) + { + header.Add((byte)((length >> shift) & 0xff)); + } + } + + await stream.WriteAsync(header.ToArray()).ConfigureAwait(false); + await stream.WriteAsync(payload).ConfigureAwait(false); + await stream.FlushAsync().ConfigureAwait(false); + } + finally + { + sendLock.Release(); + } + } + + private async Task ReadExactAsync(int count) + { + var buffer = new byte[count]; + var offset = 0; + while (offset < count) + { + var read = await stream.ReadAsync(buffer.AsMemory(offset, count - offset)).ConfigureAwait(false); + if (read == 0) + { + throw new IOException("WebSocket closed."); + } + + offset += read; + } + + return buffer; + } +} + +internal sealed record HttpRequest(string Path, IReadOnlyDictionary Headers); + +internal sealed record WebSocketFrame(int Opcode, byte[] Payload); + +internal sealed class H264AnnexBAccessUnitFramer +{ + private readonly List buffer = []; + private readonly List currentAccessUnit = []; + private bool currentHasSlice; + + public IEnumerable Push(byte[] chunk) + { + buffer.AddRange(chunk); + while (TryPopNal(out var nal)) + { + var nalType = nal.Length == 0 ? 0 : nal[0] & 0x1f; + var isSlice = nalType is 1 or 5; + var startsNewAccessUnit = nalType == 9 || (isSlice && currentHasSlice); + if (startsNewAccessUnit && currentAccessUnit.Count > 0) + { + yield return BuildCurrentAccessUnit(); + currentAccessUnit.Clear(); + currentHasSlice = false; + } + + currentAccessUnit.Add(nal); + currentHasSlice |= isSlice; + } + } + + private byte[] BuildCurrentAccessUnit() + { + var byteCount = currentAccessUnit.Sum(nal => nal.Length + 4); + var output = new byte[byteCount]; + var offset = 0; + foreach (var nal in currentAccessUnit) + { + output[offset++] = 0; + output[offset++] = 0; + output[offset++] = 0; + output[offset++] = 1; + nal.CopyTo(output.AsSpan(offset)); + offset += nal.Length; + } + + return output; + } + + private bool TryPopNal(out byte[] nal) + { + nal = []; + var firstStart = FindStartCode(buffer, 0, out var firstStartLength); + if (firstStart < 0) + { + buffer.Clear(); + return false; + } + + if (firstStart > 0) + { + buffer.RemoveRange(0, firstStart); + firstStart = 0; + } + + var payloadStart = firstStart + firstStartLength; + var nextStart = FindStartCode(buffer, payloadStart, out _); + if (nextStart < 0) + { + return false; + } + + nal = buffer.GetRange(payloadStart, nextStart - payloadStart).ToArray(); + buffer.RemoveRange(0, nextStart); + return nal.Length > 0; + } + + private static int FindStartCode(List bytes, int offset, out int length) + { + for (var index = offset; index < bytes.Count - 3; index++) + { + if (bytes[index] == 0 && bytes[index + 1] == 0 && bytes[index + 2] == 1) + { + length = 3; + return index; + } + + if (index < bytes.Count - 4 && bytes[index] == 0 && bytes[index + 1] == 0 && bytes[index + 2] == 0 && bytes[index + 3] == 1) + { + length = 4; + return index; + } + } + + length = 0; + return -1; + } +} + +internal sealed record EveRelayConfig( + string SharedTextureName, + int Width, + int Height, + int Fps, + int Port, + string LanHost, + double DeviceScaleFactor, + string Encoder, + int Quality, + string? FfmpegPath) +{ + public static EveRelayConfig Parse(string[] args) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var index = 0; index < args.Length; index++) + { + if (!args[index].StartsWith("--", StringComparison.Ordinal)) + { + continue; + } + + var key = args[index][2..]; + var value = index + 1 < args.Length && !args[index + 1].StartsWith("--", StringComparison.Ordinal) + ? args[++index] + : "true"; + values[key] = value; + } + + return new EveRelayConfig( + StringValue(values, "shared-texture", "Global\\MimirFensalirProgramTexture"), + IntValue(values, "width", 0), + IntValue(values, "height", 0), + IntValue(values, "fps", 30), + IntValue(values, "port", 8792), + StringValue(values, "lan-host", "192.168.1.66"), + DoubleValue(values, "scale", 2.0), + StringValue(values, "encoder", "h264_nvenc"), + IntValue(values, "quality", 26), + values.GetValueOrDefault("ffmpeg")); + } + + private static string StringValue(IReadOnlyDictionary values, string name, string fallback) => + values.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value) ? value : fallback; + + private static int IntValue(IReadOnlyDictionary values, string name, int fallback) => + values.TryGetValue(name, out var value) && int.TryParse(value, out var parsed) ? parsed : fallback; + + private static double DoubleValue(IReadOnlyDictionary values, string name, double fallback) => + values.TryGetValue(name, out var value) && double.TryParse(value, out var parsed) ? parsed : fallback; +}