From 9ca77ae1d4f7fa05f6d4397a54c4c2c53b9014bb Mon Sep 17 00:00:00 2001
From: Cody Mullins <1738479+codymullins@users.noreply.github.com>
Date: Tue, 16 Jun 2026 20:16:39 -0400
Subject: [PATCH] feat(interaction): zero-copy GPU surface transfer bridge
Add Skyline.Interaction.Gpu: a wgpu-handle transfer representation so a
surface copy moves by reference and never round-trips through the
processor.
- GpuSurfaceHandle (texture handle + format + size), GpuTransferOffer,
and IGpuTransferBroker / InProcessGpuTransferBroker.
- Reuses the interaction tier's Provenance, TransferPolicy, and
local/remote rules, so a surface transfer answers the same
who / how-far / how-long questions a text transfer does.
- The new assembly is wired into the 100% coverage gate (slnx and
cover.sh), and reports 100%.
Closes #23
---
Skyline.slnx | 2 +
src/Skyline.Interaction.Gpu/GpuTransfer.cs | 101 ++++++++++++++++++
.../Skyline.Interaction.Gpu.csproj | 10 ++
.../GlobalUsings.cs | 1 +
.../GpuTransferTests.cs | 98 +++++++++++++++++
.../Skyline.Interaction.Gpu.Tests.csproj | 14 +++
tools/cover.sh | 2 +-
7 files changed, 227 insertions(+), 1 deletion(-)
create mode 100644 src/Skyline.Interaction.Gpu/GpuTransfer.cs
create mode 100644 src/Skyline.Interaction.Gpu/Skyline.Interaction.Gpu.csproj
create mode 100644 tests/Skyline.Interaction.Gpu.Tests/GlobalUsings.cs
create mode 100644 tests/Skyline.Interaction.Gpu.Tests/GpuTransferTests.cs
create mode 100644 tests/Skyline.Interaction.Gpu.Tests/Skyline.Interaction.Gpu.Tests.csproj
diff --git a/Skyline.slnx b/Skyline.slnx
index 260e78a..141feaa 100644
--- a/Skyline.slnx
+++ b/Skyline.slnx
@@ -18,12 +18,14 @@
+
+
diff --git a/src/Skyline.Interaction.Gpu/GpuTransfer.cs b/src/Skyline.Interaction.Gpu/GpuTransfer.cs
new file mode 100644
index 0000000..d4e3874
--- /dev/null
+++ b/src/Skyline.Interaction.Gpu/GpuTransfer.cs
@@ -0,0 +1,101 @@
+// SPDX-License-Identifier: Apache-2.0
+using Silk.NET.WebGPU;
+
+namespace Skyline.Interaction.Gpu;
+
+///
+/// A GPU surface offered for transfer: a wgpu texture handle plus the metadata
+/// a taker needs to bind it. The source owns the texture and keeps it alive for
+/// the life of the offer; the taker must read or copy it before the offer
+/// expires or is revoked. The handle never round-trips through the processor —
+/// that is the whole point of the GPU bridge.
+///
+public readonly record struct GpuSurfaceHandle(
+ nint Texture, TextureFormat Format, uint Width, uint Height);
+
+///
+/// One offered GPU surface, with provenance and policy — the GPU twin of
+/// . Provenance and policy come from the same
+/// model, so a surface transfer answers the
+/// same who/how-far/how-long questions a text transfer does.
+///
+public sealed record GpuTransferOffer(
+ string Id, GpuSurfaceHandle Surface, Provenance Provenance, TransferPolicy Policy);
+
+///
+/// The GPU transfer seam: offer a surface, take one by id (subject to its
+/// policy), revoke, or list the live ones. Mirrors
+/// for GPU handles instead of string payloads.
+///
+public interface IGpuTransferBroker
+{
+ GpuTransferOffer Offer(GpuSurfaceHandle surface, Provenance provenance, TransferPolicy? policy = null);
+ GpuTransferOffer? Take(string id, Actor taker);
+ bool Revoke(string id);
+ IReadOnlyList List();
+}
+
+///
+/// The default in-process GPU transfer broker. Same lifetime and locality rules
+/// as the text broker: it holds offers until they expire or are revoked, denies
+/// a remote taker an offer its policy keeps local, and prunes lapsed offers
+/// against an injected .
+///
+public sealed class InProcessGpuTransferBroker(TimeProvider? time = null) : IGpuTransferBroker
+{
+ private readonly TimeProvider _time = time ?? TimeProvider.System;
+ private readonly Lock _gate = new();
+ private readonly List _offers = [];
+
+ public GpuTransferOffer Offer(GpuSurfaceHandle surface, Provenance provenance, TransferPolicy? policy = null)
+ {
+ var offer = new GpuTransferOffer(
+ Guid.NewGuid().ToString("n"), surface, provenance, policy ?? TransferPolicy.Default);
+ lock (_gate)
+ {
+ _offers.Add(offer);
+ }
+ return offer;
+ }
+
+ public GpuTransferOffer? Take(string id, Actor taker)
+ {
+ lock (_gate)
+ {
+ PruneLocked();
+ var offer = _offers.Find(o => o.Id == id);
+ if (offer is null)
+ {
+ return null;
+ }
+ if (taker.Locality == ActorLocality.Remote && !offer.Policy.AllowRemote)
+ {
+ return null;
+ }
+ return offer;
+ }
+ }
+
+ public bool Revoke(string id)
+ {
+ lock (_gate)
+ {
+ return _offers.RemoveAll(o => o.Id == id) > 0;
+ }
+ }
+
+ public IReadOnlyList List()
+ {
+ lock (_gate)
+ {
+ PruneLocked();
+ return _offers.ToArray();
+ }
+ }
+
+ private void PruneLocked()
+ {
+ var now = _time.GetUtcNow();
+ _offers.RemoveAll(o => o.Policy.ExpiresAt is { } expiresAt && expiresAt <= now);
+ }
+}
diff --git a/src/Skyline.Interaction.Gpu/Skyline.Interaction.Gpu.csproj b/src/Skyline.Interaction.Gpu/Skyline.Interaction.Gpu.csproj
new file mode 100644
index 0000000..1474956
--- /dev/null
+++ b/src/Skyline.Interaction.Gpu/Skyline.Interaction.Gpu.csproj
@@ -0,0 +1,10 @@
+
+
+ Skyline.Interaction.Gpu
+ Optional GPU bridge for the Skyline interaction tier: a wgpu-handle transfer representation so a surface copy never round-trips to the processor. Pairs Skyline.Interaction's transfer model with Skyline.Gpu.
+
+
+
+
+
+
diff --git a/tests/Skyline.Interaction.Gpu.Tests/GlobalUsings.cs b/tests/Skyline.Interaction.Gpu.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..540383d
--- /dev/null
+++ b/tests/Skyline.Interaction.Gpu.Tests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Microsoft.VisualStudio.TestTools.UnitTesting;
diff --git a/tests/Skyline.Interaction.Gpu.Tests/GpuTransferTests.cs b/tests/Skyline.Interaction.Gpu.Tests/GpuTransferTests.cs
new file mode 100644
index 0000000..ded8328
--- /dev/null
+++ b/tests/Skyline.Interaction.Gpu.Tests/GpuTransferTests.cs
@@ -0,0 +1,98 @@
+using Silk.NET.WebGPU;
+
+namespace Skyline.Interaction.Gpu.Tests;
+
+[TestClass]
+public class GpuTransferTests
+{
+ private sealed class FixedTime(DateTimeOffset now) : TimeProvider
+ {
+ public DateTimeOffset Now { get; set; } = now;
+
+ public override DateTimeOffset GetUtcNow() => Now;
+ }
+
+ private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ private static readonly Actor Local = new("u", "User", ActorKind.Human, ActorLocality.Local);
+ private static readonly Actor Remote = new("r", "Remote", ActorKind.Human, ActorLocality.Remote);
+ private static readonly GpuSurfaceHandle Surface = new(0xABCD, TextureFormat.Rgba8Unorm, 64, 48);
+
+ private static Provenance Prov() => new(Local, T0);
+
+ [TestMethod]
+ public void SurfaceHandleCarriesFields()
+ {
+ Assert.AreEqual((nint)0xABCD, Surface.Texture);
+ Assert.AreEqual(TextureFormat.Rgba8Unorm, Surface.Format);
+ Assert.AreEqual(64u, Surface.Width);
+ Assert.AreEqual(48u, Surface.Height);
+ }
+
+ [TestMethod]
+ public void OfferIsListedAndTakeable()
+ {
+ var broker = new InProcessGpuTransferBroker(new FixedTime(T0));
+ var offer = broker.Offer(Surface, Prov());
+
+ Assert.AreEqual(TransferPolicy.Default, offer.Policy);
+ Assert.AreEqual(Surface, offer.Surface);
+ CollectionAssert.AreEqual(new[] { offer }, broker.List().ToArray());
+
+ var taken = broker.Take(offer.Id, Local);
+ Assert.IsNotNull(taken);
+ Assert.AreEqual(offer.Id, taken!.Id);
+ }
+
+ [TestMethod]
+ public void TakeUnknownIdReturnsNull()
+ {
+ var broker = new InProcessGpuTransferBroker(new FixedTime(T0));
+ Assert.IsNull(broker.Take("missing", Local));
+ }
+
+ [TestMethod]
+ public void RemoteTakerIsDeniedALocalOnlyOffer()
+ {
+ var broker = new InProcessGpuTransferBroker(new FixedTime(T0));
+ var offer = broker.Offer(Surface, Prov()); // Default policy: local only
+ Assert.IsNull(broker.Take(offer.Id, Remote));
+ }
+
+ [TestMethod]
+ public void RemoteTakerIsAllowedWhenThePolicyAllowsIt()
+ {
+ var broker = new InProcessGpuTransferBroker(new FixedTime(T0));
+ var policy = new TransferPolicy(TransferScope.Session, AllowRemote: true);
+ var offer = broker.Offer(Surface, Prov(), policy);
+ var taken = broker.Take(offer.Id, Remote);
+ Assert.IsNotNull(taken);
+ Assert.AreEqual(offer.Id, taken!.Id);
+ }
+
+ [TestMethod]
+ public void RevokeRemovesAnOffer()
+ {
+ var broker = new InProcessGpuTransferBroker(new FixedTime(T0));
+ var offer = broker.Offer(Surface, Prov());
+ Assert.IsTrue(broker.Revoke(offer.Id));
+ Assert.IsFalse(broker.Revoke(offer.Id)); // already gone
+ Assert.AreEqual(0, broker.List().Count);
+ }
+
+ [TestMethod]
+ public void ExpiredOffersArePrunedAndLiveOnesSurvive()
+ {
+ var clock = new FixedTime(T0);
+ var broker = new InProcessGpuTransferBroker(clock);
+ var expiring = broker.Offer(Surface, Prov(),
+ new TransferPolicy(TransferScope.Session, AllowRemote: false, ExpiresAt: T0.AddSeconds(10)));
+ var permanent = broker.Offer(Surface, Prov()); // no expiry
+
+ clock.Now = T0.AddSeconds(11);
+
+ Assert.IsNull(broker.Take(expiring.Id, Local), "an expired offer is pruned before a take");
+ var live = broker.List();
+ Assert.AreEqual(1, live.Count);
+ Assert.AreEqual(permanent.Id, live[0].Id);
+ }
+}
diff --git a/tests/Skyline.Interaction.Gpu.Tests/Skyline.Interaction.Gpu.Tests.csproj b/tests/Skyline.Interaction.Gpu.Tests/Skyline.Interaction.Gpu.Tests.csproj
new file mode 100644
index 0000000..8c60b2f
--- /dev/null
+++ b/tests/Skyline.Interaction.Gpu.Tests/Skyline.Interaction.Gpu.Tests.csproj
@@ -0,0 +1,14 @@
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/cover.sh b/tools/cover.sh
index 4818b1b..b233633 100755
--- a/tools/cover.sh
+++ b/tools/cover.sh
@@ -23,6 +23,6 @@ dotnet reportgenerator \
-reports:"coverage/unit/*/coverage.cobertura.xml;coverage/windowed.cobertura.xml" \
-targetdir:coverage/report \
"-reporttypes:TextSummary" \
- "-assemblyfilters:+Skyline;+Skyline.Gpu;+Skyline.Render;+Skyline.Interaction;+Skyline.Interaction.Ui"
+ "-assemblyfilters:+Skyline;+Skyline.Gpu;+Skyline.Render;+Skyline.Interaction;+Skyline.Interaction.Ui;+Skyline.Interaction.Gpu"
cat coverage/report/Summary.txt