Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions SnakeGame.sln
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SnakeWebGL", "SnakeWebGL\Sn
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SnakeCore.Logging", "SnakeCore.Logging\SnakeCore.Logging.csproj", "{3D99F8B1-5913-4FE0-A999-6F876F26473D}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SnakeWebGL.Tests", "SnakeWebGL.Tests\SnakeWebGL.Tests.csproj", "{5F1D0A4E-4EAD-4D63-B006-74BD37AC56B7}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -30,15 +32,19 @@ Global
{3685AFF4-0131-43CA-9757-5B59DD877854}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3685AFF4-0131-43CA-9757-5B59DD877854}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3685AFF4-0131-43CA-9757-5B59DD877854}.Release|Any CPU.Build.0 = Release|Any CPU
{91FF9B84-E5DB-4DE8-AF89-4E4BBE203B9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{91FF9B84-E5DB-4DE8-AF89-4E4BBE203B9F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{91FF9B84-E5DB-4DE8-AF89-4E4BBE203B9F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{91FF9B84-E5DB-4DE8-AF89-4E4BBE203B9F}.Release|Any CPU.Build.0 = Release|Any CPU
{3D99F8B1-5913-4FE0-A999-6F876F26473D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3D99F8B1-5913-4FE0-A999-6F876F26473D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3D99F8B1-5913-4FE0-A999-6F876F26473D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3D99F8B1-5913-4FE0-A999-6F876F26473D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
{91FF9B84-E5DB-4DE8-AF89-4E4BBE203B9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{91FF9B84-E5DB-4DE8-AF89-4E4BBE203B9F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{91FF9B84-E5DB-4DE8-AF89-4E4BBE203B9F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{91FF9B84-E5DB-4DE8-AF89-4E4BBE203B9F}.Release|Any CPU.Build.0 = Release|Any CPU
{3D99F8B1-5913-4FE0-A999-6F876F26473D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3D99F8B1-5913-4FE0-A999-6F876F26473D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3D99F8B1-5913-4FE0-A999-6F876F26473D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3D99F8B1-5913-4FE0-A999-6F876F26473D}.Release|Any CPU.Build.0 = Release|Any CPU
{5F1D0A4E-4EAD-4D63-B006-74BD37AC56B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5F1D0A4E-4EAD-4D63-B006-74BD37AC56B7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5F1D0A4E-4EAD-4D63-B006-74BD37AC56B7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5F1D0A4E-4EAD-4D63-B006-74BD37AC56B7}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
Expand Down
126 changes: 126 additions & 0 deletions SnakeWebGL.Tests/EglStartupTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using Xunit;

namespace SnakeWebGL.Tests;

internal sealed class FakeEgl : IEglApi
{
public IntPtr DisplayHandle { get; set; } = (IntPtr)1;
public bool InitializeResult { get; set; } = true;
public Queue<bool> ChooseConfigSequence { get; set; } = new(new[] { true });
public Queue<IntPtr> ConfigHandles { get; set; } = new(new[] { (IntPtr)2 });
public bool BindApiResult { get; set; } = true;
public IntPtr ContextHandle { get; set; } = (IntPtr)3;
public IntPtr SurfaceHandle { get; set; } = (IntPtr)4;
public bool MakeCurrentResult { get; set; } = true;
public int ErrorCode { get; set; } = unchecked((int)0xBAD); // Arbitrary default

public IntPtr GetDisplay(IntPtr displayId) => DisplayHandle;

public bool Initialize(IntPtr display, out int major, out int minor)
{
major = 3;
minor = 0;
return InitializeResult;
}

public bool ChooseConfig(IntPtr display, int[] attributeList, ref IntPtr config, IntPtr configSize, ref IntPtr numConfig)
{
if (ChooseConfigSequence.Count == 0)
{
return false;
}

var result = ChooseConfigSequence.Dequeue();
if (result)
{
config = ConfigHandles.Count > 0 ? ConfigHandles.Dequeue() : (IntPtr)99;
numConfig = (IntPtr)1;
}
else
{
config = IntPtr.Zero;
numConfig = IntPtr.Zero;
}

return result;
}

public bool BindApi(int api) => BindApiResult;

public IntPtr CreateContext(IntPtr display, IntPtr config, IntPtr shareContext, int[] contextAttribs) => ContextHandle;

public IntPtr CreateWindowSurface(IntPtr display, IntPtr config, IntPtr window, IntPtr attribList) => SurfaceHandle;

public bool MakeCurrent(IntPtr display, IntPtr draw, IntPtr read, IntPtr context) => MakeCurrentResult;

public int GetError() => ErrorCode;
}

internal sealed class FakeLibraryLoader : INativeLibraryLoader
{
public bool Result { get; set; }

public bool TryLoad(string libraryName) => Result;
}

public class EglStartupTests
{
[Fact]
public void FallsBackWhenMsaaConfigUnavailable()
{
var fake = new FakeEgl
{
ChooseConfigSequence = new Queue<bool>(new[] { false, true }),
ConfigHandles = new Queue<IntPtr>(new[] { (IntPtr)11, (IntPtr)22 })
};

var logs = new List<string>();
var startup = new EglStartup(fake, logs.Add);

var handles = startup.Initialize();

Assert.Equal((IntPtr)22, handles.Config);
Assert.Contains(logs, l => l.Contains("MSAA config unavailable"));
Assert.Contains(logs, l => l.Contains("Selected non-MSAA config"));
}

[Fact]
public void SurfaceErrorsBubbleWithErrorCode()
{
var fake = new FakeEgl
{
DisplayHandle = IntPtr.Zero,
ErrorCode = 0x3001
};

var startup = new EglStartup(fake, _ => { });

var exception = Assert.Throws<InvalidOperationException>(() => startup.Initialize());
Assert.Contains("0x3001", exception.Message);
}

[Fact]
public void DescribeAttributesTrimsTrailingComma()
{
// Use reflection to reach the private helper so we can lock in the string format that was failing.
var method = typeof(EglStartup).GetMethod("DescribeAttributes", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
Assert.NotNull(method);

var attributes = new[] { EGL.EGL_RED_SIZE, 8, EGL.EGL_NONE, 0 };
var result = method!.Invoke(null, new object[] { attributes });

Assert.Equal("[12324=8]", result); // 12324 == EGL.EGL_RED_SIZE
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public void IsSupportedReflectsLibraryAvailability(bool available)
{
var startup = new EglStartup(new FakeEgl(), _ => { }, new FakeLibraryLoader { Result = available });
Assert.Equal(available, startup.IsSupported());
}
}
16 changes: 16 additions & 0 deletions SnakeWebGL.Tests/SnakeWebGL.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="xunit" Version="2.6.6" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6" />
<PackageReference Include="coverlet.collector" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SnakeWebGL\SnakeWebGL.csproj" />
</ItemGroup>
</Project>
201 changes: 201 additions & 0 deletions SnakeWebGL/EglStartup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

namespace SnakeWebGL;

internal interface IEglApi
{
IntPtr GetDisplay(IntPtr displayId);
bool Initialize(IntPtr display, out int major, out int minor);
bool ChooseConfig(IntPtr display, int[] attributeList, ref IntPtr config, IntPtr configSize, ref IntPtr numConfig);
bool BindApi(int api);
IntPtr CreateContext(IntPtr display, IntPtr config, IntPtr shareContext, int[] contextAttribs);
IntPtr CreateWindowSurface(IntPtr display, IntPtr config, IntPtr window, IntPtr attribList);
bool MakeCurrent(IntPtr display, IntPtr draw, IntPtr read, IntPtr context);
int GetError();
}

internal sealed class EglApi : IEglApi
{
public IntPtr GetDisplay(IntPtr displayId) => EGL.GetDisplay(displayId);

public bool Initialize(IntPtr display, out int major, out int minor) => EGL.Initialize(display, out major, out minor);

public bool ChooseConfig(IntPtr display, int[] attributeList, ref IntPtr config, IntPtr configSize, ref IntPtr numConfig)
=> EGL.ChooseConfig(display, attributeList, ref config, configSize, ref numConfig);

public bool BindApi(int api) => EGL.BindApi(api);

public IntPtr CreateContext(IntPtr display, IntPtr config, IntPtr shareContext, int[] contextAttribs)
=> EGL.CreateContext(display, config, shareContext, contextAttribs);

public IntPtr CreateWindowSurface(IntPtr display, IntPtr config, IntPtr window, IntPtr attribList)
=> EGL.CreateWindowSurface(display, config, window, attribList);

public bool MakeCurrent(IntPtr display, IntPtr draw, IntPtr read, IntPtr context)
=> EGL.MakeCurrent(display, draw, read, context);

public int GetError() => EGL.GetError();
}

internal readonly record struct EglHandles(IntPtr Display, IntPtr Config, IntPtr Context, IntPtr Surface, int MajorVersion, int MinorVersion);

internal interface INativeLibraryLoader
{
bool TryLoad(string libraryName);
}

internal sealed class NativeLibraryLoader : INativeLibraryLoader
{
public bool TryLoad(string libraryName) => NativeLibrary.TryLoad(libraryName, out _);
}

internal sealed class EglStartup
{
private readonly IEglApi _egl;
private readonly Action<string> _log;
private readonly INativeLibraryLoader _libraryLoader;

private static readonly int[] AttributeListMsaa =
{
EGL.EGL_RED_SIZE, 8,
EGL.EGL_GREEN_SIZE, 8,
EGL.EGL_BLUE_SIZE, 8,
EGL.EGL_SAMPLES, 16,
EGL.EGL_NONE
};

private static readonly int[] AttributeListNoMsaa =
{
EGL.EGL_RED_SIZE, 8,
EGL.EGL_GREEN_SIZE, 8,
EGL.EGL_BLUE_SIZE, 8,
EGL.EGL_NONE
};

private static readonly int[] ContextAttributes =
{
EGL.EGL_CONTEXT_CLIENT_VERSION, 3,
EGL.EGL_NONE
};

public EglStartup(IEglApi eglApi, Action<string>? log, INativeLibraryLoader? libraryLoader = null)
{
_egl = eglApi ?? throw new ArgumentNullException(nameof(eglApi));
_log = log ?? (_ => { });
_libraryLoader = libraryLoader ?? new NativeLibraryLoader();
}

public bool IsSupported()
{
var available = _libraryLoader.TryLoad(EGL.LibEgl);
if (!available)
{
_log($"[EGL] {EGL.LibEgl} not found; skipping EGL initialization");
}

return available;
}

public EglHandles Initialize()
{
_log("[EGL] Starting initialization");

var display = _egl.GetDisplay(IntPtr.Zero);
_log($"[EGL] GetDisplay returned 0x{display.ToInt64():X}");
if (display == IntPtr.Zero)
throw new InvalidOperationException($"EGL.GetDisplay returned null (error: 0x{_egl.GetError():X}). Ensure a canvas is available and WebGL is enabled.");

if (!_egl.Initialize(display, out int major, out int minor))
throw new InvalidOperationException($"EGL.Initialize failed (error: 0x{_egl.GetError():X}).");

_log($"[EGL] Initialized version {major}.{minor}");

var config = PickConfig(display);

if (!_egl.BindApi(EGL.EGL_OPENGL_ES_API))
throw new InvalidOperationException($"EGL.BindApi failed (error: 0x{_egl.GetError():X}).");

var context = _egl.CreateContext(display, config, (IntPtr)EGL.EGL_NO_CONTEXT, ContextAttributes);
_log($"[EGL] CreateContext returned 0x{context.ToInt64():X}");
if (context == IntPtr.Zero)
throw new InvalidOperationException($"EGL.CreateContext failed (error: 0x{_egl.GetError():X}).");

var surface = _egl.CreateWindowSurface(display, config, IntPtr.Zero, IntPtr.Zero);
_log($"[EGL] CreateWindowSurface returned 0x{surface.ToInt64():X}");
if (surface == IntPtr.Zero)
throw new InvalidOperationException($"EGL.CreateWindowSurface failed (error: 0x{_egl.GetError():X}).");

if (!_egl.MakeCurrent(display, surface, surface, context))
throw new InvalidOperationException($"EGL.MakeCurrent failed (error: 0x{_egl.GetError():X}).");

_log("[EGL] Context made current successfully");

return new EglHandles(display, config, context, surface, major, minor);
}

private IntPtr PickConfig(IntPtr display)
{
_log($"[EGL] Selecting config with MSAA attributes: {DescribeAttributes(AttributeListMsaa)}");
if (TryChooseConfig(display, AttributeListMsaa, out var config))
{
_log($"[EGL] Selected MSAA config 0x{config.ToInt64():X}");
return config;
}

_log("[EGL] MSAA config unavailable, retrying without MSAA");

if (TryChooseConfig(display, AttributeListNoMsaa, out config))
{
_log($"[EGL] Selected non-MSAA config 0x{config.ToInt64():X}");
return config;
}

throw new InvalidOperationException($"EGL.ChooseConfig failed for both attribute sets (last error: 0x{_egl.GetError():X}).");
}

private bool TryChooseConfig(IntPtr display, int[] attributes, out IntPtr config)
{
config = IntPtr.Zero;
var numConfig = IntPtr.Zero;
var result = _egl.ChooseConfig(display, attributes, ref config, (IntPtr)1, ref numConfig) && numConfig != IntPtr.Zero;
if (!result)
{
_log($"[EGL] ChooseConfig failed for attributes {DescribeAttributes(attributes)} (error: 0x{_egl.GetError():X})");
}

return result;
}

private static string DescribeAttributes(IReadOnlyList<int> attributes)
{
var writer = new DefaultInterpolatedStringHandler(0, 0);

writer.AppendLiteral("[");
for (int i = 0; i < attributes.Count; i += 2)
{
var key = attributes[i];
if (key == EGL.EGL_NONE)
break;

var value = (i + 1) < attributes.Count ? attributes[i + 1] : 0;
writer.AppendFormatted(key);
writer.AppendLiteral("=");
writer.AppendFormatted(value);
writer.AppendLiteral(", ");
}

writer.AppendLiteral("]");

var result = writer.ToStringAndClear();
if (result == "[")
return "[]";

if (result.EndsWith(", ]"))
result = result[..^3] + "]";

return result;
}
}
Loading