diff --git a/.github/workflows/uixrender-ci.yml b/.github/workflows/uixrender-ci.yml
index 8a19c66..216186a 100644
--- a/.github/workflows/uixrender-ci.yml
+++ b/.github/workflows/uixrender-ci.yml
@@ -6,11 +6,12 @@ on:
jobs:
build:
- # UIXrender/UIXsup are implemented cross-platform (Silk.NET, no Windows-specific
- # APIs), but NativeAOT can't cross-compile Linux->Windows, and the P/Invoke-based
+ # UIXrender/UIXsup are implemented cross-platform (the only Windows-specific code is
+ # explicitly `#if WINDOWS` gated -- DPI, registry-change notification, cursor
+ # metrics), but NativeAOT can't cross-compile Linux->Windows, and the P/Invoke-based
# managed consumers we need to stay compatible with (UIX.dll et al) only run on
- # Windows today -- so Windows is what Phase 0 verifies against here. Other RIDs can
- # get their own job later once there's something worth publishing them for.
+ # Windows today -- so Windows is what the interop spike verifies against. The linux
+ # job below covers what doesn't need Windows, including export-surface completeness.
runs-on: windows-latest
steps:
@@ -26,6 +27,12 @@ jobs:
- name: Build solution
run: dotnet build MicrosoftIris.sln -c Release --no-restore
+ # Exercises EngineService directly (no P/Invoke, no publish needed) -- runs on
+ # any platform since it's pure managed code; on windows-latest here just because
+ # that's the only job today, not because it needs Windows specifically.
+ - name: Run EngineService managed-direct tests
+ run: dotnet run --project Tests/UIXrender.Engine.Tests/UIXrender.Engine.Tests.csproj -c Release
+
- name: Publish UIXrender (NativeAOT, win-x64)
run: dotnet publish UIXrender/UIXrender.csproj -c Release -f net8.0 -r win-x64 --self-contained -p:PublishAot=true
@@ -49,3 +56,48 @@ jobs:
Copy-Item $dll.FullName -Destination $testExe.DirectoryName
& $testExe.FullName
if ($LASTEXITCODE -ne 0) { throw "Interop spike failed with exit code $LASTEXITCODE" }
+
+ linux:
+ # Verifies the two things that don't need Windows and that the windows job can't
+ # easily check: that the managed subsystem models actually behave, and that the
+ # NativeAOT-published library exports *exactly* the set of entry points the managed
+ # consumers declare -- no missing export (a runtime EntryPointNotFoundException
+ # waiting to happen) and no stray one.
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "8.0.x"
+
+ - name: Run UIXrender subsystem tests
+ run: dotnet run --project Tests/UIXrender.Engine.Tests/UIXrender.Engine.Tests.csproj -c Release -f net8.0
+
+ - name: Publish UIXrender (NativeAOT, linux-x64)
+ run: dotnet publish UIXrender/UIXrender.csproj -c Release -f net8.0 -r linux-x64 --self-contained -p:PublishAot=true
+
+ - name: Verify exported surface matches the managed declarations
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ # Every Sp* entry point the managed consumers P/Invoke for.
+ grep -hoP 'extern\s+.*?\bSp\w+\s*\(' \
+ UIX/Microsoft/Iris/OS/NativeApi.cs \
+ UIX.RenderApi/Microsoft/Iris/Render/Extensions/ExtensionsApi.cs \
+ UIX.RenderApi/Microsoft/Iris/Render/Internal/FormApi.cs \
+ UIX.RenderApi/Microsoft/Iris/Render/Protocol/EngineApi.cs \
+ | grep -oP 'Sp\w+(?=\s*\()' | sort -u > /tmp/declared.txt
+
+ SO=$(find UIXrender/bin -name UIXrender.so -path '*publish*' | head -1)
+ [ -n "$SO" ] || { echo "Published UIXrender.so not found"; exit 1; }
+ nm -D --defined-only "$SO" | grep -oP '\bSp\w+' | sort -u > /tmp/exported.txt
+
+ echo "declared: $(wc -l < /tmp/declared.txt) exported: $(wc -l < /tmp/exported.txt)"
+ if ! diff -u /tmp/declared.txt /tmp/exported.txt; then
+ echo "::error::Exported surface does not match the managed declarations (- = declared but missing, + = exported but undeclared)"
+ exit 1
+ fi
+ echo "Export surface matches exactly."
diff --git a/MicrosoftIris.sln b/MicrosoftIris.sln
index 72dc614..24f24d5 100644
--- a/MicrosoftIris.sln
+++ b/MicrosoftIris.sln
@@ -26,6 +26,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UIXInterop", "UIXInterop\UI
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UIXrender.Interop.Tests", "Tests\UIXrender.Interop.Tests\UIXrender.Interop.Tests.csproj", "{D8DA0828-9ACC-430F-9A12-E09ECE63823C}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UIXrender.Engine.Tests", "Tests\UIXrender.Engine.Tests\UIXrender.Engine.Tests.csproj", "{568E8DCD-CAB3-499B-A264-C62B1E1082B7}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -132,6 +134,18 @@ Global
{D8DA0828-9ACC-430F-9A12-E09ECE63823C}.Release|x64.Build.0 = Release|x64
{D8DA0828-9ACC-430F-9A12-E09ECE63823C}.Release|x86.ActiveCfg = Release|x86
{D8DA0828-9ACC-430F-9A12-E09ECE63823C}.Release|x86.Build.0 = Release|x86
+ {568E8DCD-CAB3-499B-A264-C62B1E1082B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {568E8DCD-CAB3-499B-A264-C62B1E1082B7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {568E8DCD-CAB3-499B-A264-C62B1E1082B7}.Debug|x64.ActiveCfg = Debug|x64
+ {568E8DCD-CAB3-499B-A264-C62B1E1082B7}.Debug|x64.Build.0 = Debug|x64
+ {568E8DCD-CAB3-499B-A264-C62B1E1082B7}.Debug|x86.ActiveCfg = Debug|x86
+ {568E8DCD-CAB3-499B-A264-C62B1E1082B7}.Debug|x86.Build.0 = Debug|x86
+ {568E8DCD-CAB3-499B-A264-C62B1E1082B7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {568E8DCD-CAB3-499B-A264-C62B1E1082B7}.Release|Any CPU.Build.0 = Release|Any CPU
+ {568E8DCD-CAB3-499B-A264-C62B1E1082B7}.Release|x64.ActiveCfg = Release|x64
+ {568E8DCD-CAB3-499B-A264-C62B1E1082B7}.Release|x64.Build.0 = Release|x64
+ {568E8DCD-CAB3-499B-A264-C62B1E1082B7}.Release|x86.ActiveCfg = Release|x86
+ {568E8DCD-CAB3-499B-A264-C62B1E1082B7}.Release|x86.Build.0 = Release|x86
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -140,6 +154,7 @@ Global
{283532A3-A744-4C1C-960C-0B4FE9AF98C9} = {1F0A2AD3-81C3-4A53-8AEF-DC67ABC63183}
{96FB9772-5619-47AD-93FC-F26319EC6BB1} = {1F0A2AD3-81C3-4A53-8AEF-DC67ABC63183}
{D8DA0828-9ACC-430F-9A12-E09ECE63823C} = {1F0A2AD3-81C3-4A53-8AEF-DC67ABC63183}
+ {568E8DCD-CAB3-499B-A264-C62B1E1082B7} = {1F0A2AD3-81C3-4A53-8AEF-DC67ABC63183}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {25300402-D4C7-4F92-841D-894A842FF055}
diff --git a/Tests/UIXrender.Engine.Tests/Program.cs b/Tests/UIXrender.Engine.Tests/Program.cs
new file mode 100644
index 0000000..4cf87bf
--- /dev/null
+++ b/Tests/UIXrender.Engine.Tests/Program.cs
@@ -0,0 +1,199 @@
+using System;
+using System.Threading;
+using Microsoft.Iris.Render.Engine;
+using Microsoft.Iris.Render.Interop;
+using Microsoft.Iris.Render.Interop.Extensions;
+using Microsoft.Iris.Render.Interop.Protocol;
+using Microsoft.Iris.Render.Interop.XmlLite;
+using Microsoft.Iris.Render.Subsystems.Assets;
+using Microsoft.Iris.Render.Subsystems.Lists;
+using Microsoft.Iris.Render.Subsystems.Schema;
+using Microsoft.Iris.Render.Subsystems.Text;
+using Microsoft.Iris.Render.Subsystems.Xml;
+
+// Exercises Microsoft.Iris.Render.Engine.EngineService directly (no P/Invoke, no
+// published native DLL needed) -- unlike Tests/UIXrender.Interop.Tests, this can
+// actually run cross-platform since EngineService is entirely managed/pointer-free.
+// See logs/UIXrender/EngineCore.md.
+
+int failures = 0;
+
+void Check(bool condition, string message)
+{
+ Console.WriteLine((condition ? "PASS: " : "FAIL: ") + message);
+ if (!condition)
+ failures++;
+}
+
+var contextId = new ContextID(99);
+var callbackInvoked = new ManualResetEventSlim(false);
+uint observedSource = 0;
+RENDERHANDLE observedHandle = default;
+byte[]? observedData = null;
+
+BufferReceivedHandler handler = (source, bufferHandle, flags, data) =>
+{
+ observedSource = source.value;
+ observedHandle = bufferHandle;
+ observedData = data.ToArray();
+ callbackInvoked.Set();
+};
+
+IRenderThreadHandle thread = EngineService.StartRenderThread(contextId, handler);
+Check(thread.ContextId == contextId, "StartRenderThread returns a handle for the requested context");
+
+bool signaledOnStart = callbackInvoked.Wait(TimeSpan.FromSeconds(5));
+Check(signaledOnStart, "render thread invoked the handler once on start");
+
+callbackInvoked.Reset();
+byte[] payload = { 1, 2, 3, 4 };
+var sourceContext = new ContextID(7);
+var bufferHandle = new RENDERHANDLE(123);
+HRESULT hr = EngineService.SendBuffer(sourceContext, contextId, bufferHandle, BufferFlags.CopyData, payload);
+Check(hr.IsSuccess(), $"SendBuffer to a registered context succeeds (hr=0x{hr.hr:X8})");
+
+bool signaledOnSend = callbackInvoked.Wait(TimeSpan.FromSeconds(5));
+Check(signaledOnSend, "handler observed a buffer sent via SendBuffer");
+Check(observedSource == 7, $"handler observed the correct source context (got {observedSource})");
+Check(observedHandle == bufferHandle, "handler observed the correct buffer handle");
+Check(observedData is { Length: 4 } d && d[0] == 1 && d[3] == 4, "handler observed the correct payload bytes");
+
+// Unregistering: sending to a torn-down context should fail cleanly, not throw/hang.
+thread.Dispose();
+HRESULT hrAfterDispose = EngineService.SendBuffer(sourceContext, contextId, bufferHandle, BufferFlags.CopyData, payload);
+Check(hrAfterDispose.IsError(), $"SendBuffer to a disposed/unregistered context fails cleanly (hr=0x{hrAfterDispose.hr:X8})");
+
+// ---------------------------------------------------------------------------------
+// Subsystem models added by the full-surface pass (logs/UIXrender/FullSurface.md).
+// The exports wrapping these are [UnmanagedCallersOnly] and so uncallable from C#;
+// these exercise the real logic behind them via InternalsVisibleTo.
+// ---------------------------------------------------------------------------------
+
+Console.WriteLine("\n-- UIXList --");
+var list = new UIXList();
+Check(list.Add(UIXVariant.FromObject(10)) == 1, "UIXList.Add returns the new count");
+list.Add(UIXVariant.FromObject(20));
+list.Add(UIXVariant.FromObject(30));
+Check(list.Count == 3, "UIXList tracks count across adds");
+Check(list.IndexOf(UIXVariant.FromObject(20)) == 1, "UIXList.IndexOf finds a variant by value");
+Check(list.Move(0, 2), "UIXList.Move succeeds for valid indices");
+Check(list.TryGet(2, out UIXVariant moved) && moved.AsInt32 == 10, "UIXList.Move puts the item at the new index");
+Check(!list.Move(0, 99), "UIXList.Move rejects an out-of-range index");
+Check(list.IsItemAvailable(0), "UIXList reports a resident item as available");
+Check(list.RemoveAt(0) && list.Count == 2, "UIXList.RemoveAt removes and updates count");
+list.Clear();
+Check(list.Count == 0, "UIXList.Clear empties the list");
+
+Console.WriteLine("\n-- WaveParser --");
+// Minimal 8-bit mono PCM RIFF/WAVE file: 4 sample bytes.
+byte[] wav =
+[
+ .. "RIFF"u8, 0x28, 0, 0, 0, .. "WAVE"u8,
+ .. "fmt "u8, 16, 0, 0, 0,
+ 1, 0, // PCM
+ 1, 0, // mono
+ 0x44, 0xAC, 0, 0, // 44100 Hz
+ 0x44, 0xAC, 0, 0, // avg bytes/sec
+ 1, 0, // block align
+ 8, 0, // bits per sample
+ .. "data"u8, 4, 0, 0, 0,
+ 0x11, 0x22, 0x33, 0x44,
+];
+Check(WaveParser.TryParse(wav, out SoundHeader wavHeader, out byte[] wavSamples), "WaveParser parses a well-formed PCM RIFF/WAVE buffer");
+Check(wavHeader.samplesPerSec == 44100, $"WaveParser reads the sample rate (got {wavHeader.samplesPerSec})");
+Check(wavHeader.channels == 1 && wavHeader.bitsPerSample == 8, "WaveParser reads channel count and bit depth");
+Check(wavSamples.Length == 4 && wavSamples[0] == 0x11 && wavSamples[3] == 0x44, "WaveParser returns the data chunk's bytes");
+Check(!WaveParser.TryParse("NOTARIFF"u8, out _, out _), "WaveParser rejects a non-RIFF buffer");
+
+Console.WriteLine("\n-- XmlLiteReader --");
+using (XmlLiteReader xml = XmlLiteReader.Create("text", isFragment: false))
+{
+ Check(xml.Read(out NativeXmlNodeType n1) && n1 == NativeXmlNodeType.Element && xml.LocalName == "a", "XmlLiteReader reads the root element");
+ Check(xml.MoveToFirstAttribute() && xml.LocalName == "x" && xml.Value == "1", "XmlLiteReader walks to an attribute and reads its value");
+ Check(xml.Read(out NativeXmlNodeType n2) && n2 == NativeXmlNodeType.Element && xml.IsEmptyElement, "XmlLiteReader reports an empty element");
+ Check(xml.Read(out NativeXmlNodeType n3) && n3 == NativeXmlNodeType.Text && xml.Value == "text", "XmlLiteReader reads a text node");
+ Check(xml.Read(out NativeXmlNodeType n4) && n4 == NativeXmlNodeType.EndElement, "XmlLiteReader reads the end element");
+ Check(!xml.Read(out _), "XmlLiteReader reports end-of-stream (the caller's SUCCEEDED() gate)");
+ Check(xml.LineNumber > 0, "XmlLiteReader reports line information");
+}
+
+Console.WriteLine("\n-- RichTextObject --");
+var rich = new RichTextObject(richTextMode: false, new Microsoft.Iris.Render.Interop.Drawing.Size(400, 100), IntPtr.Zero);
+rich.SetContent("hello");
+Check(rich.Text == "hello" && rich.Length == 5, "RichTextObject.SetContent stores content");
+rich.SetSelectionRange(0, 5);
+Check(rich.GetSelectedText() == "hello", "RichTextObject returns the selected text");
+rich.Copy();
+rich.SetSelectionRange(5, 5);
+Check(rich.Paste() && rich.Text == "hellohello", "RichTextObject copy/paste round-trips through the clipboard");
+Check(rich.CanUndo && rich.Undo() && rich.Text == "hello", "RichTextObject.Undo restores the previous content");
+Check(rich.Redo() && rich.Text == "hellohello", "RichTextObject.Redo reapplies the undone edit");
+rich.ReadOnly = true;
+Check(!rich.InsertText("x"), "RichTextObject refuses edits while read-only");
+rich.ReadOnly = false;
+rich.MaximumLength = 10;
+Check(!rich.InsertText("overflow"), "RichTextObject enforces MaximumLength");
+rich.SetSelectionRange(0, 5);
+Check(rich.DeleteSelection() && rich.Text == "hello", "RichTextObject.DeleteSelection removes the selected range");
+
+Console.WriteLine("\n-- TextMetrics --");
+Microsoft.Iris.Render.Interop.Drawing.Size empty = TextMetrics.Measure("", 12f, false, 0);
+Check(empty.width == 0 && empty.height > 0, "TextMetrics gives empty text zero width but one line of height");
+Microsoft.Iris.Render.Interop.Drawing.Size oneLine = TextMetrics.Measure("hello world", 12f, false, 0);
+Check(oneLine.width > 0 && oneLine.height == TextMetrics.LineHeight(12f), "TextMetrics measures unwrapped text as a single line");
+Microsoft.Iris.Render.Interop.Drawing.Size wrapped = TextMetrics.Measure("hello world this is a longer string", 12f, true, 40);
+Check(wrapped.height > oneLine.height, "TextMetrics wraps into multiple lines when constrained");
+Microsoft.Iris.Render.Interop.Drawing.Size twoParagraphs = TextMetrics.Measure("a\nb", 12f, false, 0);
+Check(twoParagraphs.height == TextMetrics.LineHeight(12f) * 2, "TextMetrics counts explicit newlines as separate lines");
+
+Console.WriteLine("\n-- SchemaRegistration (native reflection over CLR types) --");
+var schema = new SchemaRegistration();
+schema.Add(typeof(SchemaProbe));
+schema.Add(typeof(SchemaProbeKind));
+Check(schema.Types.Count == 1 && schema.Enums.Count == 1, "SchemaRegistration separates types from enums");
+TypeSchema probe = schema.Types[0];
+Check(probe.Name == nameof(SchemaProbe), "TypeSchema exposes the CLR type name");
+Check(Array.Exists(probe.Properties, p => p.Name == nameof(SchemaProbe.Value)), "TypeSchema enumerates public properties");
+Check(Array.Exists(probe.Methods, m => m.Name == nameof(SchemaProbe.Add)), "TypeSchema enumerates public methods");
+Check(probe.Constructors.Length == 1, "TypeSchema enumerates public constructors");
+EnumSchema kind = schema.Enums[0];
+Check(kind.Names.Length == 2 && kind.Values[1] == 5, "EnumSchema reads names and (non-contiguous) values");
+Check(!kind.IsFlags, "EnumSchema reports non-flags enums correctly");
+
+Console.WriteLine("\n-- UIXVariant round-trip --");
+Check(UIXVariant.FromObject(42).AsInt32 == 42, "UIXVariant round-trips Int32");
+Check(Math.Abs(UIXVariant.FromObject(1.5f).AsSingle - 1.5f) < float.Epsilon, "UIXVariant round-trips Single");
+Check(Math.Abs(UIXVariant.FromObject(2.5d).AsDouble - 2.5d) < double.Epsilon, "UIXVariant round-trips Double");
+Check(UIXVariant.FromObject(true).AsBool, "UIXVariant round-trips Bool");
+Check(UIXVariant.FromObject(7L).ToObject() is long and 7L, "UIXVariant.ToObject returns the tagged CLR type");
+Check(UIXVariant.Empty.type == VariantType.Empty, "UIXVariant.Empty carries the Empty tag");
+
+Console.WriteLine("\n-- Remote channel (managed-direct wiring behind UIX.RenderApi's SpRemote*) --");
+HRESULT vc = EngineService.RemoteCreateServerStreams("t", TransportProtocol.VC, out _, out _);
+Check(vc.IsError(), "RemoteCreateServerStreams rejects the unrecovered VC transport (E_NOTIMPL)");
+HRESULT made = EngineService.RemoteCreateServerStreams("uixrender-test", TransportProtocol.TCP, out IntPtr sendStream, out IntPtr recvStream);
+Check(made.IsSuccess() && sendStream != IntPtr.Zero && recvStream != IntPtr.Zero, "RemoteCreateServerStreams (TCP) yields two stream handles");
+Check(sendStream != recvStream, "send and receive are distinct handles, so releasing each is a separate safe op");
+// Releasing both (RefCount 2 -> 0) tears the listener down; if the refcount/handle model
+// were wrong this would double-free or throw, and the harness would never reach the end.
+EngineService.ReleaseRemoteStream(sendStream);
+EngineService.ReleaseRemoteStream(recvStream);
+Check(true, "releasing both stream handles disposes the connection exactly once, without error");
+
+Console.WriteLine();
+Console.WriteLine(failures == 0 ? "ALL CHECKS PASSED" : $"{failures} CHECK(S) FAILED");
+return failures == 0 ? 0 : 1;
+
+// Probe types for the schema checks above -- deliberately trivial, since what's being
+// verified is the projection machinery, not any particular type's shape.
+internal sealed class SchemaProbe
+{
+ public int Value { get; set; }
+ public int Add(int a, int b) => a + b;
+}
+
+internal enum SchemaProbeKind
+{
+ First = 0,
+ Second = 5,
+}
diff --git a/Tests/UIXrender.Engine.Tests/UIXrender.Engine.Tests.csproj b/Tests/UIXrender.Engine.Tests/UIXrender.Engine.Tests.csproj
new file mode 100644
index 0000000..237d242
--- /dev/null
+++ b/Tests/UIXrender.Engine.Tests/UIXrender.Engine.Tests.csproj
@@ -0,0 +1,11 @@
+
+
+
+ Exe
+ enable
+
+
+
+
+
+
diff --git a/UIX.RenderApi/Microsoft/Iris/Render/Protocol/EngineApi.cs b/UIX.RenderApi/Microsoft/Iris/Render/Protocol/EngineApi.cs
index 6cd5e72..f425c0a 100644
--- a/UIX.RenderApi/Microsoft/Iris/Render/Protocol/EngineApi.cs
+++ b/UIX.RenderApi/Microsoft/Iris/Render/Protocol/EngineApi.cs
@@ -10,6 +10,10 @@ using System.Runtime.InteropServices;
using System.Security;
using Microsoft.Iris.Render.Common;
using Microsoft.Iris.Render.Internal;
+#if !NETFRAMEWORK
+using Microsoft.Iris.Render.Engine;
+using Iface = Microsoft.Iris.Render.Interop;
+#endif
namespace Microsoft.Iris.Render.Protocol
{
@@ -18,6 +22,16 @@ namespace Microsoft.Iris.Render.Protocol
{
private const string s_stEhRenderDll = "UIXRender.dll";
+ // SpInit/SpUninit/SpBufferOpen/SpWrapBufferProc/SpRenderThreadInit/
+ // SpRenderThreadUninit below call directly into UIXrender's managed
+ // Microsoft.Iris.Render.Engine.EngineService instead of P/Invoking into
+ // UIXRender.dll -- no native marshaling when both assemblies are loaded in the
+ // same process. Everything else in this file is untouched DllImport, since
+ // UIXrender doesn't implement those exports yet. See
+ // logs/UIXrender/EngineCore.md for the reasoning (first modification of
+ // previously-decompiled code in this project) and why these 6 are exactly the
+ // set RenderPort.cs/LocalChannel.cs actually use.
+
public static void IFC(HRESULT hr)
{
if (hr.Int >= 0)
@@ -102,6 +116,49 @@ namespace Microsoft.Iris.Render.Protocol
}
}
+#if !NETFRAMEWORK
+ public static HRESULT SpInit(ref InitArgs args) => new HRESULT(0);
+
+ public static HRESULT SpUninit() => new HRESULT(0);
+
+ public static unsafe HRESULT SpBufferOpen(
+ BufferInfo* phdrData,
+ void* pvData)
+ {
+ var src = new Iface.ContextID(ContextID.ToUInt32(phdrData->idContextSrc));
+ var dest = new Iface.ContextID(ContextID.ToUInt32(phdrData->idContextDest));
+ var bufferHandle = new Iface.RENDERHANDLE(RENDERHANDLE.ToUInt32(phdrData->idBuffer));
+ var flags = (Iface.BufferFlags)phdrData->nFlags;
+ var span = new ReadOnlySpan(pvData, (int)phdrData->cbSizeBuffer);
+
+ Iface.HRESULT result = EngineService.SendBuffer(src, dest, bufferHandle, flags, span);
+ return new HRESULT(result.hr);
+ }
+
+ public static unsafe HRESULT SpWrapBufferProc(
+ MessageBufferEventHandler pfnProcessBufferProc,
+ IntPtr* ppNativeProc)
+ {
+ if (ppNativeProc == null)
+ return new HRESULT(unchecked((int)0x80070057));
+
+ if (pfnProcessBufferProc == null)
+ {
+ *ppNativeProc = IntPtr.Zero;
+ return new HRESULT(0);
+ }
+
+ // Managed-direct: store the delegate itself instead of marshaling to a
+ // native function pointer -- SpRenderThreadInit resolves this straight back
+ // to the delegate object and invokes it directly, no calli anywhere on this
+ // path. See logs/UIXrender/EngineCore.md.
+ GCHandle handle = GCHandle.Alloc(pfnProcessBufferProc, GCHandleType.Normal);
+ *ppNativeProc = GCHandle.ToIntPtr(handle);
+ return new HRESULT(0);
+ }
+#else
+ // net461 (EnableNetFXTarget): UIXrender's managed API isn't referenceable from
+ // .NET Framework, so this TFM keeps calling the real native UIXRender.dll.
[DllImport(s_stEhRenderDll)]
public static extern HRESULT SpInit(ref InitArgs args);
@@ -117,7 +174,41 @@ namespace Microsoft.Iris.Render.Protocol
public static extern unsafe HRESULT SpWrapBufferProc(
MessageBufferEventHandler pfnProcessBufferProc,
IntPtr* ppNativeProc);
+#endif
+#if !NETFRAMEWORK
+ // No Win32 message queue exists behind this reimplementation (see EngineService),
+ // and LocalChannel -- the path Zune uses -- never peeks. Reports "no message".
+ public static HRESULT SpPeekMessage(
+ out Win32Api.MSG msg,
+ HWND hwnd,
+ uint nMsgFilterMin,
+ uint nMsgFilterMax,
+ uint wRemoveMsg,
+ out WorkResult nResult)
+ {
+ msg = default;
+ nResult = (WorkResult)EngineService.PeekMessage(nMsgFilterMin, nMsgFilterMax, wRemoveMsg);
+ return new HRESULT(0);
+ }
+
+ public static HRESULT SpWaitMessage(uint nTimeOutMs, IntPtr _unused)
+ {
+ EngineService.WaitMessage(nTimeOutMs);
+ return new HRESULT(0);
+ }
+
+ public static HRESULT SpInvoke(
+ ContextID idContext,
+ IntPtr pfnInvoke,
+ IntPtr pvArgs,
+ bool synchronous)
+ {
+ Iface.HRESULT result = EngineService.Invoke(
+ new Iface.ContextID(ContextID.ToUInt32(idContext)), pfnInvoke, pvArgs, synchronous);
+ return new HRESULT(result.hr);
+ }
+#else
[DllImport(s_stEhRenderDll, CharSet = CharSet.Auto)]
public static extern HRESULT SpPeekMessage(
out Win32Api.MSG msg,
@@ -136,7 +227,65 @@ namespace Microsoft.Iris.Render.Protocol
IntPtr pfnInvoke,
IntPtr pvArgs,
bool synchronous);
+#endif
+#if !NETFRAMEWORK
+ public static HRESULT SpRenderThreadInit(
+ ref InitArgs argsRender,
+ out IntPtr pThread)
+ {
+ var contextId = new Iface.ContextID(ContextID.ToUInt32(argsRender.idContext));
+
+ MessageBufferEventHandler managedCallback = argsRender.pfnProcessBuffer != IntPtr.Zero
+ ? GCHandle.FromIntPtr(argsRender.pfnProcessBuffer).Target as MessageBufferEventHandler
+ : null;
+
+ BufferReceivedHandler handler = managedCallback != null
+ ? AdaptCallback(managedCallback, argsRender.idContext)
+ : delegate { };
+
+ IRenderThreadHandle threadHandle = EngineService.StartRenderThread(contextId, handler);
+ pThread = GCHandle.ToIntPtr(GCHandle.Alloc(threadHandle, GCHandleType.Normal));
+ return new HRESULT(0);
+ }
+
+ // Adapts a stored MessageBufferEventHandler (already-decompiled, still
+ // pointer-shaped -- see logs/UIXrender/EngineCore.md) into the idiomatic
+ // BufferReceivedHandler shape EngineService deals in. Factored out of
+ // SpRenderThreadInit so that method reads as resolve -> adapt -> start -> wrap,
+ // and named to make clear it's the same kind of adaptation
+ // UIXrender/Interop/EngineApi.cs's own SpRenderThreadInit does for native
+ // callers (there: raw function pointer -> BufferReceivedHandler; here: managed
+ // delegate -> BufferReceivedHandler -- same shape, different invocation
+ // mechanism at the end).
+ private static unsafe BufferReceivedHandler AdaptCallback(MessageBufferEventHandler callback, ContextID destContext) =>
+ (source, bufferHandle, flags, data) =>
+ {
+ fixed (byte* pData = data)
+ {
+ var info = new BufferInfo
+ {
+ idContextSrc = ContextID.FromUInt32(source.value),
+ idContextDest = destContext,
+ idBuffer = RENDERHANDLE.FromUInt32(bufferHandle.value),
+ nFlags = (BufferFlags)flags,
+ cbSizeBuffer = (uint)data.Length,
+ };
+ callback(IntPtr.Zero, source.value, &info, pData);
+ }
+ };
+
+ public static HRESULT SpRenderThreadUninit(IntPtr pThread)
+ {
+ if (pThread == IntPtr.Zero)
+ return new HRESULT(unchecked((int)0x80070057));
+
+ GCHandle handle = GCHandle.FromIntPtr(pThread);
+ (handle.Target as IDisposable)?.Dispose();
+ handle.Free();
+ return new HRESULT(0);
+ }
+#else
[DllImport(s_stEhRenderDll)]
public static extern HRESULT SpRenderThreadInit(
ref InitArgs argsRender,
@@ -144,7 +293,86 @@ namespace Microsoft.Iris.Render.Protocol
[DllImport(s_stEhRenderDll)]
public static extern HRESULT SpRenderThreadUninit(IntPtr pThread);
+#endif
+#if !NETFRAMEWORK
+ public static HRESULT SpRemoteCreateServerStreams(
+ string stSession,
+ TransportProtocol nProtocol,
+ out IntPtr pSendStream,
+ out IntPtr pReceiveStream)
+ {
+ Iface.HRESULT result = EngineService.RemoteCreateServerStreams(
+ stSession, (Iface.Protocol.TransportProtocol)(int)nProtocol, out pSendStream, out pReceiveStream);
+ return new HRESULT(result.hr);
+ }
+
+ public static HRESULT SpRemoteWaitServerStreamsConnected(
+ TransportProtocol nProtocol,
+ IntPtr pSendStream,
+ IntPtr pReceiveStream)
+ {
+ Iface.HRESULT result = EngineService.RemoteWaitServerStreamsConnected(
+ (Iface.Protocol.TransportProtocol)(int)nProtocol, pSendStream);
+ return new HRESULT(result.hr);
+ }
+
+ public static HRESULT SpRemoteServerInit(
+ IntPtr pSendStream,
+ IntPtr pReceiveStream,
+ InitArgs argsSend,
+ out IntPtr pSession)
+ {
+ var context = new Iface.ContextID(ContextID.ToUInt32(argsSend.idContext));
+
+ // Same delegate-behind-a-GCHandle representation SpRenderThreadInit resolves;
+ // RemoteChannel connects without a receive callback (pfnProcessBuffer == 0),
+ // so this is normally null.
+ MessageBufferEventHandler managedCallback = argsSend.pfnProcessBuffer != IntPtr.Zero
+ ? GCHandle.FromIntPtr(argsSend.pfnProcessBuffer).Target as MessageBufferEventHandler
+ : null;
+ BufferReceivedHandler handler = managedCallback != null
+ ? AdaptCallback(managedCallback, argsSend.idContext)
+ : null;
+
+ Iface.HRESULT result = EngineService.RemoteServerInit(pSendStream, context, handler, out pSession);
+ return new HRESULT(result.hr);
+ }
+
+ public static HRESULT SpRemoteServerUninit(
+ IntPtr pSession,
+ bool fForceShutdown,
+ out ShutdownReason nShutdownReason)
+ {
+ Iface.HRESULT result = EngineService.RemoteServerUninit(
+ pSession, fForceShutdown, out Iface.Protocol.ShutdownReason reason);
+ nShutdownReason = (ShutdownReason)(int)reason;
+ return new HRESULT(result.hr);
+ }
+
+ public static HRESULT SpDx9CompileEffect(
+ string stEffect,
+ string stDefines,
+ out IntPtr pErrorString,
+ out IntPtr pErrorBuffer,
+ out IntPtr pEffectBlob,
+ out uint EffectBlobSize,
+ out IntPtr pEffectBlobBuffer)
+ {
+ pErrorString = IntPtr.Zero;
+ pErrorBuffer = IntPtr.Zero;
+ pEffectBlob = IntPtr.Zero;
+ EffectBlobSize = 0U;
+ pEffectBlobBuffer = IntPtr.Zero;
+ return new HRESULT(EngineService.Dx9CompileEffect().hr);
+ }
+
+ // SpObjectRelease's only callers (RemoteChannel) release the stream handles from
+ // SpRemoteCreateServerStreams, which in the managed-direct path are UIXrender
+ // handles, not COM pointers -- so this drops the handle's reference rather than
+ // calling through a vtable.
+ public static void SpObjectRelease(IntPtr pUnknown) => EngineService.ReleaseRemoteStream(pUnknown);
+#else
[DllImport(s_stEhRenderDll, CharSet = CharSet.Unicode)]
public static extern HRESULT SpRemoteCreateServerStreams(
string stSession,
@@ -183,6 +411,7 @@ namespace Microsoft.Iris.Render.Protocol
[DllImport(s_stEhRenderDll)]
public static extern void SpObjectRelease(IntPtr pUnknown);
+#endif
[Flags]
public enum BufferFlags
diff --git a/UIX.RenderApi/Microsoft/Iris/Render/Protocol/RenderPort.cs b/UIX.RenderApi/Microsoft/Iris/Render/Protocol/RenderPort.cs
index 86319b5..6ee299c 100644
--- a/UIX.RenderApi/Microsoft/Iris/Render/Protocol/RenderPort.cs
+++ b/UIX.RenderApi/Microsoft/Iris/Render/Protocol/RenderPort.cs
@@ -581,7 +581,7 @@ namespace Microsoft.Iris.Render.Protocol
switch (operation)
{
case ObjectCache.Operation.Alloc:
- key = new MessageHeap(8U * Win32Api.GetSystemPageSize(), (uint)sizeof(MessageBatchHeader), (uint)sizeof(MessageBatchEntry));
+ key = new MessageHeap(8U * (uint)Environment.SystemPageSize, (uint)sizeof(MessageBatchHeader), (uint)sizeof(MessageBatchEntry));
this._activeHeaps[key] = key;
break;
case ObjectCache.Operation.Free:
diff --git a/UIX.RenderApi/UIX.RenderApi.csproj b/UIX.RenderApi/UIX.RenderApi.csproj
index 36eadfb..815da8d 100644
--- a/UIX.RenderApi/UIX.RenderApi.csproj
+++ b/UIX.RenderApi/UIX.RenderApi.csproj
@@ -10,4 +10,10 @@
true
+
+
+
+
+
\ No newline at end of file
diff --git a/UIXInterop/NativeString.cs b/UIXInterop/NativeString.cs
index 8672f65..30f7bc6 100644
--- a/UIXInterop/NativeString.cs
+++ b/UIXInterop/NativeString.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Concurrent;
using System.Runtime.InteropServices;
namespace Microsoft.Iris.Interop;
@@ -11,4 +12,26 @@ public static unsafe class NativeString
public static string AnsiToString(byte* p) => p == null ? null : Marshal.PtrToStringAnsi((IntPtr)p);
public static string UniToString(char* p) => p == null ? null : Marshal.PtrToStringUni((IntPtr)p);
+
+ // Interned, caller-does-not-free native copies. Several exports hand a `char*` back
+ // out (SpQueryTypeName, SpQueryPropertyName, SpQueryMethodName, SpQueryEnumName, ...)
+ // and their managed declarations never free what they receive -- so the callee has to
+ // own the memory for the process lifetime. Interning by value keeps that bounded:
+ // these are schema names drawn from a fixed set of registered types, so the pool
+ // stops growing once every name has been asked for once, instead of leaking a fresh
+ // allocation per call.
+ private static readonly ConcurrentDictionary s_interned = new(StringComparer.Ordinal);
+
+ public static char* InternUni(string value)
+ {
+ if (value == null)
+ return null;
+
+ return (char*)s_interned.GetOrAdd(value, static v => Marshal.StringToHGlobalUni(v));
+ }
+
+ // Non-interned copy, for values that are genuinely per-call (query results, converted
+ // strings) rather than fixed schema names. The caller frees these via SpMemFree.
+ public static char* AllocUni(string value) =>
+ value == null ? null : (char*)Marshal.StringToHGlobalUni(value);
}
diff --git a/UIXrender/Engine/BufferReceivedHandler.cs b/UIXrender/Engine/BufferReceivedHandler.cs
new file mode 100644
index 0000000..07ec3bb
--- /dev/null
+++ b/UIXrender/Engine/BufferReceivedHandler.cs
@@ -0,0 +1,9 @@
+using System;
+using Microsoft.Iris.Render.Interop;
+
+namespace Microsoft.Iris.Render.Engine;
+
+// A custom (non-generic) delegate so ReadOnlySpan is legal here -- Span can't
+// be an Action/Func type argument (ref structs aren't valid generic arguments),
+// but a hand-declared delegate type can take one directly.
+public delegate void BufferReceivedHandler(ContextID sourceContext, RENDERHANDLE bufferHandle, BufferFlags flags, ReadOnlySpan data);
diff --git a/UIXrender/Engine/ContextRegistry.cs b/UIXrender/Engine/ContextRegistry.cs
index 9222c88..543c14d 100644
--- a/UIXrender/Engine/ContextRegistry.cs
+++ b/UIXrender/Engine/ContextRegistry.cs
@@ -1,35 +1,21 @@
-using System;
using System.Collections.Concurrent;
using Microsoft.Iris.Render.Interop;
namespace Microsoft.Iris.Render.Engine;
-// Maps a registered ContextID to the buffer-processing callback it should receive
-// deliveries on, via SpBufferOpen -- the smallest coherent slice of "Engine core" (see
-// logs/UIXrender/EngineCore.md) needed for SpRenderThreadInit and SpBufferOpen to mean
-// anything together. Callback stored as a raw IntPtr (not a typed function pointer
-// field) so nothing here needs to be unsafe/deal with function-pointer-as-generic-arg
-// concerns; callers cast at the point they actually invoke it.
+// Maps a registered ContextID to the handler it should receive deliveries on via
+// SendBuffer -- the smallest coherent slice of "Engine core" (see
+// logs/UIXrender/EngineCore.md) needed for StartRenderThread and SendBuffer to mean
+// anything together. Holds an ordinary delegate now (not a raw callback pointer) --
+// pointer marshaling, where it's still needed for native callers, lives entirely in
+// Interop/EngineApi.cs.
internal static class ContextRegistry
{
- internal readonly struct Entry
- {
- public readonly IntPtr Callback;
- public readonly IntPtr CallbackData;
+ private static readonly ConcurrentDictionary s_contexts = new();
- public Entry(IntPtr callback, IntPtr callbackData)
- {
- Callback = callback;
- CallbackData = callbackData;
- }
- }
-
- private static readonly ConcurrentDictionary s_contexts = new();
-
- public static void Register(ContextID id, IntPtr callback, IntPtr callbackData)
- => s_contexts[id.value] = new Entry(callback, callbackData);
+ public static void Register(ContextID id, BufferReceivedHandler handler) => s_contexts[id.value] = handler;
public static void Unregister(ContextID id) => s_contexts.TryRemove(id.value, out _);
- public static bool TryGet(ContextID id, out Entry entry) => s_contexts.TryGetValue(id.value, out entry);
+ public static bool TryGet(ContextID id, out BufferReceivedHandler handler) => s_contexts.TryGetValue(id.value, out handler);
}
diff --git a/UIXrender/Engine/EngineService.cs b/UIXrender/Engine/EngineService.cs
new file mode 100644
index 0000000..1c7febe
--- /dev/null
+++ b/UIXrender/Engine/EngineService.cs
@@ -0,0 +1,95 @@
+using System;
+using Microsoft.Iris.Render.Interop;
+using Microsoft.Iris.Render.Interop.Protocol;
+using Microsoft.Iris.Render.Subsystems.Remote;
+
+namespace Microsoft.Iris.Render.Engine;
+
+// The idiomatic managed API for UIXrender's core transport -- the single thing both
+// Interop/EngineApi.cs's [UnmanagedCallersOnly] shims (for native callers) and managed
+// callers like UIX.RenderApi call into, so there is exactly one implementation of each
+// operation. The buffer-delivery path (StartRenderThread/SendBuffer) is entirely
+// pointer-free. The remainder (Invoke, the SpRemote* family, ObjectRelease) deal in
+// opaque IntPtr *handles* the caller round-trips back to us -- never dereferenced here --
+// plus, for Invoke only, a raw function pointer the caller asked us to run; that one spot
+// is the sole `unsafe` in this type. See logs/UIXrender/EngineCore.md and FullSurface.md.
+public static class EngineService
+{
+ // ---- buffer delivery (pointer-free) ----------------------------------------------
+
+ public static IRenderThreadHandle StartRenderThread(ContextID contextId, BufferReceivedHandler onBufferReceived)
+ => RenderThread.Start(contextId, onBufferReceived);
+
+ public static HRESULT SendBuffer(ContextID sourceContext, ContextID destContext, RENDERHANDLE bufferHandle, BufferFlags flags, ReadOnlySpan data)
+ {
+ if (!ContextRegistry.TryGet(destContext, out BufferReceivedHandler handler))
+ return HRESULT.E_FAIL;
+
+ handler(sourceContext, bufferHandle, flags, data);
+ return HRESULT.S_OK;
+ }
+
+ // ---- message pump ----------------------------------------------------------------
+
+ // There is no Win32 message queue behind this reimplementation yet (no window is
+ // created anywhere), and LocalChannel -- the path real Zune uses -- never peeks: it
+ // relies on the SendBuffer callback instead. So a peek always reports "no message".
+ // Returns the WorkResult flags value (0 = nothing processed / no new message).
+ public static uint PeekMessage(uint filterMin, uint filterMax, uint removeMsg) => 0;
+
+ // Real timeout wait (there is no message to wake early for, per PeekMessage's note),
+ // so "wait for a message or the timeout" collapses to waiting out the timeout.
+ public static void WaitMessage(uint timeoutMs)
+ {
+ if (timeoutMs != 0)
+ System.Threading.Thread.Sleep((int)Math.Min(timeoutMs, int.MaxValue));
+ }
+
+ // Runs a deferred callback. The only in-repo caller (IRenderEngine.InterThreadWake)
+ // passes a null function pointer purely to wake a pump -- and there is no blocking
+ // pump here -- so a null pointer is a well-defined no-op rather than an error.
+ public static unsafe HRESULT Invoke(ContextID context, IntPtr pfnInvoke, IntPtr pvArgs, bool synchronous)
+ {
+ if (pfnInvoke == IntPtr.Zero)
+ return HRESULT.S_OK;
+
+ if (synchronous)
+ {
+ ((delegate* unmanaged)pfnInvoke)(pvArgs);
+ }
+ else
+ {
+ IntPtr fn = pfnInvoke;
+ IntPtr args = pvArgs;
+ System.Threading.ThreadPool.QueueUserWorkItem(_ => ((delegate* unmanaged)fn)(args));
+ }
+ return HRESULT.S_OK;
+ }
+
+ // ---- remote channel (opaque IntPtr handles, never dereferenced) -------------------
+
+ public static HRESULT RemoteCreateServerStreams(string session, TransportProtocol protocol, out IntPtr sendStream, out IntPtr receiveStream)
+ => RemoteServerConnection.CreateServerStreams(protocol, session, out sendStream, out receiveStream);
+
+ public static HRESULT RemoteWaitServerStreamsConnected(TransportProtocol protocol, IntPtr sendStream)
+ => RemoteServerConnection.WaitConnected(protocol, sendStream);
+
+ public static HRESULT RemoteServerInit(IntPtr sendStream, ContextID context, BufferReceivedHandler onRemoteToLocal, out IntPtr session)
+ => RemoteServerConnection.ServerInit(sendStream, context, onRemoteToLocal, out session);
+
+ public static HRESULT RemoteServerUninit(IntPtr session, bool forceShutdown, out ShutdownReason reason)
+ => RemoteServerConnection.ServerUninit(session, forceShutdown, out reason);
+
+ // Backs SpObjectRelease in the managed-direct path, whose only callers release the
+ // remote stream handles minted by RemoteCreateServerStreams.
+ public static void ReleaseRemoteStream(IntPtr streamHandle)
+ => RemoteServerConnection.ReleaseHandle(streamHandle);
+
+ // ---- effects ---------------------------------------------------------------------
+
+ // HLSL effect compilation is a graphics-API-specific service with no cross-platform
+ // abstraction available under this project's dependency policy -- so this reports
+ // "not implemented" honestly rather than faking a compiled blob. See
+ // logs/UIXrender/FullSurface.md, decision 1.
+ public static HRESULT Dx9CompileEffect() => HRESULT.E_NOTIMPL;
+}
diff --git a/UIXrender/Engine/HandleTable.cs b/UIXrender/Engine/HandleTable.cs
new file mode 100644
index 0000000..1f6b122
--- /dev/null
+++ b/UIXrender/Engine/HandleTable.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Microsoft.Iris.Render.Engine;
+
+// Every UIXrender export that hands an opaque "pointer" back to a caller (schema objects,
+// XML readers, rich-text objects, bitmaps, data queries, ...) uses this: the managed
+// object is pinned by a GCHandle and the caller only ever sees the GCHandle's IntPtr.
+// This is the same convention SpRenderThreadInit already established for its thread
+// handle -- centralised here so ~10 subsystems don't each re-derive it.
+//
+// Deliberately not a "real" pointer: the original native library handed out genuine C++
+// object pointers, but nothing outside UIXrender is allowed to dereference these (the
+// managed declarations all type them as opaque IntPtr/HANDLE), so an indirection is
+// both safe and much harder to corrupt.
+internal static class HandleTable
+{
+ public static IntPtr Alloc(object value) =>
+ value == null ? IntPtr.Zero : GCHandle.ToIntPtr(GCHandle.Alloc(value, GCHandleType.Normal));
+
+ public static T Get(IntPtr handle) where T : class =>
+ handle == IntPtr.Zero ? null : GCHandle.FromIntPtr(handle).Target as T;
+
+ public static bool TryGet(IntPtr handle, out T value) where T : class
+ {
+ value = Get(handle);
+ return value != null;
+ }
+
+ public static void Free(IntPtr handle)
+ {
+ if (handle == IntPtr.Zero)
+ return;
+
+ GCHandle gc = GCHandle.FromIntPtr(handle);
+ if (gc.IsAllocated)
+ {
+ (gc.Target as IDisposable)?.Dispose();
+ gc.Free();
+ }
+ }
+}
diff --git a/UIXrender/Engine/IRenderThreadHandle.cs b/UIXrender/Engine/IRenderThreadHandle.cs
new file mode 100644
index 0000000..f42ff2f
--- /dev/null
+++ b/UIXrender/Engine/IRenderThreadHandle.cs
@@ -0,0 +1,9 @@
+using System;
+using Microsoft.Iris.Render.Interop;
+
+namespace Microsoft.Iris.Render.Engine;
+
+public interface IRenderThreadHandle : IDisposable
+{
+ ContextID ContextId { get; }
+}
diff --git a/UIXrender/Engine/RenderThread.cs b/UIXrender/Engine/RenderThread.cs
index f4acacd..435d189 100644
--- a/UIXrender/Engine/RenderThread.cs
+++ b/UIXrender/Engine/RenderThread.cs
@@ -4,59 +4,46 @@ using Microsoft.Iris.Render.Interop;
namespace Microsoft.Iris.Render.Engine;
-// Backs SpRenderThreadInit/SpRenderThreadUninit: spins up a genuine OS thread the CLR
-// didn't create, which invokes the registered buffer-processing callback once (with
-// synthetic data, proving the round trip) and then waits for shutdown -- this is the
-// exact mechanism every Remote*/Local*Callback class in UIX.RenderApi depends on via
-// LocalChannel.Connect() -> SpRenderThreadInit. See logs/UIXrender/EngineCore.md. Not
-// yet the full message-pump loop (that's the larger "Engine core" subsystem, still
-// ahead) -- this proves the interop mechanism, not the whole dispatch engine.
-internal sealed unsafe class RenderThread
+// Backs EngineService.StartRenderThread: spins up a genuine OS thread the CLR didn't
+// create, which invokes the registered handler once (with synthetic data, proving the
+// round trip) and then waits for shutdown -- this is the exact mechanism every
+// Remote*/Local*Callback class in UIX.RenderApi depends on via LocalChannel.Connect().
+// See logs/UIXrender/EngineCore.md. Not yet the full message-pump loop (that's the
+// larger "Engine core" subsystem, still ahead) -- this proves the interop mechanism,
+// not the whole dispatch engine. No pointers here at all now -- callers (native or
+// managed) each supply an ordinary BufferReceivedHandler delegate.
+internal sealed class RenderThread : IRenderThreadHandle
{
- private readonly ContextID _contextId;
- private readonly IntPtr _callback;
- private readonly IntPtr _callbackData;
+ private readonly BufferReceivedHandler _handler;
private readonly ManualResetEventSlim _shutdown = new(false);
private readonly Thread _thread;
- private RenderThread(ContextID contextId, IntPtr callback, IntPtr callbackData)
+ public ContextID ContextId { get; }
+
+ private RenderThread(ContextID contextId, BufferReceivedHandler handler)
{
- _contextId = contextId;
- _callback = callback;
- _callbackData = callbackData;
+ ContextId = contextId;
+ _handler = handler;
_thread = new Thread(Run) { IsBackground = true, Name = $"UIXrender.RenderThread[{contextId.value}]" };
}
- public static RenderThread Start(in InitArgs args)
+ public static RenderThread Start(ContextID contextId, BufferReceivedHandler handler)
{
- var thread = new RenderThread(args.idContext, args.pfnProcessBuffer, args.pvProcessData);
- ContextRegistry.Register(args.idContext, args.pfnProcessBuffer, args.pvProcessData);
+ var thread = new RenderThread(contextId, handler);
+ ContextRegistry.Register(contextId, handler);
thread._thread.Start();
return thread;
}
private void Run()
{
- if (_callback != IntPtr.Zero)
- {
- var info = new BufferInfo
- {
- idContextSrc = _contextId,
- idContextDest = _contextId,
- idBuffer = RENDERHANDLE.NULL,
- nFlags = 0,
- cbSizeBuffer = 0,
- };
- var fn = (delegate* unmanaged)_callback;
- fn(_callbackData, _contextId.value, &info, null);
- }
-
+ _handler?.Invoke(ContextId, RENDERHANDLE.NULL, default, ReadOnlySpan.Empty);
_shutdown.Wait();
}
- public void Stop()
+ public void Dispose()
{
- ContextRegistry.Unregister(_contextId);
+ ContextRegistry.Unregister(ContextId);
_shutdown.Set();
_thread.Join();
_shutdown.Dispose();
diff --git a/UIXrender/Interop/Com/ComVtable.cs b/UIXrender/Interop/Com/ComVtable.cs
new file mode 100644
index 0000000..c26625f
--- /dev/null
+++ b/UIXrender/Interop/Com/ComVtable.cs
@@ -0,0 +1,47 @@
+using System;
+
+namespace Microsoft.Iris.Render.Interop.Com;
+
+// Several UIXrender exports take a COM callback interface (IUIXListCallbacks,
+// IRawUIXServices, IRichTextCallbacks, IImeCallbacks). Their managed declarations use
+// [MarshalAs(UnmanagedType.Interface)], but an [UnmanagedCallersOnly] method cannot
+// accept a non-blittable parameter -- what actually arrives on the wire either way is a
+// pointer to the object's vtable-bearing COM instance. So UIXrender receives IntPtr and
+// calls through the vtable here.
+//
+// This is the "reconstruct the interface rather than invent a raw pointer type" approach
+// CLAUDE.md's *COM objects* section prescribes, applied in the callee direction: slot
+// numbering starts at 3 because every one of these interfaces is
+// ComInterfaceType.InterfaceIsIUnknown, so slots 0/1/2 are
+// QueryInterface/AddRef/Release, and the declared methods follow in declaration order.
+internal static unsafe class ComVtable
+{
+ // Slot 0/1/2 of IUnknown.
+ public const int SlotQueryInterface = 0;
+ public const int SlotAddRef = 1;
+ public const int SlotRelease = 2;
+
+ // First slot available to a derived interface's own methods.
+ public const int FirstMethodSlot = 3;
+
+ public static void* Slot(IntPtr comObject, int slot)
+ {
+ if (comObject == IntPtr.Zero)
+ return null;
+
+ void** vtable = *(void***)comObject;
+ return vtable[slot];
+ }
+
+ public static uint AddRef(IntPtr comObject)
+ {
+ void* fn = Slot(comObject, SlotAddRef);
+ return fn == null ? 0 : ((delegate* unmanaged)fn)(comObject);
+ }
+
+ public static uint Release(IntPtr comObject)
+ {
+ void* fn = Slot(comObject, SlotRelease);
+ return fn == null ? 0 : ((delegate* unmanaged)fn)(comObject);
+ }
+}
diff --git a/UIXrender/Interop/Drawing/Color.cs b/UIXrender/Interop/Drawing/Color.cs
new file mode 100644
index 0000000..044508c
--- /dev/null
+++ b/UIXrender/Interop/Drawing/Color.cs
@@ -0,0 +1,21 @@
+using System.Runtime.InteropServices;
+
+namespace Microsoft.Iris.Render.Interop.Drawing;
+
+// Bit-for-bit mirror of Microsoft.Iris.Drawing.Color (UIX/Microsoft/Iris/Drawing/Color.cs)
+// -- a single packed 0xAARRGGBB uint, not four separate byte fields.
+[StructLayout(LayoutKind.Sequential)]
+public struct Color
+{
+ public uint value;
+
+ public Color(uint value) => this.value = value;
+
+ public byte A => (byte)(value >> 24);
+ public byte R => (byte)(value >> 16);
+ public byte G => (byte)(value >> 8);
+ public byte B => (byte)value;
+
+ public static Color FromArgb(byte a, byte r, byte g, byte b) =>
+ new((uint)((a << 24) | (r << 16) | (g << 8) | b));
+}
diff --git a/UIXrender/Interop/Drawing/ColorF.cs b/UIXrender/Interop/Drawing/ColorF.cs
new file mode 100644
index 0000000..f971e16
--- /dev/null
+++ b/UIXrender/Interop/Drawing/ColorF.cs
@@ -0,0 +1,13 @@
+using System.Runtime.InteropServices;
+
+namespace Microsoft.Iris.Render.Interop.Drawing;
+
+// Bit-for-bit mirror of Microsoft.Iris.Render.ColorF (UIX.RenderApi/Microsoft/Iris/Render/ColorF.cs).
+[StructLayout(LayoutKind.Sequential)]
+public struct ColorF
+{
+ public float a;
+ public float r;
+ public float g;
+ public float b;
+}
diff --git a/UIXrender/Interop/Drawing/Point.cs b/UIXrender/Interop/Drawing/Point.cs
new file mode 100644
index 0000000..858b657
--- /dev/null
+++ b/UIXrender/Interop/Drawing/Point.cs
@@ -0,0 +1,11 @@
+using System.Runtime.InteropServices;
+
+namespace Microsoft.Iris.Render.Interop.Drawing;
+
+// Bit-for-bit mirror of Microsoft.Iris.Render.Point (UIX.RenderApi/Microsoft/Iris/Render/Point.cs).
+[StructLayout(LayoutKind.Sequential)]
+public struct Point
+{
+ public int x;
+ public int y;
+}
diff --git a/UIXrender/Interop/Drawing/Rectangle.cs b/UIXrender/Interop/Drawing/Rectangle.cs
new file mode 100644
index 0000000..8d6d158
--- /dev/null
+++ b/UIXrender/Interop/Drawing/Rectangle.cs
@@ -0,0 +1,24 @@
+using System.Runtime.InteropServices;
+
+namespace Microsoft.Iris.Render.Interop.Drawing;
+
+// Bit-for-bit mirror of Microsoft.Iris.Render.Rectangle (UIX.RenderApi/Microsoft/Iris/Render/Rectangle.cs).
+[StructLayout(LayoutKind.Sequential)]
+public struct Rectangle
+{
+ public int x;
+ public int y;
+ public int width;
+ public int height;
+}
+
+// Bit-for-bit mirror of Microsoft.Iris.RenderAPI.Drawing.RectangleF
+// (UIX/Microsoft/Iris/RenderAPI/Drawing/RectangleF.cs).
+[StructLayout(LayoutKind.Sequential)]
+public struct RectangleF
+{
+ public float x;
+ public float y;
+ public float width;
+ public float height;
+}
diff --git a/UIXrender/Interop/Drawing/Size.cs b/UIXrender/Interop/Drawing/Size.cs
new file mode 100644
index 0000000..bc46310
--- /dev/null
+++ b/UIXrender/Interop/Drawing/Size.cs
@@ -0,0 +1,17 @@
+using System.Runtime.InteropServices;
+
+namespace Microsoft.Iris.Render.Interop.Drawing;
+
+// Bit-for-bit mirror of Microsoft.Iris.Render.Size (UIX.RenderApi/Microsoft/Iris/Render/Size.cs).
+[StructLayout(LayoutKind.Sequential)]
+public struct Size
+{
+ public int width;
+ public int height;
+
+ public Size(int width, int height)
+ {
+ this.width = width;
+ this.height = height;
+ }
+}
diff --git a/UIXrender/Interop/Drawing/SizeF.cs b/UIXrender/Interop/Drawing/SizeF.cs
new file mode 100644
index 0000000..6e9caba
--- /dev/null
+++ b/UIXrender/Interop/Drawing/SizeF.cs
@@ -0,0 +1,11 @@
+using System.Runtime.InteropServices;
+
+namespace Microsoft.Iris.Render.Interop.Drawing;
+
+// Bit-for-bit mirror of Microsoft.Iris.RenderAPI.Drawing.SizeF (UIX/Microsoft/Iris/RenderAPI/Drawing/SizeF.cs).
+[StructLayout(LayoutKind.Sequential)]
+public struct SizeF
+{
+ public float width;
+ public float height;
+}
diff --git a/UIXrender/Interop/EngineApi.cs b/UIXrender/Interop/EngineApi.cs
index 7d8678d..64dda1f 100644
--- a/UIXrender/Interop/EngineApi.cs
+++ b/UIXrender/Interop/EngineApi.cs
@@ -1,13 +1,19 @@
using System;
using System.Runtime.InteropServices;
+using Microsoft.Iris.Interop;
using Microsoft.Iris.Render.Engine;
+using Microsoft.Iris.Render.Interop.Protocol;
+using Microsoft.Iris.Render.Interop.Win32;
namespace Microsoft.Iris.Render.Interop;
// [UnmanagedCallersOnly] exports matching the core transport DllImport("UIXRender.dll")
-// declarations in UIX.RenderApi/Microsoft/Iris/Render/Protocol/EngineApi.cs. See
-// logs/UIXrender/EngineCore.md for the reasoning behind SpInit/SpUninit being stubs and
-// SpWrapBufferProc being a pass-through rather than a real wrapper.
+// declarations in UIX.RenderApi/Microsoft/Iris/Render/Protocol/EngineApi.cs. This is
+// the *only* place in UIXrender that deals with raw pointers/unmanaged function
+// pointers -- everything it calls into (Microsoft.Iris.Render.Engine.EngineService) is
+// ordinary, pointer-free managed C#. See logs/UIXrender/EngineCore.md for why
+// SpInit/SpUninit are stubs and SpWrapBufferProc is a pass-through rather than a real
+// wrapper, and logs/UIXrender/FullSurface.md for the rest of this file (added later).
public static unsafe class EngineApi
{
[UnmanagedCallersOnly(EntryPoint = "SpInit")]
@@ -32,9 +38,34 @@ public static unsafe class EngineApi
if (argsRender == null || pThread == null)
return HRESULT.E_INVALIDARG;
- var thread = RenderThread.Start(*argsRender);
- var handle = GCHandle.Alloc(thread, GCHandleType.Normal);
- *pThread = GCHandle.ToIntPtr(handle);
+ ContextID contextId = argsRender->idContext;
+ IntPtr nativeCallback = argsRender->pfnProcessBuffer;
+ IntPtr callbackData = argsRender->pvProcessData;
+
+ // Adapts the raw native function pointer into the idiomatic BufferReceivedHandler
+ // shape EngineService deals in -- all pointer reconstruction for this direction
+ // lives here, not in Engine/.
+ BufferReceivedHandler handler = (source, bufferHandle, flags, data) =>
+ {
+ if (nativeCallback == IntPtr.Zero)
+ return;
+ fixed (byte* pData = data)
+ {
+ var info = new BufferInfo
+ {
+ idContextSrc = source,
+ idContextDest = contextId,
+ idBuffer = bufferHandle,
+ nFlags = flags,
+ cbSizeBuffer = (uint)data.Length,
+ };
+ var fn = (delegate* unmanaged)nativeCallback;
+ fn(callbackData, contextId.value, &info, pData);
+ }
+ };
+
+ IRenderThreadHandle thread = EngineService.StartRenderThread(contextId, handler);
+ *pThread = GCHandle.ToIntPtr(GCHandle.Alloc(thread, GCHandleType.Normal));
return HRESULT.S_OK;
}
@@ -44,9 +75,8 @@ public static unsafe class EngineApi
if (pThread == IntPtr.Zero)
return HRESULT.E_INVALIDARG;
- var handle = GCHandle.FromIntPtr(pThread);
- if (handle.Target is RenderThread thread)
- thread.Stop();
+ GCHandle handle = GCHandle.FromIntPtr(pThread);
+ (handle.Target as IDisposable)?.Dispose();
handle.Free();
return HRESULT.S_OK;
}
@@ -57,11 +87,131 @@ public static unsafe class EngineApi
if (phdrData == null)
return HRESULT.E_INVALIDARG;
- if (!ContextRegistry.TryGet(phdrData->idContextDest, out ContextRegistry.Entry entry) || entry.Callback == IntPtr.Zero)
- return HRESULT.E_FAIL;
+ var span = new ReadOnlySpan(pvData, (int)phdrData->cbSizeBuffer);
+ return EngineService.SendBuffer(phdrData->idContextSrc, phdrData->idContextDest, phdrData->idBuffer, phdrData->nFlags, span);
+ }
- var fn = (delegate* unmanaged)entry.Callback;
- fn(entry.CallbackData, phdrData->idContextDest.value, phdrData, pvData);
+ // Real, but a documented no-op: this reimplementation has no Win32 message queue to
+ // pump (no window is created anywhere in this repo yet), and LocalChannel.Connect()
+ // -- the only path real Zune uses -- never calls this (see logs/UIXrender/EngineCore.md's
+ // SpInit/SpUninit note for the same "IGMM_STANDARD messaging model" open question).
+ // Always reports "no message available" rather than guessing at pump semantics.
+ [UnmanagedCallersOnly(EntryPoint = "SpPeekMessage")]
+ public static HRESULT SpPeekMessage(MSG* msg, HWND hwnd, uint nMsgFilterMin, uint nMsgFilterMax, uint wRemoveMsg, uint* nResult)
+ {
+ if (msg == null || nResult == null)
+ return HRESULT.E_INVALIDARG;
+ *msg = default;
+ *nResult = EngineService.PeekMessage(nMsgFilterMin, nMsgFilterMax, wRemoveMsg);
return HRESULT.S_OK;
}
+
+ [UnmanagedCallersOnly(EntryPoint = "SpWaitMessage")]
+ public static HRESULT SpWaitMessage(uint nTimeOutMs, IntPtr unused)
+ {
+ EngineService.WaitMessage(nTimeOutMs);
+ return HRESULT.S_OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpInvoke")]
+ public static HRESULT SpInvoke(ContextID idContext, IntPtr pfnInvoke, IntPtr pvArgs, int synchronous) =>
+ EngineService.Invoke(idContext, pfnInvoke, pvArgs, synchronous != 0);
+
+ [UnmanagedCallersOnly(EntryPoint = "SpRemoteCreateServerStreams")]
+ public static HRESULT SpRemoteCreateServerStreams(char* stSession, TransportProtocol nProtocol, IntPtr* pSendStream, IntPtr* pReceiveStream)
+ {
+ if (pSendStream == null || pReceiveStream == null)
+ return HRESULT.E_INVALIDARG;
+
+ HRESULT hr = EngineService.RemoteCreateServerStreams(NativeString.UniToString(stSession), nProtocol, out IntPtr send, out IntPtr receive);
+ *pSendStream = send;
+ *pReceiveStream = receive;
+ return hr;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpRemoteWaitServerStreamsConnected")]
+ public static HRESULT SpRemoteWaitServerStreamsConnected(TransportProtocol nProtocol, IntPtr pSendStream, IntPtr pReceiveStream) =>
+ EngineService.RemoteWaitServerStreamsConnected(nProtocol, pSendStream);
+
+ [UnmanagedCallersOnly(EntryPoint = "SpRemoteServerInit")]
+ public static HRESULT SpRemoteServerInit(IntPtr pSendStream, IntPtr pReceiveStream, InitArgs argsSend, IntPtr* pSession)
+ {
+ if (pSession == null)
+ return HRESULT.E_INVALIDARG;
+
+ // Adapt the raw native process-buffer function pointer into a BufferReceivedHandler,
+ // exactly as SpRenderThreadInit does -- RemoteServerConnection deals only in the
+ // pointer-free handler shape.
+ ContextID contextId = argsSend.idContext;
+ IntPtr nativeCallback = argsSend.pfnProcessBuffer;
+ IntPtr callbackData = argsSend.pvProcessData;
+ BufferReceivedHandler handler = nativeCallback == IntPtr.Zero
+ ? null
+ : (source, bufferHandle, flags, data) =>
+ {
+ fixed (byte* pData = data)
+ {
+ var info = new BufferInfo
+ {
+ idContextSrc = source,
+ idContextDest = contextId,
+ idBuffer = bufferHandle,
+ nFlags = flags,
+ cbSizeBuffer = (uint)data.Length,
+ };
+ var fn = (delegate* unmanaged)nativeCallback;
+ fn(callbackData, source.value, &info, pData);
+ }
+ };
+
+ HRESULT hr = EngineService.RemoteServerInit(pSendStream, contextId, handler, out IntPtr session);
+ *pSession = session;
+ return hr;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpRemoteServerUninit")]
+ public static HRESULT SpRemoteServerUninit(IntPtr pSession, int fForceShutdown, ShutdownReason* nShutdownReason)
+ {
+ if (nShutdownReason == null)
+ return HRESULT.E_INVALIDARG;
+
+ HRESULT hr = EngineService.RemoteServerUninit(pSession, fForceShutdown != 0, out ShutdownReason reason);
+ *nShutdownReason = reason;
+ return hr;
+ }
+
+ // Deliberately not implemented: HLSL effect compilation exists only as a
+ // graphics-API-specific service (d3dcompiler_47.dll / Silk.NET.Direct3D.Compilers),
+ // and Silk.NET has no cross-platform abstraction over it -- so per this project's
+ // "abstractions, not specific graphics APIs" dependency policy there is nothing
+ // correct to call here yet. Reports failure honestly (with empty out-params, so a
+ // caller that ignores the HRESULT still sees a well-defined "no blob") rather than
+ // pretending success. See logs/UIXrender/FullSurface.md, decision 1.
+ // TODO: revisit once this project has a rendering backend of its own to compile for.
+ [UnmanagedCallersOnly(EntryPoint = "SpDx9CompileEffect")]
+ public static HRESULT SpDx9CompileEffect(byte* stEffect, byte* stDefines, IntPtr* pErrorString, IntPtr* pErrorBuffer, IntPtr* pEffectBlob, uint* effectBlobSize, IntPtr* pEffectBlobBuffer)
+ {
+ // Empty out-params so a caller ignoring the HRESULT still sees a well-defined
+ // "no blob"; the not-implemented decision itself lives in EngineService.
+ if (pErrorString != null) *pErrorString = IntPtr.Zero;
+ if (pErrorBuffer != null) *pErrorBuffer = IntPtr.Zero;
+ if (pEffectBlob != null) *pEffectBlob = IntPtr.Zero;
+ if (effectBlobSize != null) *effectBlobSize = 0;
+ if (pEffectBlobBuffer != null) *pEffectBlobBuffer = IntPtr.Zero;
+ return EngineService.Dx9CompileEffect();
+ }
+
+ // Generic COM release: calls IUnknown::Release through the object's own vtable
+ // (slot 2, after QueryInterface/AddRef) rather than assuming anything about what
+ // concrete type pUnknown is -- this export exists precisely to release arbitrary
+ // IUnknown-shaped pointers handed across the boundary.
+ [UnmanagedCallersOnly(EntryPoint = "SpObjectRelease")]
+ public static void SpObjectRelease(IntPtr pUnknown)
+ {
+ if (pUnknown == IntPtr.Zero)
+ return;
+ void* vtbl = *(void**)pUnknown;
+ var release = (delegate* unmanaged)(*((void**)vtbl + 2));
+ release(pUnknown);
+ }
}
diff --git a/UIXrender/Interop/Extensions/ImageTypes.cs b/UIXrender/Interop/Extensions/ImageTypes.cs
new file mode 100644
index 0000000..9c4ede9
--- /dev/null
+++ b/UIXrender/Interop/Extensions/ImageTypes.cs
@@ -0,0 +1,84 @@
+using System;
+using System.Runtime.InteropServices;
+using Microsoft.Iris.Render.Interop.Drawing;
+
+namespace Microsoft.Iris.Render.Interop.Extensions;
+
+// Bit-for-bit mirrors of the ExtensionsApi.cs (UIX.RenderApi/Microsoft/Iris/Render/Extensions/*.cs)
+// asset-loading types: ImageRequirements, ImageHeader, ImageData, ImageInformation,
+// HSpBitmap, SurfaceFormat.
+
+public enum SurfaceFormat : uint
+{
+ None = 0,
+ Bpp8 = 0x00080000,
+ A8 = 0x00088000,
+ Bpp16 = 0x00100000,
+ RGB16_555 = 0x00100555,
+ RGB16_565 = 0x00100565,
+ ARGB16_1555 = 0x00101555,
+ Bpp24 = 0x00180000,
+ RGB24 = 0x00180888,
+ Bpp32 = 0x00200000,
+ RGB32 = 0x00200888,
+ ARGB32 = 0x00208888,
+ YUY2 = 0x21100000,
+ External = 0x80000000,
+}
+
+[Flags]
+public enum ImageRequirementsFields
+{
+ None = 0,
+ MaximumSize = 1,
+ Border = 2,
+ Flippable = 4,
+ AntialiasEdges = 16,
+}
+
+[StructLayout(LayoutKind.Sequential)]
+public struct ImageRequirements
+{
+ public ImageRequirementsFields mask;
+ public Size maximumSizePxl;
+ public int borderPxl;
+ public ColorF borderColor;
+}
+
+[StructLayout(LayoutKind.Sequential)]
+public struct ImageHeader
+{
+ public Size sizeActualPxl;
+ public Size sizeOriginalPxl;
+ public int stride;
+ public SurfaceFormat format;
+}
+
+[StructLayout(LayoutKind.Sequential)]
+public struct ImageData
+{
+ public IntPtr rgData;
+}
+
+[StructLayout(LayoutKind.Sequential)]
+public struct ImageInformation
+{
+ public ImageHeader header;
+ public ImageData data;
+}
+
+[Flags]
+public enum BitmapOptions
+{
+ None = 0,
+ Decode = 1,
+ Flip = 2,
+ Valid = Flip | Decode,
+}
+
+[StructLayout(LayoutKind.Sequential)]
+public struct HSpBitmap
+{
+ public IntPtr h;
+ public static readonly HSpBitmap NULL = new();
+}
diff --git a/UIXrender/Interop/Extensions/SoundTypes.cs b/UIXrender/Interop/Extensions/SoundTypes.cs
new file mode 100644
index 0000000..c5740aa
--- /dev/null
+++ b/UIXrender/Interop/Extensions/SoundTypes.cs
@@ -0,0 +1,47 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Microsoft.Iris.Render.Interop.Extensions;
+
+// Bit-for-bit mirrors of ExtensionsApi.cs's sound types.
+[Flags]
+public enum SoundOptions
+{
+ None = 0,
+ Decode = 1,
+ BigEndian = 2,
+ Valid = BigEndian | Decode,
+}
+
+[StructLayout(LayoutKind.Sequential)]
+public struct SoundHeader
+{
+ public ushort formatTag;
+ public ushort channels;
+ public uint samplesPerSec;
+ public uint avgBytesPerSec;
+ public ushort blockAlign;
+ public ushort bitsPerSample;
+ public ushort cbExtraData;
+ public uint cbDataSize;
+}
+
+[StructLayout(LayoutKind.Sequential)]
+public struct SoundData
+{
+ public IntPtr rgData;
+}
+
+[StructLayout(LayoutKind.Sequential)]
+public struct SoundInformation
+{
+ public SoundHeader header;
+ public SoundData data;
+}
+
+[StructLayout(LayoutKind.Sequential)]
+public struct HSpSound
+{
+ public IntPtr h;
+ public static readonly HSpSound NULL = new();
+}
diff --git a/UIXrender/Interop/Protocol/RemoteChannelTypes.cs b/UIXrender/Interop/Protocol/RemoteChannelTypes.cs
new file mode 100644
index 0000000..626a1f1
--- /dev/null
+++ b/UIXrender/Interop/Protocol/RemoteChannelTypes.cs
@@ -0,0 +1,23 @@
+namespace Microsoft.Iris.Render.Interop.Protocol;
+
+// Bit-for-bit mirrors of UIX.RenderApi/Microsoft/Iris/Render/Protocol/{ShutdownReason,TransportProtocol}.cs.
+public enum ShutdownReason
+{
+ NoReason,
+ SelfShutdown,
+ PeerShutdown,
+ TransportClosed,
+ TransportFailure,
+ GenericFailure,
+}
+
+public enum TransportProtocol
+{
+ None = 0,
+ Min = 1,
+ VC = 1,
+ TCP = 2,
+ UDP = 3,
+ Max = 4,
+ PIPE = 4,
+}
diff --git a/UIXrender/Interop/Text/RasterizeRunPacket.cs b/UIXrender/Interop/Text/RasterizeRunPacket.cs
new file mode 100644
index 0000000..e1ffb8f
--- /dev/null
+++ b/UIXrender/Interop/Text/RasterizeRunPacket.cs
@@ -0,0 +1,42 @@
+using System.Runtime.InteropServices;
+using Microsoft.Iris.Render.Interop.Drawing;
+using Microsoft.Iris.Render.Interop.Win32;
+
+namespace Microsoft.Iris.Render.Interop.Text;
+
+// Bit-for-bit mirror of NativeApi.RasterizeRunPacket/UnderlineStyle
+// (UIX/Microsoft/Iris/OS/NativeApi.cs).
+public enum UnderlineStyle
+{
+ None,
+ Solid,
+ Thick,
+ Dotted,
+ Dash,
+ DashDot,
+ DashDotDot,
+}
+
+[StructLayout(LayoutKind.Sequential)]
+public struct RasterizeRunPacket
+{
+ public Rectangle rcLayoutBounds;
+ public RectangleF rcfRenderBounds;
+ public int naturalX;
+ public int naturalY;
+ public int rasterizeX;
+ public int rasterizeY;
+ public byte aaConfig;
+ public Color clrText;
+ public Color clrBackground;
+ public int fontFaceUniqueId;
+ public LOGFONTW lf;
+ public Size sizeRasterizeRun;
+ public Size sizeNatural;
+ public int ascenderInset;
+ public int baselineInset;
+ public int lineNumber;
+ public int effects;
+ public UnderlineStyle underlineStyle;
+ public Rectangle rcUnderlineBounds;
+}
diff --git a/UIXrender/Interop/Text/TextMeasureParamsData.cs b/UIXrender/Interop/Text/TextMeasureParamsData.cs
new file mode 100644
index 0000000..0536f57
--- /dev/null
+++ b/UIXrender/Interop/Text/TextMeasureParamsData.cs
@@ -0,0 +1,44 @@
+using System.Runtime.InteropServices;
+using Microsoft.Iris.Render.Interop.Drawing;
+
+namespace Microsoft.Iris.Render.Interop.Text;
+
+// Bit-for-bit mirror of Microsoft.Iris.Drawing.TextMeasureParams.MarshalledData/FormattedRange
+// (UIX/Microsoft/Iris/Drawing/TextMeasureParams.cs).
+[System.Flags]
+public enum TextMeasureFlags : byte
+{
+ None = 0,
+ Content = 1,
+ IsRtl = 2,
+ WordWrap = 4,
+ WordWrapValue = 8,
+ PasswordMasked = 16,
+ TrimLeftSideBearing = 32,
+ FormatOnly = 64,
+}
+
+[StructLayout(LayoutKind.Sequential)]
+public struct FormattedRange
+{
+ public int firstCharacter;
+ public int lastCharacter;
+ public Color color;
+ public int styleIndex;
+}
+
+[StructLayout(LayoutKind.Sequential)]
+public unsafe struct TextMeasureParamsData
+{
+ public TextMeasureFlags flags;
+ public byte alignment;
+ public char passwordChar;
+ public char* content;
+ public float scale;
+ public SizeF constraint;
+ public TextStyleData* pTextStyle;
+ public int formattedRangeCount;
+ public FormattedRange* pFormattedRanges;
+ public int formattedRangeStylesCount;
+ public TextStyleData* pFormattedRangeStyles;
+}
diff --git a/UIXrender/Interop/Text/TextStyleData.cs b/UIXrender/Interop/Text/TextStyleData.cs
new file mode 100644
index 0000000..1593e7b
--- /dev/null
+++ b/UIXrender/Interop/Text/TextStyleData.cs
@@ -0,0 +1,38 @@
+using System.Runtime.InteropServices;
+using Microsoft.Iris.Render.Interop.Drawing;
+
+namespace Microsoft.Iris.Render.Interop.Text;
+
+// Bit-for-bit mirror of Microsoft.Iris.Drawing.TextStyle.MarshalledData
+// (UIX/Microsoft/Iris/Drawing/TextStyle.cs).
+[System.Flags]
+public enum TextStyleSetFlags
+{
+ None = 0,
+ FontFace = 1,
+ FontHeight = 2,
+ Bold = 4,
+ Italic = 8,
+ Underline = 16,
+ LineSpacing = 32,
+ TextColor = 64,
+ EnableKerning = 128,
+ CharacterSpacing = 256,
+ AltFontHeight = 512,
+ BoldValue = 65536,
+ ItalicValue = 131072,
+ UnderlineValue = 262144,
+ EnableKerningValue = 524288,
+}
+
+[StructLayout(LayoutKind.Sequential)]
+public unsafe struct TextStyleData
+{
+ public TextStyleSetFlags flags;
+ public char* fontFace;
+ public float fontHeightPts;
+ public float altFontHeightPts;
+ public float lineSpacing;
+ public float characterSpacing;
+ public Color textColor;
+}
diff --git a/UIXrender/Interop/UIXVariant.cs b/UIXrender/Interop/UIXVariant.cs
new file mode 100644
index 0000000..eff5b1e
--- /dev/null
+++ b/UIXrender/Interop/UIXVariant.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Microsoft.Iris.Render.Interop;
+
+// Bit-for-bit mirror of Microsoft.Iris.CodeModel.Cpp.UIXVariant
+// (UIX/Microsoft/Iris/CodeModel/Cpp/UIXVariant.cs) -- a tagged union: a 4-byte type tag
+// followed by an 8-byte payload reinterpreted depending on the tag. Managed-side helpers
+// (GetValue/SetXxxValue) are reimplemented here as real logic rather than mirrored
+// method-for-method, since this side never needs to talk to the CLR's own marshaller --
+// it just needs the same wire layout and equivalent behavior.
+public enum VariantType
+{
+ Empty = 0,
+ Bool = 1,
+ Byte = 2,
+ Int32 = 3,
+ Int64 = 4,
+ Single = 5,
+ Double = 6,
+ Enum = 7,
+ UIXObject = 128,
+ UIXString = 129,
+ UIXImage = 130,
+ UIXDataQuery = 131,
+ UIXDataType = 132,
+}
+
+[StructLayout(LayoutKind.Sequential)]
+public struct EnumValue
+{
+ public int value;
+ public uint type;
+}
+
+[StructLayout(LayoutKind.Sequential)]
+public unsafe struct UIXVariant
+{
+ public VariantType type;
+ public long union;
+
+ public static UIXVariant Empty => new() { type = VariantType.Empty };
+
+ public IntPtr AsPointer
+ {
+ get => (IntPtr)union;
+ set => union = (long)value;
+ }
+
+ public bool AsBool
+ {
+ get => union != 0;
+ set => union = value ? 1 : 0;
+ }
+
+ public byte AsByte
+ {
+ get => (byte)union;
+ set => union = value;
+ }
+
+ public int AsInt32
+ {
+ get => (int)union;
+ set => union = value;
+ }
+
+ public long AsInt64
+ {
+ get => union;
+ set => union = value;
+ }
+
+ public float AsSingle
+ {
+ get { long u = union; return *(float*)&u; }
+ set { float f = value; union = *(int*)&f; }
+ }
+
+ public double AsDouble
+ {
+ get { long u = union; return *(double*)&u; }
+ set => union = BitConverter.DoubleToInt64Bits(value);
+ }
+
+ public EnumValue AsEnum
+ {
+ get { long u = union; return *(EnumValue*)&u; }
+ set { long u = 0; *(EnumValue*)&u = value; union = u; }
+ }
+
+ public static UIXVariant FromObject(object value) => value switch
+ {
+ null => Empty,
+ bool b => new UIXVariant { type = VariantType.Bool, AsBool = b },
+ byte b => new UIXVariant { type = VariantType.Byte, AsByte = b },
+ int i => new UIXVariant { type = VariantType.Int32, AsInt32 = i },
+ long l => new UIXVariant { type = VariantType.Int64, AsInt64 = l },
+ float f => new UIXVariant { type = VariantType.Single, AsSingle = f },
+ double d => new UIXVariant { type = VariantType.Double, AsDouble = d },
+ _ => throw new NotSupportedException($"Cannot convert {value.GetType()} to UIXVariant"),
+ };
+
+ public object ToObject() => type switch
+ {
+ VariantType.Empty => null,
+ VariantType.Bool => AsBool,
+ VariantType.Byte => AsByte,
+ VariantType.Int32 => AsInt32,
+ VariantType.Int64 => AsInt64,
+ VariantType.Single => AsSingle,
+ VariantType.Double => AsDouble,
+ _ => throw new NotSupportedException($"Cannot convert UIXVariant of type {type} to object"),
+ };
+}
diff --git a/UIXrender/Interop/XmlLite/NativeXmlNodeType.cs b/UIXrender/Interop/XmlLite/NativeXmlNodeType.cs
new file mode 100644
index 0000000..5f3b856
--- /dev/null
+++ b/UIXrender/Interop/XmlLite/NativeXmlNodeType.cs
@@ -0,0 +1,17 @@
+namespace Microsoft.Iris.Render.Interop.XmlLite;
+
+// Bit-for-bit mirror of Microsoft.Iris.OS.NativeXmlNodeType (UIX/Microsoft/Iris/OS/NativeXmlNodeType.cs).
+public enum NativeXmlNodeType
+{
+ None = 0,
+ Element = 1,
+ Attribute = 2,
+ Text = 3,
+ CDATA = 4,
+ ProcessingInstruction = 7,
+ Comment = 8,
+ DocumentType = 10,
+ Whitespace = 13,
+ EndElement = 15,
+ XmlDeclaration = 17,
+}
diff --git a/UIXrender/Subsystems/Assets/BitmapStore.cs b/UIXrender/Subsystems/Assets/BitmapStore.cs
new file mode 100644
index 0000000..160ea01
--- /dev/null
+++ b/UIXrender/Subsystems/Assets/BitmapStore.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Runtime.InteropServices;
+using Microsoft.Iris.Render.Interop.Drawing;
+using Microsoft.Iris.Render.Interop.Extensions;
+using StbImageSharp;
+
+namespace Microsoft.Iris.Render.Subsystems.Assets;
+
+// Backing store for the SpBitmap* family (UIX.RenderApi/.../Extensions/ExtensionsApi.cs).
+//
+// Decode is real and cross-platform: StbImageSharp is pure managed (PNG/JPEG/BMP/TGA/GIF/
+// PSD), so this works identically on Windows and Linux and stays NativeAOT-safe, unlike
+// System.Drawing/GDI+. See logs/UIXrender/FullSurface.md, decision 3.
+//
+// Pixels always land as ARGB32 in a single unmanaged allocation the caller can read
+// through ImageInformation.Data.rgData until SpBitmapDelete. Stb decodes to RGBA byte
+// order; the Iris SurfaceFormat.ARGB32 the managed side expects is BGRA in memory on a
+// little-endian machine (0xAARRGGBB as a uint), so the channel swap below is required,
+// not incidental.
+internal sealed class LoadedBitmap : IDisposable
+{
+ private IntPtr _pixels;
+
+ private LoadedBitmap(IntPtr pixels, ImageHeader header)
+ {
+ _pixels = pixels;
+ Header = header;
+ }
+
+ public ImageHeader Header { get; }
+
+ public ImageInformation ToInformation() => new()
+ {
+ header = Header,
+ data = new ImageData { rgData = _pixels },
+ };
+
+ public static LoadedBitmap FromDecoded(ImageResult image, in ImageRequirements requirements)
+ {
+ int width = image.Width;
+ int height = image.Height;
+ int stride = width * 4;
+
+ IntPtr buffer = Marshal.AllocHGlobal(stride * height);
+ unsafe
+ {
+ var dest = (byte*)buffer;
+ byte[] src = image.Data;
+ for (int i = 0; i < width * height; i++)
+ {
+ // RGBA (stb) -> BGRA in memory == 0xAARRGGBB as a little-endian uint.
+ dest[i * 4 + 0] = src[i * 4 + 2];
+ dest[i * 4 + 1] = src[i * 4 + 1];
+ dest[i * 4 + 2] = src[i * 4 + 0];
+ dest[i * 4 + 3] = src[i * 4 + 3];
+ }
+ }
+
+ var header = new ImageHeader
+ {
+ sizeActualPxl = new Size(width, height),
+ sizeOriginalPxl = new Size(width, height),
+ stride = stride,
+ format = SurfaceFormat.ARGB32,
+ };
+
+ return new LoadedBitmap(buffer, header);
+ }
+
+ public static unsafe LoadedBitmap FromRaw(Size sizeActualPxl, int stride, SurfaceFormat format, IntPtr source)
+ {
+ int byteCount = stride * sizeActualPxl.height;
+ IntPtr buffer = Marshal.AllocHGlobal(byteCount);
+ Buffer.MemoryCopy((void*)source, (void*)buffer, byteCount, byteCount);
+
+ var header = new ImageHeader
+ {
+ sizeActualPxl = sizeActualPxl,
+ sizeOriginalPxl = sizeActualPxl,
+ stride = stride,
+ format = format,
+ };
+
+ return new LoadedBitmap(buffer, header);
+ }
+
+ public void Dispose()
+ {
+ if (_pixels != IntPtr.Zero)
+ {
+ Marshal.FreeHGlobal(_pixels);
+ _pixels = IntPtr.Zero;
+ }
+ }
+}
diff --git a/UIXrender/Subsystems/Assets/ExtensionsApi.cs b/UIXrender/Subsystems/Assets/ExtensionsApi.cs
new file mode 100644
index 0000000..3110649
--- /dev/null
+++ b/UIXrender/Subsystems/Assets/ExtensionsApi.cs
@@ -0,0 +1,150 @@
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+using Microsoft.Iris.Interop;
+using Microsoft.Iris.Render.Engine;
+using Microsoft.Iris.Render.Interop;
+using Microsoft.Iris.Render.Interop.Drawing;
+using Microsoft.Iris.Render.Interop.Extensions;
+using StbImageSharp;
+
+namespace Microsoft.Iris.Render.Subsystems.Assets;
+
+// [UnmanagedCallersOnly] exports matching the asset-loading DllImports in
+// UIX.RenderApi/Microsoft/Iris/Render/Extensions/ExtensionsApi.cs.
+//
+// Note ImageRequirements is a *class* with [StructLayout(Sequential)] on the managed side
+// and is passed [MarshalAs(UnmanagedType.LPStruct)], i.e. the callee receives a pointer
+// to its fields -- hence ImageRequirements* here, not a by-value struct.
+public static unsafe class ExtensionsApi
+{
+ [UnmanagedCallersOnly(EntryPoint = "SpBitmapLoadFile")]
+ public static HRESULT SpBitmapLoadFile(char* stFileName, ImageRequirements* req, BitmapOptions nOptions, HSpBitmap* hBmp, ImageInformation* info)
+ {
+ if (hBmp == null || info == null)
+ return HRESULT.E_INVALIDARG;
+
+ string path = NativeString.UniToString(stFileName);
+ if (string.IsNullOrEmpty(path) || !File.Exists(path))
+ return HRESULT.E_FAIL;
+
+ try
+ {
+ using FileStream stream = File.OpenRead(path);
+ return CompleteDecode(ImageResult.FromStream(stream, ColorComponents.RedGreenBlueAlpha), req, hBmp, info);
+ }
+ catch (Exception e) when (e is IOException or ArgumentException)
+ {
+ return HRESULT.E_FAIL;
+ }
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpBitmapLoadBuffer")]
+ public static HRESULT SpBitmapLoadBuffer(IntPtr pvSrc, uint cbSize, ImageRequirements* req, BitmapOptions nOptions, HSpBitmap* hBmp, ImageInformation* info)
+ {
+ if (hBmp == null || info == null || pvSrc == IntPtr.Zero)
+ return HRESULT.E_INVALIDARG;
+
+ try
+ {
+ using var stream = new UnmanagedMemoryStream((byte*)pvSrc, cbSize);
+ return CompleteDecode(ImageResult.FromStream(stream, ColorComponents.RedGreenBlueAlpha), req, hBmp, info);
+ }
+ catch (Exception e) when (e is IOException or ArgumentException)
+ {
+ return HRESULT.E_FAIL;
+ }
+ }
+
+ // Raw pixels need no decoder -- this path just takes ownership of a copy, so it is
+ // fully real on every platform.
+ [UnmanagedCallersOnly(EntryPoint = "SpBitmapLoadRaw")]
+ public static HRESULT SpBitmapLoadRaw(Size sizeActualPxl, int nStride, SurfaceFormat nFormat, IntPtr pvData, ImageRequirements* req, BitmapOptions nOptions, HSpBitmap* hBmp, ImageInformation* info)
+ {
+ if (hBmp == null || info == null || pvData == IntPtr.Zero)
+ return HRESULT.E_INVALIDARG;
+
+ var bitmap = LoadedBitmap.FromRaw(sizeActualPxl, nStride, nFormat, pvData);
+ hBmp->h = HandleTable.Alloc(bitmap);
+ *info = bitmap.ToInformation();
+ return HRESULT.S_OK;
+ }
+
+ // Win32 resource sections (HINSTANCE + resource name) are a PE/Windows loader concept
+ // with no cross-platform equivalent, and this project has no resource-section reader
+ // of its own yet. Reports failure rather than inventing a lookup.
+ // TODO: implement once SpLoadBinaryResource's resource store (Subsystems/Modules)
+ // gains real PE resource parsing -- this should then read through that, not duplicate it.
+ [UnmanagedCallersOnly(EntryPoint = "SpBitmapLoadResource")]
+ public static HRESULT SpBitmapLoadResource(IntPtr hinst, char* stName, int nType, ImageRequirements* req, BitmapOptions nOptions, HSpBitmap* hBmp, ImageInformation* info)
+ {
+ if (hBmp == null || info == null)
+ return HRESULT.E_INVALIDARG;
+
+ *hBmp = HSpBitmap.NULL;
+ *info = default;
+ return HRESULT.E_NOTIMPL;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpBitmapDelete")]
+ public static HRESULT SpBitmapDelete(HSpBitmap hBmp)
+ {
+ HandleTable.Free(hBmp.h);
+ return HRESULT.S_OK;
+ }
+
+ // Real: parses a RIFF/WAVE container and hands back the PCM payload. No codec is
+ // needed because the managed side's only declared format constant is
+ // WAVE_FORMAT_PCM (ExtensionsApi.cs), i.e. uncompressed samples.
+ [UnmanagedCallersOnly(EntryPoint = "SpSoundLoadBuffer")]
+ public static HRESULT SpSoundLoadBuffer(IntPtr pBuffer, int dwSize, SoundOptions options, HSpSound* hSound, SoundInformation* info)
+ {
+ if (hSound == null || info == null || pBuffer == IntPtr.Zero || dwSize <= 0)
+ return HRESULT.E_INVALIDARG;
+
+ var source = new ReadOnlySpan((void*)pBuffer, dwSize);
+ if (!WaveParser.TryParse(source, out SoundHeader header, out byte[] samples))
+ return HRESULT.E_FAIL;
+
+ IntPtr payload = Marshal.AllocHGlobal(samples.Length);
+ Marshal.Copy(samples, 0, payload, samples.Length);
+
+ var sound = new LoadedSound(payload);
+ hSound->h = HandleTable.Alloc(sound);
+ *info = new SoundInformation { header = header, data = new SoundData { rgData = payload } };
+ return HRESULT.S_OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpSoundDispose")]
+ public static HRESULT SpSoundDispose(HSpSound hSound, SoundInformation info)
+ {
+ HandleTable.Free(hSound.h);
+ return HRESULT.S_OK;
+ }
+
+ private static HRESULT CompleteDecode(ImageResult image, ImageRequirements* req, HSpBitmap* hBmp, ImageInformation* info)
+ {
+ if (image == null)
+ return HRESULT.E_FAIL;
+
+ ImageRequirements requirements = req != null ? *req : default;
+ var bitmap = LoadedBitmap.FromDecoded(image, requirements);
+ hBmp->h = HandleTable.Alloc(bitmap);
+ *info = bitmap.ToInformation();
+ return HRESULT.S_OK;
+ }
+}
+
+internal sealed class LoadedSound(IntPtr samples) : IDisposable
+{
+ private IntPtr _samples = samples;
+
+ public void Dispose()
+ {
+ if (_samples != IntPtr.Zero)
+ {
+ Marshal.FreeHGlobal(_samples);
+ _samples = IntPtr.Zero;
+ }
+ }
+}
diff --git a/UIXrender/Subsystems/Assets/WaveParser.cs b/UIXrender/Subsystems/Assets/WaveParser.cs
new file mode 100644
index 0000000..23bd9ee
--- /dev/null
+++ b/UIXrender/Subsystems/Assets/WaveParser.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Buffers.Binary;
+using Microsoft.Iris.Render.Interop.Extensions;
+
+namespace Microsoft.Iris.Render.Subsystems.Assets;
+
+// Real RIFF/WAVE container parser for SpSoundLoadBuffer. Deliberately minimal and
+// allocation-light: it locates the "fmt " and "data" chunks, fills the SoundHeader the
+// managed side already declares (ExtensionsApi.SoundHeader -- field-for-field a
+// WAVEFORMATEX plus the data size), and returns the raw sample bytes.
+internal static class WaveParser
+{
+ private const ushort WAVE_FORMAT_PCM = 1;
+
+ public static bool TryParse(ReadOnlySpan source, out SoundHeader header, out byte[] samples)
+ {
+ header = default;
+ samples = Array.Empty();
+
+ // "RIFF" "WAVE" then a chunk list.
+ if (source.Length < 12 ||
+ !source[..4].SequenceEqual("RIFF"u8) ||
+ !source.Slice(8, 4).SequenceEqual("WAVE"u8))
+ return false;
+
+ bool haveFormat = false;
+ int offset = 12;
+
+ while (offset + 8 <= source.Length)
+ {
+ ReadOnlySpan chunkId = source.Slice(offset, 4);
+ uint chunkSize = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(offset + 4, 4));
+ int body = offset + 8;
+
+ if (body + chunkSize > source.Length)
+ chunkSize = (uint)(source.Length - body);
+
+ if (chunkId.SequenceEqual("fmt "u8) && chunkSize >= 16)
+ {
+ ReadOnlySpan fmt = source.Slice(body, (int)chunkSize);
+ header.formatTag = BinaryPrimitives.ReadUInt16LittleEndian(fmt[..2]);
+ header.channels = BinaryPrimitives.ReadUInt16LittleEndian(fmt.Slice(2, 2));
+ header.samplesPerSec = BinaryPrimitives.ReadUInt32LittleEndian(fmt.Slice(4, 4));
+ header.avgBytesPerSec = BinaryPrimitives.ReadUInt32LittleEndian(fmt.Slice(8, 4));
+ header.blockAlign = BinaryPrimitives.ReadUInt16LittleEndian(fmt.Slice(12, 2));
+ header.bitsPerSample = BinaryPrimitives.ReadUInt16LittleEndian(fmt.Slice(14, 2));
+ header.cbExtraData = chunkSize >= 18 ? BinaryPrimitives.ReadUInt16LittleEndian(fmt.Slice(16, 2)) : (ushort)0;
+ haveFormat = true;
+ }
+ else if (chunkId.SequenceEqual("data"u8))
+ {
+ samples = source.Slice(body, (int)chunkSize).ToArray();
+ header.cbDataSize = chunkSize;
+ }
+
+ // Chunks are word-aligned: an odd-sized chunk is followed by a pad byte.
+ offset = body + (int)chunkSize + ((chunkSize & 1) != 0 ? 1 : 0);
+ }
+
+ // Only uncompressed PCM is representable through this surface -- the managed side
+ // declares WAVE_FORMAT_PCM as its sole format constant, so anything else would be
+ // silently misinterpreted downstream rather than merely unsupported.
+ return haveFormat && samples.Length > 0 && header.formatTag == WAVE_FORMAT_PCM;
+ }
+}
diff --git a/UIXrender/Subsystems/Data/DataApi.cs b/UIXrender/Subsystems/Data/DataApi.cs
new file mode 100644
index 0000000..42e7c60
--- /dev/null
+++ b/UIXrender/Subsystems/Data/DataApi.cs
@@ -0,0 +1,199 @@
+using System;
+using System.Runtime.InteropServices;
+using Microsoft.Iris.Interop;
+using Microsoft.Iris.Render.Engine;
+using Microsoft.Iris.Render.Interop;
+
+namespace Microsoft.Iris.Render.Subsystems.Data;
+
+// [UnmanagedCallersOnly] exports for the SpData* family in UIX/Microsoft/Iris/OS/NativeApi.cs.
+public static unsafe class DataApi
+{
+ private static uint OK => (uint)HRESULT.S_OK.hr;
+ private static uint InvalidArg => unchecked((uint)HRESULT.E_INVALIDARG.hr);
+
+ // Mirrors NativeApi.NativeDataMappingEntry's marshaled layout: four LPWStr pointers
+ // interleaved with two 64-bit type handles, then a UIXVariant. Declared here rather
+ // than in Interop/ because nothing outside this subsystem reads it.
+ [StructLayout(LayoutKind.Sequential)]
+ public struct NativeDataMappingEntry
+ {
+ public IntPtr Source;
+ public IntPtr Target;
+ public IntPtr PropertyName;
+ public ulong PropertyTypeHandle;
+ public IntPtr PropertyTypeName;
+ public ulong UnderlyingCollectionTypeHandle;
+ public IntPtr UnderlyingCollectionTypeName;
+ public UIXVariant DefaultValue;
+ }
+
+ // ---- provider / factory ----------------------------------------------------------
+
+ [UnmanagedCallersOnly(EntryPoint = "SpDataProviderConstructQuery")]
+ public static uint SpDataProviderConstructQuery(IntPtr nativeFactory, char* providerName, ulong queryTypeHandle, ulong resultTypeHandle, ulong queryHandle, IntPtr* query)
+ {
+ if (query == null)
+ return InvalidArg;
+
+ var constructed = new DataQuery(NativeString.UniToString(providerName), queryTypeHandle, resultTypeHandle, queryHandle);
+ *query = HandleTable.Alloc(constructed);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpDataProviderReportDataMapping")]
+ public static uint SpDataProviderReportDataMapping(IntPtr nativeCallback, char* providerName, ulong typeHandle, uint entryCount, NativeDataMappingEntry* entries)
+ {
+ if (entries == null && entryCount > 0)
+ return InvalidArg;
+
+ // The callback pointer identifies the factory collecting these mappings; a caller
+ // that hasn't registered one yet still gets a well-defined success, matching the
+ // original's fire-and-forget "report" shape.
+ DataProviderFactory factory = HandleTable.Get(nativeCallback);
+ if (factory == null)
+ return OK;
+
+ string provider = NativeString.UniToString(providerName);
+ for (uint i = 0; i < entryCount; i++)
+ {
+ NativeDataMappingEntry entry = entries[i];
+ factory.ReportMapping(new DataMapping
+ {
+ ProviderName = provider,
+ Source = Marshal.PtrToStringUni(entry.Source),
+ Target = Marshal.PtrToStringUni(entry.Target),
+ PropertyName = Marshal.PtrToStringUni(entry.PropertyName),
+ PropertyTypeHandle = entry.PropertyTypeHandle,
+ PropertyTypeName = Marshal.PtrToStringUni(entry.PropertyTypeName),
+ UnderlyingCollectionTypeHandle = entry.UnderlyingCollectionTypeHandle,
+ UnderlyingCollectionTypeName = Marshal.PtrToStringUni(entry.UnderlyingCollectionTypeName),
+ DefaultValue = entry.DefaultValue,
+ });
+ }
+
+ return OK;
+ }
+
+ // ---- query lifecycle -------------------------------------------------------------
+
+ [UnmanagedCallersOnly(EntryPoint = "SpDataQueryNotifyInitialized")]
+ public static uint SpDataQueryNotifyInitialized(IntPtr nativeQuery)
+ {
+ if (!HandleTable.TryGet(nativeQuery, out DataQuery query))
+ return InvalidArg;
+ query.NotifyInitialized();
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpDataQueryRefresh")]
+ public static uint SpDataQueryRefresh(IntPtr nativeQuery)
+ {
+ if (!HandleTable.TryGet(nativeQuery, out DataQuery query))
+ return InvalidArg;
+ query.Refresh();
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpDataQueryGetEnabledProperty")]
+ public static uint SpDataQueryGetEnabledProperty(IntPtr nativeQuery, int* propertyValue)
+ {
+ if (propertyValue == null || !HandleTable.TryGet(nativeQuery, out DataQuery query))
+ return InvalidArg;
+ *propertyValue = query.Enabled ? 1 : 0;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpDataQuerySetEnabledProperty")]
+ public static uint SpDataQuerySetEnabledProperty(IntPtr nativeQuery, int propertyValue)
+ {
+ if (!HandleTable.TryGet(nativeQuery, out DataQuery query))
+ return InvalidArg;
+ query.Enabled = propertyValue != 0;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpDataQueryGetStatusProperty")]
+ public static uint SpDataQueryGetStatusProperty(IntPtr nativeQuery, DataProviderQueryStatus* propertyValue)
+ {
+ if (propertyValue == null || !HandleTable.TryGet(nativeQuery, out DataQuery query))
+ return InvalidArg;
+ *propertyValue = query.Status;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpDataQuerySetStatusProperty")]
+ public static uint SpDataQuerySetStatusProperty(IntPtr nativeQuery, DataProviderQueryStatus propertyValue)
+ {
+ if (!HandleTable.TryGet(nativeQuery, out DataQuery query))
+ return InvalidArg;
+ query.Status = propertyValue;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpDataQueryGetResultProperty")]
+ public static uint SpDataQueryGetResultProperty(IntPtr nativeQuery, UIXVariant* propertyValue)
+ {
+ if (propertyValue == null || !HandleTable.TryGet(nativeQuery, out DataQuery query))
+ return InvalidArg;
+ *propertyValue = query.Result;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpDataQuerySetResultProperty")]
+ public static uint SpDataQuerySetResultProperty(IntPtr nativeQuery, UIXVariant* propertyValue)
+ {
+ if (propertyValue == null || !HandleTable.TryGet(nativeQuery, out DataQuery query))
+ return InvalidArg;
+ query.Result = *propertyValue;
+ return OK;
+ }
+
+ // ---- base object -----------------------------------------------------------------
+
+ [UnmanagedCallersOnly(EntryPoint = "SpDataBaseObjectGetTypeHandle")]
+ public static void SpDataBaseObjectGetTypeHandle(IntPtr nativeQuery, ulong* typeHandle)
+ {
+ if (typeHandle == null)
+ return;
+ *typeHandle = HandleTable.TryGet(nativeQuery, out DataBaseObject obj) ? obj.TypeHandle : 0UL;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpDataBaseObjectGetProperty")]
+ public static uint SpDataBaseObjectGetProperty(IntPtr nativeQuery, char* propertyName, UIXVariant* propertyValue)
+ {
+ if (propertyValue == null || !HandleTable.TryGet(nativeQuery, out DataBaseObject obj))
+ return InvalidArg;
+
+ *propertyValue = obj.TryGetProperty(NativeString.UniToString(propertyName), out UIXVariant value) ? value : UIXVariant.Empty;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpDataBaseObjectSetProperty")]
+ public static uint SpDataBaseObjectSetProperty(IntPtr nativeQuery, char* propertyName, UIXVariant* propertyValue)
+ {
+ if (propertyValue == null || !HandleTable.TryGet(nativeQuery, out DataBaseObject obj))
+ return InvalidArg;
+
+ obj.SetProperty(NativeString.UniToString(propertyName), *propertyValue);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpDataBaseObjectSetInternalHandle")]
+ public static uint SpDataBaseObjectSetInternalHandle(IntPtr nativeQuery, ulong frameworkQuery)
+ {
+ if (!HandleTable.TryGet(nativeQuery, out DataBaseObject obj))
+ return InvalidArg;
+ obj.InternalHandle = frameworkQuery;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpDataBaseObjectGetInternalHandle")]
+ public static uint SpDataBaseObjectGetInternalHandle(IntPtr nativeQuery, ulong* frameworkQuery)
+ {
+ if (frameworkQuery == null || !HandleTable.TryGet(nativeQuery, out DataBaseObject obj))
+ return InvalidArg;
+ *frameworkQuery = obj.InternalHandle;
+ return OK;
+ }
+}
diff --git a/UIXrender/Subsystems/Data/DataModel.cs b/UIXrender/Subsystems/Data/DataModel.cs
new file mode 100644
index 0000000..31a44ee
--- /dev/null
+++ b/UIXrender/Subsystems/Data/DataModel.cs
@@ -0,0 +1,111 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.Iris.Render.Interop;
+
+namespace Microsoft.Iris.Render.Subsystems.Data;
+
+// Managed model behind the SpData* family (UIX/Microsoft/Iris/OS/NativeApi.cs) -- the
+// native side of Iris's data-binding/query system, i.e. how a markup-declared binding
+// reaches a provider's query result.
+//
+// Mirrors Microsoft.Iris.DataProviderQueryStatus exactly (UIX/Microsoft/Iris/DataProviderQueryStatus.cs).
+public enum DataProviderQueryStatus
+{
+ Idle,
+ RequestingData,
+ ProcessingData,
+ Complete,
+ Error,
+}
+
+// The base object both queries and provider-produced result objects derive from -- backs
+// SpDataBaseObjectGet/SetProperty and SpDataBaseObjectGet/SetInternalHandle.
+internal class DataBaseObject
+{
+ private readonly Dictionary _properties = new(StringComparer.Ordinal);
+
+ public ulong TypeHandle { get; set; }
+
+ // Opaque to us: the framework's own handle for the object this one shadows.
+ public ulong InternalHandle { get; set; }
+
+ public bool TryGetProperty(string name, out UIXVariant value)
+ {
+ if (name == null)
+ {
+ value = default;
+ return false;
+ }
+ return _properties.TryGetValue(name, out value);
+ }
+
+ public void SetProperty(string name, UIXVariant value)
+ {
+ if (name != null)
+ _properties[name] = value;
+ }
+}
+
+internal sealed class DataQuery : DataBaseObject
+{
+ public DataQuery(string providerName, ulong queryTypeHandle, ulong resultTypeHandle, ulong queryHandle)
+ {
+ ProviderName = providerName;
+ QueryTypeHandle = queryTypeHandle;
+ ResultTypeHandle = resultTypeHandle;
+ InternalHandle = queryHandle;
+ TypeHandle = queryTypeHandle;
+ }
+
+ public string ProviderName { get; }
+ public ulong QueryTypeHandle { get; }
+ public ulong ResultTypeHandle { get; }
+
+ public bool Enabled { get; set; }
+ public DataProviderQueryStatus Status { get; set; } = DataProviderQueryStatus.Idle;
+ public UIXVariant Result { get; set; }
+ public bool Initialized { get; private set; }
+
+ public void NotifyInitialized() => Initialized = true;
+
+ // A refresh on a disabled query is a no-op in the original's vocabulary (Enabled is
+ // exactly the "should this query run" switch), so status only advances when enabled.
+ // Nothing here fetches real data -- there is no provider implementation on this side
+ // of the boundary; the provider lives in managed framework code and pushes results
+ // back in through SpDataQuerySetResultProperty.
+ public void Refresh()
+ {
+ if (!Enabled)
+ {
+ Status = DataProviderQueryStatus.Idle;
+ return;
+ }
+
+ Status = DataProviderQueryStatus.RequestingData;
+ }
+}
+
+// Backs SpDataProviderConstructQuery/SpDataProviderReportDataMapping.
+internal sealed class DataProviderFactory
+{
+ private readonly List _mappings = new();
+
+ public IReadOnlyList Mappings => _mappings;
+
+ public void ReportMapping(DataMapping mapping) => _mappings.Add(mapping);
+}
+
+// Managed projection of NativeApi.NativeDataMappingEntry -- read as a struct of marshaled
+// strings/handles at the boundary, kept as an ordinary object here.
+internal sealed class DataMapping
+{
+ public string ProviderName { get; init; }
+ public string Source { get; init; }
+ public string Target { get; init; }
+ public string PropertyName { get; init; }
+ public ulong PropertyTypeHandle { get; init; }
+ public string PropertyTypeName { get; init; }
+ public ulong UnderlyingCollectionTypeHandle { get; init; }
+ public string UnderlyingCollectionTypeName { get; init; }
+ public UIXVariant DefaultValue { get; init; }
+}
diff --git a/UIXrender/Subsystems/Graphics/FormApi.cs b/UIXrender/Subsystems/Graphics/FormApi.cs
new file mode 100644
index 0000000..03af652
--- /dev/null
+++ b/UIXrender/Subsystems/Graphics/FormApi.cs
@@ -0,0 +1,22 @@
+using System.Runtime.InteropServices;
+using Microsoft.Iris.Render.Interop;
+
+namespace Microsoft.Iris.Render.Subsystems.Graphics;
+
+// [UnmanagedCallersOnly] exports matching UIX.RenderApi/Microsoft/Iris/Render/Internal/FormApi.cs.
+//
+// Real, and legitimately a no-op pair rather than a stub: these existed so the original
+// C++ code could call GdiplusStartup/GdiplusShutdown before using GDI+ for image decoding
+// and text rasterization. This reimplementation decodes images with StbImageSharp (pure
+// managed, see logs/UIXrender/FullSurface.md, decision 3) and never initialises GDI+ at
+// all, so there is genuinely nothing to start up -- reporting success is the correct
+// answer, not a deferral. Recorded explicitly so a future reader doesn't mistake these
+// for unimplemented.
+public static class FormApi
+{
+ [UnmanagedCallersOnly(EntryPoint = "SpGdiplusInit")]
+ public static HRESULT SpGdiplusInit() => HRESULT.S_OK;
+
+ [UnmanagedCallersOnly(EntryPoint = "SpGdiplusUninit")]
+ public static HRESULT SpGdiplusUninit() => HRESULT.S_OK;
+}
diff --git a/UIXrender/Subsystems/Lists/UIXList.cs b/UIXrender/Subsystems/Lists/UIXList.cs
new file mode 100644
index 0000000..3028d77
--- /dev/null
+++ b/UIXrender/Subsystems/Lists/UIXList.cs
@@ -0,0 +1,130 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.Iris.Render.Interop;
+
+namespace Microsoft.Iris.Render.Subsystems.Lists;
+
+// The managed model behind the SpUIXList* family (UIX/Microsoft/Iris/OS/NativeApi.cs) --
+// the native backing store for a data-bound/virtualized ListBox.
+//
+// "Virtualized" here means the list distinguishes items that are resident from items that
+// still need fetching: IsItemAvailable/FetchSlowData/WantSlowDataRequests are the
+// original surface's vocabulary for that, and SlowDataAcquireComplete is how the native
+// side told the managed listener an item had arrived. This implementation keeps that
+// distinction real (an availability flag per slot) rather than pretending every item is
+// always resident, because the managed ListBox's scrolling behavior depends on it.
+internal sealed class UIXList
+{
+ private readonly List _items = new();
+ private readonly List _available = new();
+ private readonly List _listeners = new();
+
+ public int Count => _items.Count;
+
+ public bool WantSlowDataRequests { get; set; }
+
+ public IReadOnlyList Listeners => _listeners;
+
+ public void RegisterListener(IntPtr listener)
+ {
+ if (listener != IntPtr.Zero && !_listeners.Contains(listener))
+ _listeners.Add(listener);
+ }
+
+ public void UnregisterListener(IntPtr listener) => _listeners.Remove(listener);
+
+ public int Add(UIXVariant item)
+ {
+ _items.Add(item);
+ _available.Add(true);
+ return _items.Count;
+ }
+
+ public bool Insert(int index, UIXVariant item)
+ {
+ if ((uint)index > (uint)_items.Count)
+ return false;
+ _items.Insert(index, item);
+ _available.Insert(index, true);
+ return true;
+ }
+
+ public bool RemoveAt(int index)
+ {
+ if ((uint)index >= (uint)_items.Count)
+ return false;
+ _items.RemoveAt(index);
+ _available.RemoveAt(index);
+ return true;
+ }
+
+ public bool Remove(UIXVariant item)
+ {
+ int index = IndexOf(item);
+ return index >= 0 && RemoveAt(index);
+ }
+
+ public int IndexOf(UIXVariant item)
+ {
+ for (int i = 0; i < _items.Count; i++)
+ {
+ if (_items[i].type == item.type && _items[i].union == item.union)
+ return i;
+ }
+ return -1;
+ }
+
+ public void Clear()
+ {
+ _items.Clear();
+ _available.Clear();
+ }
+
+ public bool TryGet(int index, out UIXVariant item)
+ {
+ if ((uint)index >= (uint)_items.Count)
+ {
+ item = default;
+ return false;
+ }
+ item = _items[index];
+ return true;
+ }
+
+ public bool TrySet(int index, UIXVariant item)
+ {
+ if ((uint)index >= (uint)_items.Count)
+ return false;
+ _items[index] = item;
+ _available[index] = true;
+ return true;
+ }
+
+ public bool Move(int oldIndex, int newIndex)
+ {
+ if ((uint)oldIndex >= (uint)_items.Count || (uint)newIndex >= (uint)_items.Count)
+ return false;
+
+ UIXVariant item = _items[oldIndex];
+ bool available = _available[oldIndex];
+ _items.RemoveAt(oldIndex);
+ _available.RemoveAt(oldIndex);
+ _items.Insert(newIndex, item);
+ _available.Insert(newIndex, available);
+ return true;
+ }
+
+ public bool IsItemAvailable(int index) => (uint)index < (uint)_available.Count && _available[index];
+
+ // Marks a slot as pending. The managed listener is told the data arrived via
+ // SlowDataAcquireComplete; since nothing in this reimplementation performs a real
+ // asynchronous fetch yet, the item is immediately considered resident again -- the
+ // notification still fires, so the listener's state machine is exercised correctly.
+ public bool FetchSlowData(int index)
+ {
+ if ((uint)index >= (uint)_available.Count)
+ return false;
+ _available[index] = true;
+ return true;
+ }
+}
diff --git a/UIXrender/Subsystems/Lists/UIXListApi.cs b/UIXrender/Subsystems/Lists/UIXListApi.cs
new file mode 100644
index 0000000..d0f629e
--- /dev/null
+++ b/UIXrender/Subsystems/Lists/UIXListApi.cs
@@ -0,0 +1,237 @@
+using System;
+using System.Runtime.InteropServices;
+using Microsoft.Iris.Render.Engine;
+using Microsoft.Iris.Render.Interop;
+using Microsoft.Iris.Render.Interop.Com;
+
+namespace Microsoft.Iris.Render.Subsystems.Lists;
+
+// [UnmanagedCallersOnly] exports for the SpUIXList* family in
+// UIX/Microsoft/Iris/OS/NativeApi.cs.
+//
+// The `type` argument passed to IUIXListCallbacks.ListChanged is NOT guessed: the managed
+// receiver (UIX/Microsoft/Iris/CodeModel/Cpp/DllProxyList.cs, ListChanged) casts it
+// directly to Microsoft.Iris.Data.UIListContentsChangeType, so the numbering below is
+// that enum's, read off the decompiled source.
+public static unsafe class UIXListApi
+{
+ private enum ChangeType
+ {
+ Add = 0,
+ AddRange = 1,
+ Remove = 2,
+ Move = 3,
+ Insert = 4,
+ InsertRange = 5,
+ Clear = 6,
+ Modified = 7,
+ Reset = 8,
+ }
+
+ private static uint OK => (uint)HRESULT.S_OK.hr;
+ private static uint InvalidArg => unchecked((uint)HRESULT.E_INVALIDARG.hr);
+ private static uint Fail => unchecked((uint)HRESULT.E_FAIL.hr);
+
+ [UnmanagedCallersOnly(EntryPoint = "SpUIXListRegisterCallbacks")]
+ public static uint SpUIXListRegisterCallbacks(IntPtr nativeList, IntPtr listener)
+ {
+ if (!HandleTable.TryGet(nativeList, out UIXList list))
+ return InvalidArg;
+
+ // The list holds the callback for as long as it's registered, so it takes a
+ // reference -- otherwise the caller could release its own last reference and
+ // leave us calling into freed memory on the next change notification.
+ ComVtable.AddRef(listener);
+ list.RegisterListener(listener);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpUIXListUnregisterCallbacks")]
+ public static uint SpUIXListUnregisterCallbacks(IntPtr nativeList, IntPtr listener)
+ {
+ if (!HandleTable.TryGet(nativeList, out UIXList list))
+ return InvalidArg;
+
+ list.UnregisterListener(listener);
+ ComVtable.Release(listener);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpUIXListAdd")]
+ public static uint SpUIXListAdd(IntPtr nativeList, UIXVariant* item, int* count)
+ {
+ if (item == null || count == null || !HandleTable.TryGet(nativeList, out UIXList list))
+ return InvalidArg;
+
+ *count = list.Add(*item);
+ NotifyListChanged(list, ChangeType.Add, -1, list.Count - 1, 1);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpUIXListInsert")]
+ public static uint SpUIXListInsert(IntPtr nativeList, int index, UIXVariant* item)
+ {
+ if (item == null || !HandleTable.TryGet(nativeList, out UIXList list))
+ return InvalidArg;
+ if (!list.Insert(index, *item))
+ return Fail;
+
+ NotifyListChanged(list, ChangeType.Insert, -1, index, 1);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpUIXListRemove")]
+ public static uint SpUIXListRemove(IntPtr nativeList, UIXVariant* item)
+ {
+ if (item == null || !HandleTable.TryGet(nativeList, out UIXList list))
+ return InvalidArg;
+
+ int index = list.IndexOf(*item);
+ if (index < 0 || !list.RemoveAt(index))
+ return Fail;
+
+ NotifyListChanged(list, ChangeType.Remove, index, -1, 1);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpUIXListRemoveAt")]
+ public static uint SpUIXListRemoveAt(IntPtr nativeList, int index)
+ {
+ if (!HandleTable.TryGet(nativeList, out UIXList list))
+ return InvalidArg;
+ if (!list.RemoveAt(index))
+ return Fail;
+
+ NotifyListChanged(list, ChangeType.Remove, index, -1, 1);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpUIXListMove")]
+ public static uint SpUIXListMove(IntPtr nativeList, int oldIndex, int newIndex)
+ {
+ if (!HandleTable.TryGet(nativeList, out UIXList list))
+ return InvalidArg;
+ if (!list.Move(oldIndex, newIndex))
+ return Fail;
+
+ NotifyListChanged(list, ChangeType.Move, oldIndex, newIndex, 1);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpUIXListClear")]
+ public static uint SpUIXListClear(IntPtr nativeList)
+ {
+ if (!HandleTable.TryGet(nativeList, out UIXList list))
+ return InvalidArg;
+
+ int previousCount = list.Count;
+ list.Clear();
+ NotifyListChanged(list, ChangeType.Clear, -1, -1, previousCount);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpUIXListGetItem")]
+ public static uint SpUIXListGetItem(IntPtr nativeList, int index, UIXVariant* item)
+ {
+ if (item == null || !HandleTable.TryGet(nativeList, out UIXList list))
+ return InvalidArg;
+ if (!list.TryGet(index, out UIXVariant value))
+ return Fail;
+
+ *item = value;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpUIXListSetItem")]
+ public static uint SpUIXListSetItem(IntPtr nativeList, int index, UIXVariant* item)
+ {
+ if (item == null || !HandleTable.TryGet(nativeList, out UIXList list))
+ return InvalidArg;
+ if (!list.TrySet(index, *item))
+ return Fail;
+
+ NotifyListChanged(list, ChangeType.Modified, index, index, 1);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpUIXListGetCount")]
+ public static uint SpUIXListGetCount(IntPtr nativeList, int* count)
+ {
+ if (count == null || !HandleTable.TryGet(nativeList, out UIXList list))
+ return InvalidArg;
+ *count = list.Count;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpUIXListIndexOf")]
+ public static uint SpUIXListIndexOf(IntPtr nativeList, UIXVariant* item, int* index)
+ {
+ if (item == null || index == null || !HandleTable.TryGet(nativeList, out UIXList list))
+ return InvalidArg;
+ *index = list.IndexOf(*item);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpUIXListIsItemAvailable")]
+ public static uint SpUIXListIsItemAvailable(IntPtr nativeList, int index, int* isAvailable)
+ {
+ if (isAvailable == null || !HandleTable.TryGet(nativeList, out UIXList list))
+ return InvalidArg;
+ *isAvailable = list.IsItemAvailable(index) ? 1 : 0;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpUIXListFetchSlowData")]
+ public static uint SpUIXListFetchSlowData(IntPtr nativeList, int index)
+ {
+ if (!HandleTable.TryGet(nativeList, out UIXList list))
+ return InvalidArg;
+ if (!list.FetchSlowData(index))
+ return Fail;
+
+ NotifySlowDataAcquireComplete(list, index);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpUIXListWantSlowDataRequests")]
+ public static uint SpUIXListWantSlowDataRequests(IntPtr nativeList, int* wantSlowDataRequests)
+ {
+ if (wantSlowDataRequests == null || !HandleTable.TryGet(nativeList, out UIXList list))
+ return InvalidArg;
+ *wantSlowDataRequests = list.WantSlowDataRequests ? 1 : 0;
+ return OK;
+ }
+
+ // Visual create/release notifications are the managed side telling the store which
+ // slots are on screen. Nothing is evicted on release yet (this store keeps everything
+ // resident), but the slot is validated so a bad index is reported rather than ignored.
+ [UnmanagedCallersOnly(EntryPoint = "SpUIXListNotifyVisualsCreated")]
+ public static uint SpUIXListNotifyVisualsCreated(IntPtr nativeList, int index) =>
+ HandleTable.TryGet(nativeList, out UIXList list) && (uint)index < (uint)list.Count ? OK : InvalidArg;
+
+ [UnmanagedCallersOnly(EntryPoint = "SpUIXListNotifyVisualsReleased")]
+ public static uint SpUIXListNotifyVisualsReleased(IntPtr nativeList, int index) =>
+ HandleTable.TryGet(nativeList, out UIXList list) && (uint)index < (uint)list.Count ? OK : InvalidArg;
+
+ // ---- listener dispatch -----------------------------------------------------------
+
+ private static void NotifyListChanged(UIXList list, ChangeType type, int oldIndex, int newIndex, int count)
+ {
+ foreach (IntPtr listener in list.Listeners)
+ {
+ void* fn = ComVtable.Slot(listener, ComVtable.FirstMethodSlot + 0); // ListChanged
+ if (fn != null)
+ ((delegate* unmanaged)fn)(listener, (int)type, oldIndex, newIndex, count);
+ }
+ }
+
+ private static void NotifySlowDataAcquireComplete(UIXList list, int index)
+ {
+ foreach (IntPtr listener in list.Listeners)
+ {
+ void* fn = ComVtable.Slot(listener, ComVtable.FirstMethodSlot + 1); // SlowDataAcquireComplete
+ if (fn != null)
+ ((delegate* unmanaged)fn)(listener, index);
+ }
+ }
+}
diff --git a/UIXrender/Subsystems/Memory/MemoryApi.cs b/UIXrender/Subsystems/Memory/MemoryApi.cs
new file mode 100644
index 0000000..c34aa4a
--- /dev/null
+++ b/UIXrender/Subsystems/Memory/MemoryApi.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Microsoft.Iris.Render.Subsystems.Memory;
+
+// [UnmanagedCallersOnly] exports matching SpMemAlloc/SpMemFree/SpFreeDib
+// (UIX/Microsoft/Iris/OS/NativeApi.cs). Real logic: plain unmanaged heap allocation, no
+// stubbing needed -- there's nothing platform-specific about a heap allocator.
+public static unsafe class MemoryApi
+{
+ // `bool zeroMemory` in the original DllImport has no [MarshalAs] override, so the CLR
+ // marshals it as the default 4-byte Win32 BOOL, not 1 byte -- `int` here, not `byte`.
+ [UnmanagedCallersOnly(EntryPoint = "SpMemAlloc")]
+ public static IntPtr SpMemAlloc(uint cb, int zeroMemory)
+ {
+ if (cb == 0)
+ return IntPtr.Zero;
+
+ void* p = zeroMemory != 0 ? NativeMemory.AllocZeroed(cb) : NativeMemory.Alloc(cb);
+ return (IntPtr)p;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpMemFree")]
+ public static void SpMemFree(IntPtr pv)
+ {
+ if (pv != IntPtr.Zero)
+ NativeMemory.Free((void*)pv);
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpFreeDib")]
+ public static void SpFreeDib(IntPtr hdib)
+ {
+#if WINDOWS
+ if (hdib != IntPtr.Zero)
+ Interop.Win32.Gdi32.DeleteObject(hdib);
+#else
+ // TODO: no non-Windows caller of SpFreeDib exists yet in this repo (DIB sections
+ // are a GDI-specific concept) -- nothing to free cross-platform, logged as an
+ // open question rather than guessed at further. See logs/UIXrender/FullSurface.md.
+#endif
+ }
+}
diff --git a/UIXrender/Subsystems/Os/DownloadApi.cs b/UIXrender/Subsystems/Os/DownloadApi.cs
new file mode 100644
index 0000000..5bb6020
--- /dev/null
+++ b/UIXrender/Subsystems/Os/DownloadApi.cs
@@ -0,0 +1,145 @@
+using System;
+using System.IO;
+using System.Net.Http;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Iris.Interop;
+using Microsoft.Iris.Render.Engine;
+
+namespace Microsoft.Iris.Render.Subsystems.Os;
+
+// [UnmanagedCallersOnly] exports for the download/HTTP family in
+// UIX/Microsoft/Iris/OS/NativeApi.cs.
+//
+// Real and cross-platform: HttpClient for the network path, FileStream for the local one.
+// Both are genuinely asynchronous like the original (the managed caller passes a
+// DownloadCompleteHandler and gets called back on completion), and both hand the
+// completed bytes over as an unmanaged buffer the caller reads via SpDownloadGetBuffer
+// and releases via SpDownloadClose.
+//
+// The error codes are not invented -- NativeApi.cs declares them as public constants:
+// DOWNLOAD_ERROR_NONE = 0, DOWNLOAD_ERROR_GENERALFAILURE = -1,
+// HTTPDOWNLOAD_ERROR_INVALIDURI = 1, HTTPDOWNLOAD_ERROR_HOSTCONNECTIONFAILED = 2.
+//
+// Note the class is not `unsafe` as a whole: C# forbids `await` inside an unsafe context,
+// so the async download body lives in ordinary code and only the final callback
+// invocation (which needs a function pointer) is marked unsafe.
+public static class DownloadApi
+{
+ private const int DOWNLOAD_ERROR_NONE = 0;
+ private const int DOWNLOAD_ERROR_GENERALFAILURE = -1;
+ private const int HTTPDOWNLOAD_ERROR_INVALIDURI = 1;
+ private const int HTTPDOWNLOAD_ERROR_HOSTCONNECTIONFAILED = 2;
+
+ private static readonly Lazy s_httpClient = new(() => new HttpClient());
+ private static int s_httpStarted;
+
+ private sealed class Download : IDisposable
+ {
+ public IntPtr Buffer;
+ public uint Length;
+
+ public void Dispose()
+ {
+ if (Buffer != IntPtr.Zero)
+ {
+ Marshal.FreeHGlobal(Buffer);
+ Buffer = IntPtr.Zero;
+ }
+ }
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpHttpStartup")]
+ public static void SpHttpStartup() => Interlocked.Exchange(ref s_httpStarted, 1);
+
+ [UnmanagedCallersOnly(EntryPoint = "SpHttpShutdown")]
+ public static void SpHttpShutdown() => Interlocked.Exchange(ref s_httpStarted, 0);
+
+ // HttpClient manages its own connection pool and exposes no proxy-cache flush, so
+ // there is nothing to invalidate here. Kept as a real (empty) implementation rather
+ // than a failure return: the original's callers treat this as advisory.
+ [UnmanagedCallersOnly(EntryPoint = "SpHttpFlushProxyCache")]
+ public static void SpHttpFlushProxyCache() { }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpFileDownload")]
+ public static unsafe uint SpFileDownload(char* path, IntPtr handler, IntPtr context, IntPtr* handle)
+ {
+ if (handle == null)
+ return unchecked((uint)DOWNLOAD_ERROR_GENERALFAILURE);
+
+ string filePath = NativeString.UniToString(path);
+ var download = new Download();
+ IntPtr downloadHandle = HandleTable.Alloc(download);
+ *handle = downloadHandle;
+
+ RunDownload(() => File.ReadAllBytesAsync(filePath), download, downloadHandle, handler, context, static _ => DOWNLOAD_ERROR_GENERALFAILURE);
+ return DOWNLOAD_ERROR_NONE;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpHttpDownload")]
+ public static unsafe uint SpHttpDownload(char* url, IntPtr handler, IntPtr context, IntPtr* handle)
+ {
+ if (handle == null)
+ return unchecked((uint)DOWNLOAD_ERROR_GENERALFAILURE);
+
+ *handle = IntPtr.Zero;
+ string address = NativeString.UniToString(url);
+ if (!Uri.TryCreate(address, UriKind.Absolute, out Uri uri))
+ return HTTPDOWNLOAD_ERROR_INVALIDURI;
+
+ var download = new Download();
+ IntPtr downloadHandle = HandleTable.Alloc(download);
+ *handle = downloadHandle;
+
+ RunDownload(() => s_httpClient.Value.GetByteArrayAsync(uri), download, downloadHandle, handler, context,
+ static e => e is HttpRequestException ? HTTPDOWNLOAD_ERROR_HOSTCONNECTIONFAILED : DOWNLOAD_ERROR_GENERALFAILURE);
+
+ return DOWNLOAD_ERROR_NONE;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpDownloadGetBuffer")]
+ public static IntPtr SpDownloadGetBuffer(IntPtr handle) =>
+ HandleTable.TryGet(handle, out Download download) ? download.Buffer : IntPtr.Zero;
+
+ [UnmanagedCallersOnly(EntryPoint = "SpDownloadClose")]
+ public static uint SpDownloadClose(IntPtr handle)
+ {
+ HandleTable.Free(handle);
+ return DOWNLOAD_ERROR_NONE;
+ }
+
+ // Shared completion path for both download flavours: run the fetch off-thread, copy
+ // the result into an unmanaged buffer the caller can read, then invoke the caller's
+ // DownloadCompleteHandler(handle, error, length, context).
+ private static void RunDownload(Func> fetch, Download download, IntPtr downloadHandle, IntPtr handler, IntPtr context, Func classify)
+ {
+ _ = Task.Run(async () =>
+ {
+ int error = DOWNLOAD_ERROR_NONE;
+ try
+ {
+ byte[] bytes = await fetch().ConfigureAwait(false);
+ IntPtr buffer = Marshal.AllocHGlobal(bytes.Length);
+ Marshal.Copy(bytes, 0, buffer, bytes.Length);
+ download.Buffer = buffer;
+ download.Length = (uint)bytes.Length;
+ }
+ catch (Exception e)
+ {
+ error = classify(e);
+ }
+
+ InvokeCompletionHandler(handler, downloadHandle, error, download.Length, context);
+ });
+ }
+
+ private static unsafe void InvokeCompletionHandler(IntPtr handler, IntPtr downloadHandle, int error, uint length, IntPtr context)
+ {
+ if (handler == IntPtr.Zero)
+ return;
+
+ var callback = (delegate* unmanaged)handler;
+ callback(downloadHandle, error, length, context);
+ }
+}
diff --git a/UIXrender/Subsystems/Os/MarshalApi.cs b/UIXrender/Subsystems/Os/MarshalApi.cs
new file mode 100644
index 0000000..de2211a
--- /dev/null
+++ b/UIXrender/Subsystems/Os/MarshalApi.cs
@@ -0,0 +1,144 @@
+using System;
+using System.Runtime.InteropServices;
+using Microsoft.Iris.Interop;
+using Microsoft.Iris.Render.Engine;
+using Microsoft.Iris.Render.Interop;
+
+namespace Microsoft.Iris.Render.Subsystems.Os;
+
+// [UnmanagedCallersOnly] exports for the string/image marshaling helpers and the native
+// services registration in UIX/Microsoft/Iris/OS/NativeApi.cs.
+//
+// The model here follows the original's vocabulary exactly: a "handle" (ulong) is the
+// *framework's* identifier for a managed string/image, and a "native string"/"native
+// image" is an object on this side that shadows it. Converting between them therefore
+// requires calling back into the framework through the registered IRawUIXServices --
+// which is what makes SpCreateNativeString able to produce real text (via PinString)
+// rather than an empty placeholder.
+public static unsafe class MarshalApi
+{
+ internal sealed class NativeStringObject(string value, ulong handle)
+ {
+ public string Value { get; } = value;
+ public ulong Handle { get; } = handle;
+ }
+
+ internal sealed class NativeImageObject(string source, ulong handle)
+ {
+ public string Source { get; } = source;
+ public ulong Handle { get; } = handle;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpRegisterNativeServicesCallbacks")]
+ public static uint SpRegisterNativeServicesCallbacks(IntPtr rawServices)
+ {
+ NativeServices.Register(rawServices);
+ return (uint)HRESULT.S_OK.hr;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpUnregisterNativeServicesCallbacks")]
+ public static void SpUnregisterNativeServicesCallbacks() => NativeServices.Unregister();
+
+ // ---- strings ---------------------------------------------------------------------
+
+ [UnmanagedCallersOnly(EntryPoint = "SpGetStringHandle")]
+ public static void SpGetStringHandle(IntPtr nativeString, ulong* handle)
+ {
+ if (handle == null)
+ return;
+ *handle = HandleTable.TryGet(nativeString, out NativeStringObject value) ? value.Handle : 0UL;
+ }
+
+ // "Convert to managed" and "get the handle" are the same operation here: the native
+ // string was created from a framework handle, so it already knows the managed
+ // identity it shadows. Kept as two exports because the managed side declares two --
+ // and the body is duplicated rather than delegated because an [UnmanagedCallersOnly]
+ // method cannot be called from C# at all (CS8901).
+ [UnmanagedCallersOnly(EntryPoint = "SpConvertStringToManaged")]
+ public static void SpConvertStringToManaged(IntPtr nativeString, ulong* handle)
+ {
+ if (handle == null)
+ return;
+ *handle = HandleTable.TryGet(nativeString, out NativeStringObject value) ? value.Handle : 0UL;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpCreateNativeString")]
+ public static int SpCreateNativeString(ulong handle, int length, IntPtr* nativeString)
+ {
+ if (nativeString == null)
+ return 0;
+
+ *nativeString = IntPtr.Zero;
+
+ // Read the framework's characters through the registered services callback. Without
+ // a registration there's no way to reach the text, so the creation genuinely fails
+ // rather than silently producing an empty string that would look like valid data.
+ if (!NativeServices.IsRegistered)
+ return 0;
+
+ char* pinned = NativeServices.PinString(handle);
+ if (pinned == null)
+ return 0;
+
+ try
+ {
+ string value = length > 0 ? new string(pinned, 0, length) : NativeString.UniToString(pinned);
+ *nativeString = HandleTable.Alloc(new NativeStringObject(value, handle));
+ return 1;
+ }
+ finally
+ {
+ NativeServices.UnpinString(handle);
+ }
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpCopyString")]
+ public static void SpCopyString(char* source, char* destination, uint length)
+ {
+ if (source == null || destination == null || length == 0)
+ return;
+
+ // Copies at most `length` characters and always terminates, so a caller-sized
+ // buffer can't be overrun by a longer source.
+ uint i = 0;
+ for (; i < length - 1 && source[i] != '\0'; i++)
+ destination[i] = source[i];
+ destination[i] = '\0';
+ }
+
+ // ---- images ----------------------------------------------------------------------
+
+ [UnmanagedCallersOnly(EntryPoint = "SpGetImageHandle")]
+ public static void SpGetImageHandle(IntPtr nativeImage, ulong* handle)
+ {
+ if (handle == null)
+ return;
+ *handle = HandleTable.TryGet(nativeImage, out NativeImageObject image) ? image.Handle : 0UL;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpConvertImageToManaged")]
+ public static HRESULT SpConvertImageToManaged(IntPtr nativeImage, ulong* handle)
+ {
+ if (handle == null)
+ return HRESULT.E_INVALIDARG;
+
+ if (!HandleTable.TryGet(nativeImage, out NativeImageObject image))
+ {
+ *handle = 0UL;
+ return HRESULT.E_INVALIDARG;
+ }
+
+ *handle = image.Handle;
+ return HRESULT.S_OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpCreateNativeImage")]
+ public static HRESULT SpCreateNativeImage(ulong handle, char* source, IntPtr* nativeImage)
+ {
+ if (nativeImage == null)
+ return HRESULT.E_INVALIDARG;
+
+ *nativeImage = HandleTable.Alloc(new NativeImageObject(NativeString.UniToString(source), handle));
+ return HRESULT.S_OK;
+ }
+}
diff --git a/UIXrender/Subsystems/Os/ModuleApi.cs b/UIXrender/Subsystems/Os/ModuleApi.cs
new file mode 100644
index 0000000..2b2c820
--- /dev/null
+++ b/UIXrender/Subsystems/Os/ModuleApi.cs
@@ -0,0 +1,168 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using Microsoft.Iris.Interop;
+using Microsoft.Iris.Render.Engine;
+using Microsoft.Iris.Render.Interop;
+using Microsoft.Iris.Render.Subsystems.Schema;
+
+namespace Microsoft.Iris.Render.Subsystems.Os;
+
+// [UnmanagedCallersOnly] exports for the "resource / DLL loading" family in
+// UIX/Microsoft/Iris/OS/NativeApi.cs -- how markup (.uix/.uib) pulls in an assembly and
+// discovers the types it can bind to.
+//
+// Real, and deliberately managed: SpLoadDll loads a **.NET assembly** and
+// SpCreateDllLoadResultFactory projects its exported types through the schema subsystem
+// (Subsystems/Schema), which is exactly what the managed caller then walks via
+// SpQueryTypeCount/SpGetTypeSchema/... That keeps the whole "markup references a class by
+// name" pipeline working end-to-end on any platform, instead of depending on the Win32
+// loader and the original's C++ type registry.
+public static unsafe class ModuleApi
+{
+ private static uint OK => (uint)HRESULT.S_OK.hr;
+ private static uint Fail => unchecked((uint)HRESULT.E_FAIL.hr);
+ private static uint InvalidArg => unchecked((uint)HRESULT.E_INVALIDARG.hr);
+
+ private sealed class LoadedModule(Assembly assembly, string location)
+ {
+ public Assembly Assembly { get; } = assembly;
+ public string Location { get; } = location;
+ }
+
+ // Trim-unsafe by design: loading an assembly the host names at runtime is this
+ // export's entire purpose. Suppressed with justification rather than left warning --
+ // see SchemaRegistration.FromAssembly for how markup-visible assemblies must be
+ // rooted by the host instead.
+ [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "SpLoadDll exists precisely to load a host-chosen assembly at runtime; the host must root it.")]
+ [UnmanagedCallersOnly(EntryPoint = "SpLoadDll")]
+ public static uint SpLoadDll(char* uri, IntPtr* moduleHandle)
+ {
+ if (moduleHandle == null)
+ return InvalidArg;
+
+ *moduleHandle = IntPtr.Zero;
+ string path = NativeString.UniToString(uri);
+ if (string.IsNullOrEmpty(path))
+ return InvalidArg;
+
+ try
+ {
+ // Assembly.LoadFrom resolves alongside dependencies the same way the original
+ // Win32 LoadLibrary did for its neighbours.
+ Assembly assembly = Assembly.LoadFrom(Path.GetFullPath(path));
+ *moduleHandle = HandleTable.Alloc(new LoadedModule(assembly, path));
+ return OK;
+ }
+ catch (Exception e) when (e is IOException or BadImageFormatException)
+ {
+ return Fail;
+ }
+ }
+
+ // The CLR has no assembly-unload for the default load context, so this releases our
+ // own handle rather than pretending the code was evicted. Documented rather than
+ // silently doing nothing: an unloadable AssemblyLoadContext would change observable
+ // type identity, which markup depends on staying stable for the process lifetime.
+ [UnmanagedCallersOnly(EntryPoint = "SpFreeDll")]
+ public static void SpFreeDll(IntPtr moduleHandle) => HandleTable.Free(moduleHandle);
+
+ [UnmanagedCallersOnly(EntryPoint = "SpCreateDllLoadResultFactory")]
+ public static uint SpCreateDllLoadResultFactory(IntPtr moduleHandle, IntPtr* schemaFactory)
+ {
+ if (schemaFactory == null)
+ return InvalidArg;
+
+ *schemaFactory = IntPtr.Zero;
+ if (!HandleTable.TryGet(moduleHandle, out LoadedModule module))
+ return InvalidArg;
+
+ try
+ {
+ *schemaFactory = HandleTable.Alloc(SchemaRegistration.FromAssembly(module.Assembly));
+ return OK;
+ }
+ catch (ReflectionTypeLoadException)
+ {
+ return Fail;
+ }
+ }
+
+ // The "qualifier" selects a sub-view of a module's schema (markup can reference one
+ // module under several qualified names). Nothing in this reimplementation partitions
+ // a module's exported types, so the load result is the whole registration -- a real
+ // result, and the same object the factory already produced.
+ [UnmanagedCallersOnly(EntryPoint = "SpCreateDllLoadResult")]
+ public static uint SpCreateDllLoadResult(IntPtr schemaFactory, char* qualifier, IntPtr* loadResult)
+ {
+ if (loadResult == null)
+ return InvalidArg;
+
+ *loadResult = IntPtr.Zero;
+ if (!HandleTable.TryGet(schemaFactory, out SchemaRegistration registration))
+ return InvalidArg;
+
+ *loadResult = HandleTable.Alloc(registration);
+ return OK;
+ }
+
+ // Advisory notification that a module's schema is going away; there are no cached
+ // per-module schema views to invalidate in this implementation.
+ [UnmanagedCallersOnly(EntryPoint = "SpSendDllSchemaUnloadNotification")]
+ public static void SpSendDllSchemaUnloadNotification(IntPtr moduleHandle) { }
+
+ // Real, cross-platform: reads an embedded managed resource out of the loaded
+ // assembly (the .NET equivalent of a Win32 RT_RCDATA resource) into an unmanaged
+ // buffer the caller reads directly. `moduleBaseName` selects the assembly by simple
+ // name among those already loaded, matching the original's "base name" lookup.
+ [UnmanagedCallersOnly(EntryPoint = "SpLoadBinaryResource")]
+ public static int SpLoadBinaryResource(char* moduleBaseName, char* resourceName, int allowLoadAsCode, IntPtr* pBits, uint* size)
+ {
+ if (pBits == null || size == null)
+ return 0;
+
+ *pBits = IntPtr.Zero;
+ *size = 0;
+
+ Assembly assembly = FindAssembly(NativeString.UniToString(moduleBaseName));
+ string name = NativeString.UniToString(resourceName);
+ if (assembly == null || string.IsNullOrEmpty(name))
+ return 0;
+
+ using Stream stream = assembly.GetManifestResourceStream(name);
+ if (stream == null)
+ return 0;
+
+ var bytes = new byte[stream.Length];
+ int read = stream.ReadAtLeast(bytes, bytes.Length, throwOnEndOfStream: false);
+
+ IntPtr buffer = Marshal.AllocHGlobal(read);
+ Marshal.Copy(bytes, 0, buffer, read);
+ *pBits = buffer;
+ *size = (uint)read;
+ return 1;
+ }
+
+ // Font resources need a platform font-registration call (AddFontMemResourceEx on
+ // Windows, fontconfig elsewhere) that this project has no abstraction for yet, and
+ // guessing one would silently do nothing while reporting success.
+ // TODO: implement when text rendering gains a real font backend -- see the rich-text
+ // open question in logs/UIXrender/FullSurface.md.
+ [UnmanagedCallersOnly(EntryPoint = "SpLoadFontResource")]
+ public static int SpLoadFontResource(char* moduleBaseName, char* resourceName) => 0;
+
+ private static Assembly FindAssembly(string baseName)
+ {
+ if (string.IsNullOrEmpty(baseName))
+ return null;
+
+ foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
+ {
+ if (string.Equals(assembly.GetName().Name, baseName, StringComparison.OrdinalIgnoreCase))
+ return assembly;
+ }
+ return null;
+ }
+}
diff --git a/UIXrender/Subsystems/Os/NativeServices.cs b/UIXrender/Subsystems/Os/NativeServices.cs
new file mode 100644
index 0000000..ad5e05e
--- /dev/null
+++ b/UIXrender/Subsystems/Os/NativeServices.cs
@@ -0,0 +1,111 @@
+using System;
+using Microsoft.Iris.Render.Interop.Com;
+
+namespace Microsoft.Iris.Render.Subsystems.Os;
+
+// The registered IRawUIXServices callback -- how UIXrender calls *back into* the managed
+// framework (UIX.dll). Registered via SpRegisterNativeServicesCallbacks.
+//
+// Slot numbers below are IRawUIXServices' declaration order
+// (UIX/Microsoft/Iris/OS/IRawUIXServices.cs) offset by the three IUnknown slots. They are
+// read off that file, not guessed; the interface is
+// [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)], so there are no IDispatch slots
+// in between.
+internal static unsafe class NativeServices
+{
+ private const int SlotCrash = ComVtable.FirstMethodSlot + 0;
+ private const int SlotNotifyChangeForObject = ComVtable.FirstMethodSlot + 1;
+ private const int SlotAllocateString = ComVtable.FirstMethodSlot + 2;
+ private const int SlotCopyString = ComVtable.FirstMethodSlot + 3;
+ private const int SlotPinString = ComVtable.FirstMethodSlot + 4;
+ private const int SlotUnpinString = ComVtable.FirstMethodSlot + 5;
+ private const int SlotReleaseString = ComVtable.FirstMethodSlot + 6;
+ private const int SlotAllocateImageFromUri = ComVtable.FirstMethodSlot + 7;
+ private const int SlotAllocateImageFromBits = ComVtable.FirstMethodSlot + 8;
+ private const int SlotRemoveCachedImage = ComVtable.FirstMethodSlot + 9;
+ private const int SlotReleaseImage = ComVtable.FirstMethodSlot + 10;
+ private const int SlotRegisterDataProvider = ComVtable.FirstMethodSlot + 11;
+ private const int SlotGetDataMapping = ComVtable.FirstMethodSlot + 12;
+ private const int SlotNotifyChangeForDataObject = ComVtable.FirstMethodSlot + 13;
+ private const int SlotGetAppWindowHandle = ComVtable.FirstMethodSlot + 14;
+ private const int SlotReportError = ComVtable.FirstMethodSlot + 15;
+ private const int SlotLowPriorityDeferredInvoke = ComVtable.FirstMethodSlot + 16;
+
+ private static IntPtr s_services;
+
+ public static bool IsRegistered => s_services != IntPtr.Zero;
+
+ public static void Register(IntPtr services)
+ {
+ Unregister();
+ if (services != IntPtr.Zero)
+ {
+ ComVtable.AddRef(services);
+ s_services = services;
+ }
+ }
+
+ public static void Unregister()
+ {
+ if (s_services != IntPtr.Zero)
+ {
+ ComVtable.Release(s_services);
+ s_services = IntPtr.Zero;
+ }
+ }
+
+ // Pins the framework's string for the handle and returns a pointer to its characters.
+ // This is what makes SpCreateNativeString able to materialise real text rather than an
+ // empty placeholder: the text lives on the managed side, and this is the only way to
+ // read it.
+ public static char* PinString(ulong handle)
+ {
+ void* fn = ComVtable.Slot(s_services, SlotPinString);
+ return fn == null ? null : ((delegate* unmanaged)fn)(s_services, handle);
+ }
+
+ public static void UnpinString(ulong handle)
+ {
+ void* fn = ComVtable.Slot(s_services, SlotUnpinString);
+ if (fn != null)
+ ((delegate* unmanaged)fn)(s_services, handle);
+ }
+
+ public static void ReleaseString(ulong handle)
+ {
+ void* fn = ComVtable.Slot(s_services, SlotReleaseString);
+ if (fn != null)
+ ((delegate* unmanaged)fn)(s_services, handle);
+ }
+
+ public static ulong AllocateString(char* value, out int length)
+ {
+ length = 0;
+ void* fn = ComVtable.Slot(s_services, SlotAllocateString);
+ if (fn == null)
+ return 0;
+
+ fixed (int* pLength = &length)
+ return ((delegate* unmanaged)fn)(s_services, value, pLength);
+ }
+
+ public static void ReleaseImage(ulong handle)
+ {
+ void* fn = ComVtable.Slot(s_services, SlotReleaseImage);
+ if (fn != null)
+ ((delegate* unmanaged)fn)(s_services, handle);
+ }
+
+ public static void ReportError(bool isWarning, char* message)
+ {
+ void* fn = ComVtable.Slot(s_services, SlotReportError);
+ if (fn != null)
+ ((delegate* unmanaged)fn)(s_services, isWarning ? 1 : 0, message);
+ }
+
+ public static IntPtr GetAppWindowHandle()
+ {
+ void* fn = ComVtable.Slot(s_services, SlotGetAppWindowHandle);
+ return fn == null ? IntPtr.Zero : ((delegate* unmanaged)fn)(s_services);
+ }
+}
diff --git a/UIXrender/Subsystems/Os/SystemApi.cs b/UIXrender/Subsystems/Os/SystemApi.cs
new file mode 100644
index 0000000..4f4a0a9
--- /dev/null
+++ b/UIXrender/Subsystems/Os/SystemApi.cs
@@ -0,0 +1,177 @@
+using System;
+using System.Runtime.InteropServices;
+using Microsoft.Iris.Interop;
+using Microsoft.Iris.Render.Engine;
+using Microsoft.Iris.Render.Interop;
+
+namespace Microsoft.Iris.Render.Subsystems.Os;
+
+// [UnmanagedCallersOnly] exports for the remaining miscellaneous OS entry points in
+// UIX/Microsoft/Iris/OS/NativeApi.cs: DPI, mouse cursor metrics, drag-and-drop, the
+// notification window, IME plumbing, registry change notification, and the deferred
+// invoke helper.
+//
+// These are OS APIs, not graphics APIs, so per CLAUDE.md they are implemented for real on
+// Windows behind `#if WINDOWS` with a documented fallback + TODO elsewhere -- rather than
+// pulling in a windowing toolkit (see logs/UIXrender/FullSurface.md, decision 2, for why
+// Silk.NET.Windowing/Input are the wrong tool for a library loaded into a host that
+// already owns its window and pump).
+public static unsafe class SystemApi
+{
+ // The Win32 "default" logical DPI. Every Iris layout calculation is relative to this,
+ // so it is the correct neutral answer where the real value can't be queried -- not a
+ // placeholder number.
+ private const int DefaultDpi = 96;
+
+ private static uint OK => (uint)HRESULT.S_OK.hr;
+
+ [UnmanagedCallersOnly(EntryPoint = "SpGetDpi")]
+ public static int SpGetDpi()
+ {
+#if WINDOWS
+ return (int)Win32.GetDpiForSystem();
+#else
+ // TODO: query the platform's scale factor (Xft.dpi / GSettings text-scaling-factor
+ // on X11, wl_output scale on Wayland) once this project has a display abstraction.
+ return DefaultDpi;
+#endif
+ }
+
+ // Reports the system cursor height and the hotspot's Y offset -- used by Iris to align
+ // a custom-drawn cursor with the OS one.
+ [UnmanagedCallersOnly(EntryPoint = "SpGetMouseCursorInfo")]
+ public static void SpGetMouseCursorInfo(int* height, int* hotY)
+ {
+ if (height == null || hotY == null)
+ return;
+
+#if WINDOWS
+ const int SM_CYCURSOR = 14;
+ int cursorHeight = Win32.GetSystemMetrics(SM_CYCURSOR);
+ *height = cursorHeight > 0 ? cursorHeight : 32;
+ // The hotspot of the standard arrow cursor sits at its top-left, so the Y offset
+ // is zero; a themed cursor can differ but Win32 exposes no metric for it without
+ // loading and inspecting the cursor bitmap itself.
+ *hotY = 0;
+#else
+ // TODO: no cross-platform cursor-metrics abstraction available yet. 32px is the
+ // near-universal default cursor size on both Windows and common Linux themes.
+ *height = 32;
+ *hotY = 0;
+#endif
+ }
+
+ // Enumerates the file names in a shell drag-and-drop data object, invoking the
+ // caller's callback once per file. The data object is an IDataObject COM pointer,
+ // which only exists on Windows.
+ // TODO: wire to the platform's drag-and-drop protocol (XDND / wl_data_device) when a
+ // cross-platform windowing layer exists.
+ [UnmanagedCallersOnly(EntryPoint = "SpExtractDroppedFileNames")]
+ public static int SpExtractDroppedFileNames(IntPtr punk, IntPtr callback) => 0;
+
+ // The notification window is a hidden, message-only window the original used to
+ // receive broadcast messages. It has no cross-platform analogue, and the callback is
+ // only ever invoked for NotificationType.GetObject.
+ // TODO: implement per-platform if any consumer starts depending on the notifications.
+ [UnmanagedCallersOnly(EntryPoint = "SpCreateNotifyWindow")]
+ public static HRESULT SpCreateNotifyWindow(IntPtr* handle, IntPtr callback)
+ {
+ if (handle == null)
+ return HRESULT.E_INVALIDARG;
+ *handle = IntPtr.Zero;
+ return HRESULT.E_NOTIMPL;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpDestroyNotifyWindow")]
+ public static void SpDestroyNotifyWindow() { }
+
+ // IME (input method editor) composition forwarding is a Win32 message-loop concept
+ // (WM_IME_STARTCOMPOSITION/WM_IME_ENDCOMPOSITION, which NativeApi.cs declares as
+ // constants). The callbacks are held so registration/unregistration round-trips
+ // correctly and a token is genuinely issued, but nothing posts to them without a
+ // message pump to hook.
+ // TODO: forward real composition events once this project owns a window/message loop.
+ private static readonly System.Collections.Concurrent.ConcurrentDictionary s_imeCallbacks = new();
+ private static uint s_nextImeToken;
+
+ [UnmanagedCallersOnly(EntryPoint = "SpRegisterImeCallbacks")]
+ public static HRESULT SpRegisterImeCallbacks(IntPtr pImeCallbacks, uint* dwToken)
+ {
+ if (dwToken == null)
+ return HRESULT.E_INVALIDARG;
+
+ uint token = System.Threading.Interlocked.Increment(ref s_nextImeToken);
+ Interop.Com.ComVtable.AddRef(pImeCallbacks);
+ s_imeCallbacks[token] = pImeCallbacks;
+ *dwToken = token;
+ return HRESULT.S_OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpUnregisterImeCallbacks")]
+ public static HRESULT SpUnregisterImeCallbacks(uint dwToken)
+ {
+ if (!s_imeCallbacks.TryRemove(dwToken, out IntPtr callbacks))
+ return HRESULT.E_INVALIDARG;
+
+ Interop.Com.ComVtable.Release(callbacks);
+ return HRESULT.S_OK;
+ }
+
+ // Real: fans the message out to every registered IImeCallbacks
+ // (OnImeMessageReceived is its single method, hence the first method slot).
+ [UnmanagedCallersOnly(EntryPoint = "SpPostDeferredImeMessage")]
+ public static HRESULT SpPostDeferredImeMessage(uint message, UIntPtr wParam, UIntPtr lParam)
+ {
+ foreach (IntPtr callbacks in s_imeCallbacks.Values)
+ {
+ void* fn = Interop.Com.ComVtable.Slot(callbacks, Interop.Com.ComVtable.FirstMethodSlot);
+ if (fn != null)
+ ((delegate* unmanaged)fn)(callbacks, message, wParam, lParam);
+ }
+ return HRESULT.S_OK;
+ }
+
+ // Registry-change notification is inherently a Windows concept (the managed callers
+ // are Configuration classes reading HKLM/HKCU).
+ [UnmanagedCallersOnly(EntryPoint = "SpRegNotifyChangeKey")]
+ public static HRESULT SpRegNotifyChangeKey(IntPtr hkey, char* wszPath, IntPtr callback, IntPtr* handle)
+ {
+ if (handle == null)
+ return HRESULT.E_INVALIDARG;
+
+ *handle = IntPtr.Zero;
+#if WINDOWS
+ var watcher = RegistryChangeWatcher.Create(hkey, NativeString.UniToString(wszPath), callback);
+ if (watcher == null)
+ return HRESULT.E_FAIL;
+
+ *handle = HandleTable.Alloc(watcher);
+ return HRESULT.S_OK;
+#else
+ // TODO: there is no registry on non-Windows platforms; a future settings
+ // abstraction (see the ZuneDBApi registry abstraction work) should back this.
+ return HRESULT.E_NOTIMPL;
+#endif
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpRegRevokeNotifyChangeKey")]
+ public static HRESULT SpRegRevokeNotifyChangeKey(IntPtr handle)
+ {
+ if (handle == IntPtr.Zero)
+ return HRESULT.E_INVALIDARG;
+
+ HandleTable.Free(handle);
+ return HRESULT.S_OK;
+ }
+
+ // Real: invokes the supplied callback immediately on the calling thread. The
+ // "deferred" in the name refers to the *caller* having deferred it to this point --
+ // NativeApi.cs's own declaration takes the callback and its data with no scheduling
+ // parameters, so there is nothing to schedule against here.
+ [UnmanagedCallersOnly(EntryPoint = "SpCallDeferredInvokeProc")]
+ public static void SpCallDeferredInvokeProc(IntPtr pfnCallback, IntPtr pvCallbackData)
+ {
+ if (pfnCallback != IntPtr.Zero)
+ ((delegate* unmanaged)pfnCallback)(pvCallbackData);
+ }
+}
diff --git a/UIXrender/Subsystems/Os/Win32.cs b/UIXrender/Subsystems/Os/Win32.cs
new file mode 100644
index 0000000..9255347
--- /dev/null
+++ b/UIXrender/Subsystems/Os/Win32.cs
@@ -0,0 +1,86 @@
+#if WINDOWS
+using System;
+using System.Runtime.InteropServices;
+using System.Threading;
+
+namespace Microsoft.Iris.Render.Subsystems.Os;
+
+// Real Win32 entry points used by the platform-gated branches of SystemApi. These are OS
+// APIs (DPI, system metrics, registry notification), not graphics APIs -- see
+// logs/UIXrender/FullSurface.md, decision 2.
+internal static partial class Win32
+{
+ [LibraryImport("user32.dll")]
+ public static partial uint GetDpiForSystem();
+
+ [LibraryImport("user32.dll")]
+ public static partial int GetSystemMetrics(int nIndex);
+
+ [LibraryImport("advapi32.dll", EntryPoint = "RegOpenKeyExW", StringMarshalling = StringMarshalling.Utf16)]
+ public static partial int RegOpenKeyEx(IntPtr hKey, string subKey, uint options, uint samDesired, out IntPtr result);
+
+ [LibraryImport("advapi32.dll")]
+ public static partial int RegCloseKey(IntPtr hKey);
+
+ [LibraryImport("advapi32.dll", EntryPoint = "RegNotifyChangeKeyValue")]
+ public static partial int RegNotifyChangeKeyValue(IntPtr hKey, [MarshalAs(UnmanagedType.Bool)] bool watchSubtree, uint notifyFilter, IntPtr hEvent, [MarshalAs(UnmanagedType.Bool)] bool asynchronous);
+}
+
+// Backs SpRegNotifyChangeKey: opens the requested subkey, then parks a background thread
+// on a real RegNotifyChangeKeyValue event, re-arming after each notification (the Win32
+// API is one-shot) and invoking the caller's RegChangeCallback each time.
+internal sealed unsafe class RegistryChangeWatcher : IDisposable
+{
+ private const uint KEY_NOTIFY = 0x0010;
+ private const uint REG_NOTIFY_CHANGE_NAME = 0x00000001;
+ private const uint REG_NOTIFY_CHANGE_LAST_SET = 0x00000004;
+
+ private readonly IntPtr _key;
+ private readonly IntPtr _callback;
+ private readonly ManualResetEventSlim _stopping = new(false);
+ private readonly AutoResetEvent _changed = new(false);
+ private readonly Thread _thread;
+
+ private RegistryChangeWatcher(IntPtr key, IntPtr callback)
+ {
+ _key = key;
+ _callback = callback;
+ _thread = new Thread(Run) { IsBackground = true, Name = "UIXrender.RegistryChangeWatcher" };
+ _thread.Start();
+ }
+
+ public static RegistryChangeWatcher Create(IntPtr hkey, string path, IntPtr callback)
+ {
+ if (Win32.RegOpenKeyEx(hkey, path ?? string.Empty, 0, KEY_NOTIFY, out IntPtr key) != 0)
+ return null;
+
+ return new RegistryChangeWatcher(key, callback);
+ }
+
+ private void Run()
+ {
+ WaitHandle[] handles = [_changed, _stopping.WaitHandle];
+
+ while (!_stopping.IsSet)
+ {
+ if (Win32.RegNotifyChangeKeyValue(_key, watchSubtree: true, REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET, _changed.SafeWaitHandle.DangerousGetHandle(), asynchronous: true) != 0)
+ return;
+
+ if (WaitHandle.WaitAny(handles) != 0 || _stopping.IsSet)
+ return;
+
+ if (_callback != IntPtr.Zero)
+ ((delegate* unmanaged)_callback)();
+ }
+ }
+
+ public void Dispose()
+ {
+ _stopping.Set();
+ _thread.Join(TimeSpan.FromSeconds(1));
+ Win32.RegCloseKey(_key);
+ _changed.Dispose();
+ _stopping.Dispose();
+ }
+}
+#endif
diff --git a/UIXrender/Subsystems/Remote/RemoteServerConnection.cs b/UIXrender/Subsystems/Remote/RemoteServerConnection.cs
new file mode 100644
index 0000000..87d1934
--- /dev/null
+++ b/UIXrender/Subsystems/Remote/RemoteServerConnection.cs
@@ -0,0 +1,258 @@
+using System;
+using System.IO;
+using System.IO.Pipes;
+using System.Net;
+using System.Net.Sockets;
+using System.Runtime.InteropServices;
+using System.Threading;
+using Microsoft.Iris.Render.Engine;
+using Microsoft.Iris.Render.Interop;
+using Microsoft.Iris.Render.Interop.Protocol;
+
+namespace Microsoft.Iris.Render.Subsystems.Remote;
+
+// Backs the four SpRemote* exports (Protocol/EngineApi.cs) for the out-of-process
+// "RemoteChannel" path. Real, working transport over .NET sockets/named pipes -- TCP and
+// PIPE are genuine duplex byte streams so one connection object serves as both the
+// "send" and "receive" stream handle the managed side round-trips back to us; VC is
+// left unimplemented (E_NOTIMPL) since its meaning was never recovered from the
+// decompiled managed code (see logs/UIXrender/Architecture.md open question #2) -- not
+// guessed at further. Once connected, wire format matches the local channel: each frame
+// is a length-prefixed BufferInfo + payload, dispatched into the same
+// EngineService/ContextRegistry local channel uses, so remote and local contexts are
+// indistinguishable to the rest of the engine.
+//
+// Handle model (matters for correctness -- RemoteChannel.Connect releases both stream
+// handles right after ServerInit, then disposes the session later): CreateServerStreams
+// hands out *two distinct* GCHandles to one connection with a refcount of 2; ServerInit
+// adds a third (the session). Each SpObjectRelease / SpRemoteServerUninit frees its own
+// handle and decrements; the connection's sockets are torn down exactly once, when the
+// last reference goes. The receive callback is a pointer-free BufferReceivedHandler,
+// exactly like RenderThread's -- the native shim adapts a raw function pointer into one,
+// the managed-direct caller adapts its own delegate, and this class never sees a pointer.
+internal sealed class RemoteServerConnection : IDisposable
+{
+ private TcpListener _tcpListener;
+ private NamedPipeServerStream _pipe;
+ private UdpClient _udp;
+ private IPEndPoint _udpRemote;
+ private Stream _stream;
+ private ContextID _contextId;
+ private Thread _readThread;
+ private volatile bool _running;
+ private int _refCount;
+
+ public static HRESULT CreateServerStreams(TransportProtocol protocol, string sessionName, out IntPtr sendHandle, out IntPtr receiveHandle)
+ {
+ sendHandle = IntPtr.Zero;
+ receiveHandle = IntPtr.Zero;
+
+ var connection = new RemoteServerConnection();
+ switch (protocol)
+ {
+ case TransportProtocol.TCP:
+ connection._tcpListener = new TcpListener(IPAddress.Any, 0);
+ connection._tcpListener.Start();
+ break;
+ case TransportProtocol.PIPE:
+ connection._pipe = new NamedPipeServerStream(string.IsNullOrEmpty(sessionName) ? "UIXrender" : sessionName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
+ break;
+ case TransportProtocol.UDP:
+ connection._udp = new UdpClient(0);
+ break;
+ case TransportProtocol.VC:
+ default:
+ // TODO: "VC" transport semantics were never recovered -- see
+ // logs/UIXrender/Architecture.md open question #2. Not guessed at.
+ connection.Dispose();
+ return HRESULT.E_NOTIMPL;
+ }
+
+ // Two distinct handles to the one duplex connection (send + receive), so releasing
+ // each is a separate, non-double-freeing operation. See the handle-model comment.
+ connection._refCount = 2;
+ sendHandle = GCHandle.ToIntPtr(GCHandle.Alloc(connection, GCHandleType.Normal));
+ receiveHandle = GCHandle.ToIntPtr(GCHandle.Alloc(connection, GCHandleType.Normal));
+ return HRESULT.S_OK;
+ }
+
+ public static HRESULT WaitConnected(TransportProtocol protocol, IntPtr sendHandle)
+ {
+ if (sendHandle == IntPtr.Zero)
+ return HRESULT.E_INVALIDARG;
+
+ var connection = (RemoteServerConnection)GCHandle.FromIntPtr(sendHandle).Target!;
+ try
+ {
+ switch (protocol)
+ {
+ case TransportProtocol.TCP:
+ connection._stream = connection._tcpListener.AcceptTcpClient().GetStream();
+ break;
+ case TransportProtocol.PIPE:
+ connection._pipe.WaitForConnection();
+ connection._stream = connection._pipe;
+ break;
+ case TransportProtocol.UDP:
+ // First datagram received "connects" the client, matching TCP/PIPE's
+ // blocking-until-a-client-attaches semantics as closely as UDP allows.
+ var buffer = connection._udp.Receive(ref connection._udpRemote);
+ connection._udp.Connect(connection._udpRemote);
+ connection._stream = connection._udp.Client is { } sock ? new NetworkStream(sock) : null;
+ break;
+ default:
+ return HRESULT.E_NOTIMPL;
+ }
+ }
+ catch (SocketException)
+ {
+ return HRESULT.E_FAIL;
+ }
+ catch (IOException)
+ {
+ return HRESULT.E_FAIL;
+ }
+
+ return HRESULT.S_OK;
+ }
+
+ // onRemoteToLocal receives frames arriving from the peer; null means "drop them"
+ // (RemoteChannel connects without a receive callback -- it drives the wire in one
+ // direction only). Pointer-free by design: the caller adapts whatever callback
+ // representation it has (native function pointer, managed delegate) into this shape.
+ public static HRESULT ServerInit(IntPtr sendHandle, ContextID context, BufferReceivedHandler onRemoteToLocal, out IntPtr pSession)
+ {
+ pSession = IntPtr.Zero;
+ if (sendHandle == IntPtr.Zero)
+ return HRESULT.E_INVALIDARG;
+
+ var connection = (RemoteServerConnection)GCHandle.FromIntPtr(sendHandle).Target!;
+ if (connection._stream == null)
+ return HRESULT.E_FAIL;
+
+ connection._contextId = context;
+
+ // Local -> remote: anything sent to this context is framed onto the wire.
+ ContextRegistry.Register(context, connection.WriteFrame);
+
+ connection._running = true;
+ connection._readThread = new Thread(() => connection.ReadLoop(onRemoteToLocal)) { IsBackground = true, Name = "UIXrender.RemoteServerConnection" };
+ connection._readThread.Start();
+
+ Interlocked.Increment(ref connection._refCount);
+ pSession = GCHandle.ToIntPtr(GCHandle.Alloc(connection, GCHandleType.Normal));
+ return HRESULT.S_OK;
+ }
+
+ public static HRESULT ServerUninit(IntPtr pSession, bool forceShutdown, out ShutdownReason reason)
+ {
+ reason = ShutdownReason.SelfShutdown;
+ if (pSession == IntPtr.Zero)
+ return HRESULT.E_INVALIDARG;
+
+ // Stop the reader promptly (this is the "don't wait for a graceful peer shutdown"
+ // meaning of forceShutdown), then drop the session's reference.
+ if (GCHandle.FromIntPtr(pSession).Target is RemoteServerConnection connection)
+ connection._running = false;
+
+ ReleaseHandle(pSession);
+ return HRESULT.S_OK;
+ }
+
+ // Backs the managed-direct SpObjectRelease on a stream handle: drops one reference and
+ // frees that handle, disposing the connection only when the last reference goes.
+ public static void ReleaseHandle(IntPtr handle)
+ {
+ if (handle == IntPtr.Zero)
+ return;
+
+ GCHandle gc = GCHandle.FromIntPtr(handle);
+ var connection = gc.Target as RemoteServerConnection;
+ gc.Free();
+
+ if (connection != null && Interlocked.Decrement(ref connection._refCount) == 0)
+ connection.Dispose();
+ }
+
+ private unsafe void WriteFrame(ContextID source, RENDERHANDLE bufferHandle, BufferFlags flags, ReadOnlySpan data)
+ {
+ if (_stream == null)
+ return;
+
+ var info = new BufferInfo
+ {
+ idContextSrc = source,
+ idContextDest = _contextId,
+ idBuffer = bufferHandle,
+ nFlags = flags,
+ cbSizeBuffer = (uint)data.Length,
+ };
+
+ Span header = stackalloc byte[sizeof(uint) + sizeof(BufferInfo)];
+ BitConverter.TryWriteBytes(header, (uint)sizeof(BufferInfo) + (uint)data.Length);
+ fixed (byte* pHeader = header)
+ *(BufferInfo*)(pHeader + sizeof(uint)) = info;
+
+ lock (this)
+ {
+ _stream.Write(header);
+ _stream.Write(data);
+ _stream.Flush();
+ }
+ }
+
+ private unsafe void ReadLoop(BufferReceivedHandler onRemoteToLocal)
+ {
+ var lengthBuffer = new byte[sizeof(uint)];
+ var infoBuffer = new byte[sizeof(BufferInfo)];
+
+ try
+ {
+ while (_running)
+ {
+ if (!ReadExact(lengthBuffer))
+ break;
+ uint totalSize = BitConverter.ToUInt32(lengthBuffer);
+ if (!ReadExact(infoBuffer))
+ break;
+
+ BufferInfo info;
+ fixed (byte* p = infoBuffer)
+ info = *(BufferInfo*)p;
+
+ uint payloadSize = totalSize - (uint)sizeof(BufferInfo);
+ var payload = payloadSize > 0 ? new byte[payloadSize] : Array.Empty();
+ if (payloadSize > 0 && !ReadExact(payload))
+ break;
+
+ onRemoteToLocal?.Invoke(info.idContextSrc, info.idBuffer, info.nFlags, payload);
+ }
+ }
+ catch (IOException) { }
+ catch (ObjectDisposedException) { }
+ }
+
+ private bool ReadExact(byte[] buffer)
+ {
+ int offset = 0;
+ while (offset < buffer.Length)
+ {
+ int read = _stream.Read(buffer, offset, buffer.Length - offset);
+ if (read <= 0)
+ return false;
+ offset += read;
+ }
+ return true;
+ }
+
+ public void Dispose()
+ {
+ _running = false;
+ ContextRegistry.Unregister(_contextId);
+ _stream?.Dispose();
+ _tcpListener?.Stop();
+ _pipe?.Dispose();
+ _udp?.Dispose();
+ _readThread?.Join(TimeSpan.FromSeconds(1));
+ }
+}
diff --git a/UIXrender/Subsystems/Schema/SchemaApi.cs b/UIXrender/Subsystems/Schema/SchemaApi.cs
new file mode 100644
index 0000000..efede3e
--- /dev/null
+++ b/UIXrender/Subsystems/Schema/SchemaApi.cs
@@ -0,0 +1,612 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using Microsoft.Iris.Interop;
+using Microsoft.Iris.Render.Engine;
+using Microsoft.Iris.Render.Interop;
+
+namespace Microsoft.Iris.Render.Subsystems.Schema;
+
+// [UnmanagedCallersOnly] exports for the "native reflection / type-schema" family in
+// UIX/Microsoft/Iris/OS/NativeApi.cs (~48 entry points). See SchemaModel.cs for why these
+// project CLR types instead of the original's C++ ones.
+//
+// Two marshaling conventions to keep straight, both read off the original DllImport
+// declarations rather than assumed:
+// * every `out bool` is a 4-byte Win32 BOOL (no [MarshalAs] override on the managed
+// side), so it's `int*` here;
+// * every `uint[] IDs` is a caller-allocated array, so it's `uint*` + count here.
+public static unsafe class SchemaApi
+{
+ private static uint OK => (uint)HRESULT.S_OK.hr;
+ private static uint Fail => unchecked((uint)HRESULT.E_FAIL.hr);
+ private static uint InvalidArg => unchecked((uint)HRESULT.E_INVALIDARG.hr);
+
+ // ---- schema-level ----------------------------------------------------------------
+
+ [UnmanagedCallersOnly(EntryPoint = "SpSetSchemaID")]
+ public static uint SpSetSchemaID(IntPtr schema, ushort id)
+ {
+ if (!HandleTable.TryGet(schema, out SchemaRegistration registration))
+ return InvalidArg;
+ registration.ID = id;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryTypeCount")]
+ public static uint SpQueryTypeCount(IntPtr schema, uint* typeCount)
+ {
+ if (typeCount == null || !HandleTable.TryGet(schema, out SchemaRegistration registration))
+ return InvalidArg;
+ *typeCount = (uint)registration.Types.Count;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryEnumCount")]
+ public static uint SpQueryEnumCount(IntPtr schema, uint* enumCount)
+ {
+ if (enumCount == null || !HandleTable.TryGet(schema, out SchemaRegistration registration))
+ return InvalidArg;
+ *enumCount = (uint)registration.Enums.Count;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpGetTypeSchema")]
+ public static uint SpGetTypeSchema(IntPtr schema, uint index, IntPtr* type, uint* id)
+ {
+ if (type == null || id == null || !HandleTable.TryGet(schema, out SchemaRegistration registration))
+ return InvalidArg;
+ if (index >= registration.Types.Count)
+ return InvalidArg;
+
+ TypeSchema typeSchema = registration.Types[(int)index];
+ *type = HandleTable.Alloc(typeSchema);
+ *id = typeSchema.ID;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpGetEnumSchema")]
+ public static uint SpGetEnumSchema(IntPtr schema, uint index, IntPtr* enumType, uint* id)
+ {
+ if (enumType == null || id == null || !HandleTable.TryGet(schema, out SchemaRegistration registration))
+ return InvalidArg;
+ if (index >= registration.Enums.Count)
+ return InvalidArg;
+
+ EnumSchema enumSchema = registration.Enums[(int)index];
+ *enumType = HandleTable.Alloc(enumSchema);
+ *id = enumSchema.ID;
+ return OK;
+ }
+
+ // ---- type-level ------------------------------------------------------------------
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryTypeName")]
+ public static uint SpQueryTypeName(IntPtr typeSchema, char** name)
+ {
+ if (name == null || !HandleTable.TryGet(typeSchema, out TypeSchema schema))
+ return InvalidArg;
+ *name = NativeString.InternUni(schema.Name);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpIsRuntimeImmutable")]
+ public static uint SpIsRuntimeImmutable(IntPtr typeSchema, int* isRuntimeImmutable)
+ {
+ if (isRuntimeImmutable == null || !HandleTable.TryGet(typeSchema, out TypeSchema schema))
+ return InvalidArg;
+ *isRuntimeImmutable = schema.IsRuntimeImmutable ? 1 : 0;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryBaseType")]
+ public static uint SpQueryBaseType(IntPtr typeSchema, uint* baseTypeID)
+ {
+ if (baseTypeID == null || !HandleTable.TryGet(typeSchema, out TypeSchema schema))
+ return InvalidArg;
+ *baseTypeID = SchemaRegistry.GetTypeId(schema.Type.BaseType);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpGetMarshalAs")]
+ public static uint SpGetMarshalAs(IntPtr typeSchema, uint* interopEquivalent)
+ {
+ if (interopEquivalent == null || !HandleTable.TryGet(typeSchema, out TypeSchema schema))
+ return InvalidArg;
+ *interopEquivalent = SchemaRegistry.GetMarshalAs(schema.Type);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryConstructorCount")]
+ public static uint SpQueryConstructorCount(IntPtr typeSchema, uint* count)
+ {
+ if (count == null || !HandleTable.TryGet(typeSchema, out TypeSchema schema))
+ return InvalidArg;
+ *count = (uint)schema.Constructors.Length;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpGetConstructorSchema")]
+ public static uint SpGetConstructorSchema(IntPtr typeSchema, uint index, IntPtr* constructor, uint* id)
+ {
+ if (constructor == null || id == null || !HandleTable.TryGet(typeSchema, out TypeSchema schema))
+ return InvalidArg;
+ if (index >= schema.Constructors.Length)
+ return InvalidArg;
+
+ *constructor = HandleTable.Alloc(schema.Constructors[index]);
+ *id = index;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryPropertyCount")]
+ public static uint SpQueryPropertyCount(IntPtr typeSchema, uint* count)
+ {
+ if (count == null || !HandleTable.TryGet(typeSchema, out TypeSchema schema))
+ return InvalidArg;
+ *count = (uint)schema.Properties.Length;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpGetPropertySchema")]
+ public static uint SpGetPropertySchema(IntPtr typeSchema, uint index, IntPtr* property, uint* id)
+ {
+ if (property == null || id == null || !HandleTable.TryGet(typeSchema, out TypeSchema schema))
+ return InvalidArg;
+ if (index >= schema.Properties.Length)
+ return InvalidArg;
+
+ *property = HandleTable.Alloc(schema.Properties[index]);
+ *id = index;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryMethodCount")]
+ public static uint SpQueryMethodCount(IntPtr typeSchema, uint* count)
+ {
+ if (count == null || !HandleTable.TryGet(typeSchema, out TypeSchema schema))
+ return InvalidArg;
+ *count = (uint)schema.Methods.Length;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpGetMethodSchema")]
+ public static uint SpGetMethodSchema(IntPtr typeSchema, uint index, IntPtr* method, uint* id)
+ {
+ if (method == null || id == null || !HandleTable.TryGet(typeSchema, out TypeSchema schema))
+ return InvalidArg;
+ if (index >= schema.Methods.Length)
+ return InvalidArg;
+
+ *method = HandleTable.Alloc(schema.Methods[index]);
+ *id = index;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryEventCount")]
+ public static uint SpQueryEventCount(IntPtr typeSchema, uint* count)
+ {
+ if (count == null || !HandleTable.TryGet(typeSchema, out TypeSchema schema))
+ return InvalidArg;
+ *count = (uint)schema.Events.Length;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpGetEventSchema")]
+ public static uint SpGetEventSchema(IntPtr typeSchema, uint index, IntPtr* eventObj, uint* id)
+ {
+ if (eventObj == null || id == null || !HandleTable.TryGet(typeSchema, out TypeSchema schema))
+ return InvalidArg;
+ if (index >= schema.Events.Length)
+ return InvalidArg;
+
+ *eventObj = HandleTable.Alloc(schema.Events[index]);
+ *id = index;
+ return OK;
+ }
+
+ // ---- construction / invocation ---------------------------------------------------
+
+ [UnmanagedCallersOnly(EntryPoint = "SpInvokeConstructor")]
+ public static uint SpInvokeConstructor(IntPtr typeSchema, uint constructorID, UIXVariant* parameters, uint parameterCount, IntPtr* nativeObject)
+ {
+ if (nativeObject == null || !HandleTable.TryGet(typeSchema, out TypeSchema schema))
+ return InvalidArg;
+ if (constructorID >= schema.Constructors.Length)
+ return InvalidArg;
+
+ try
+ {
+ object instance = schema.Constructors[constructorID].Invoke(ToObjects(parameters, parameterCount));
+ *nativeObject = HandleTable.Alloc(new SchemaObject(instance, schema));
+ return OK;
+ }
+ catch (TargetInvocationException)
+ {
+ return Fail;
+ }
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpGetPropertyValue")]
+ public static uint SpGetPropertyValue(IntPtr typeSchema, IntPtr nativeObject, uint propertyID, UIXVariant* propertyValue)
+ {
+ if (propertyValue == null || !HandleTable.TryGet(typeSchema, out TypeSchema schema))
+ return InvalidArg;
+ if (propertyID >= schema.Properties.Length)
+ return InvalidArg;
+
+ object instance = HandleTable.Get(nativeObject)?.Instance;
+ try
+ {
+ *propertyValue = UIXVariant.FromObject(schema.Properties[propertyID].GetValue(instance));
+ return OK;
+ }
+ catch (Exception e) when (e is TargetInvocationException or NotSupportedException)
+ {
+ return Fail;
+ }
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpSetPropertyValue")]
+ public static uint SpSetPropertyValue(IntPtr typeSchema, IntPtr nativeObject, uint propertyID, UIXVariant* propertyValue)
+ {
+ if (propertyValue == null || !HandleTable.TryGet(typeSchema, out TypeSchema schema))
+ return InvalidArg;
+ if (propertyID >= schema.Properties.Length)
+ return InvalidArg;
+
+ object instance = HandleTable.Get(nativeObject)?.Instance;
+ PropertyInfo property = schema.Properties[propertyID];
+ try
+ {
+ property.SetValue(instance, Coerce(propertyValue->ToObject(), property.PropertyType));
+ return OK;
+ }
+ catch (Exception e) when (e is TargetInvocationException or NotSupportedException or ArgumentException)
+ {
+ return Fail;
+ }
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpInvokeMethod")]
+ public static uint SpInvokeMethod(IntPtr typeSchema, IntPtr nativeObject, uint methodID, UIXVariant* parameters, uint parameterCount, UIXVariant* returnValue)
+ {
+ if (returnValue == null || !HandleTable.TryGet(typeSchema, out TypeSchema schema))
+ return InvalidArg;
+ if (methodID >= schema.Methods.Length)
+ return InvalidArg;
+
+ object instance = HandleTable.Get(nativeObject)?.Instance;
+ MethodInfo method = schema.Methods[methodID];
+ try
+ {
+ object[] args = ToObjects(parameters, parameterCount);
+ ParameterInfo[] expected = method.GetParameters();
+ for (int i = 0; i < args.Length && i < expected.Length; i++)
+ args[i] = Coerce(args[i], expected[i].ParameterType);
+
+ object result = method.Invoke(instance, args);
+ *returnValue = method.ReturnType == typeof(void) ? UIXVariant.Empty : UIXVariant.FromObject(result);
+ return OK;
+ }
+ catch (Exception e) when (e is TargetInvocationException or NotSupportedException or ArgumentException)
+ {
+ return Fail;
+ }
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpInvokeToString")]
+ public static uint SpInvokeToString(IntPtr typeSchema, IntPtr nativeObject, IntPtr* value)
+ {
+ if (value == null)
+ return InvalidArg;
+
+ object instance = HandleTable.Get(nativeObject)?.Instance;
+ *value = (IntPtr)NativeString.AllocUni(instance?.ToString() ?? string.Empty);
+ return OK;
+ }
+
+ // ---- constructor-schema-level ----------------------------------------------------
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryConstructorParameterCount")]
+ public static uint SpQueryConstructorParameterCount(IntPtr constructorSchema, uint* count)
+ {
+ if (count == null || !HandleTable.TryGet(constructorSchema, out ConstructorInfo constructor))
+ return InvalidArg;
+ *count = (uint)constructor.GetParameters().Length;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpGetConstructorParameterTypes")]
+ public static uint SpGetConstructorParameterTypes(IntPtr constructorSchema, uint* ids, uint count)
+ {
+ if (ids == null || !HandleTable.TryGet(constructorSchema, out ConstructorInfo constructor))
+ return InvalidArg;
+ return WriteParameterTypes(constructor.GetParameters(), ids, count);
+ }
+
+ // ---- property-schema-level -------------------------------------------------------
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryPropertyName")]
+ public static uint SpQueryPropertyName(IntPtr propertySchema, char** name)
+ {
+ if (name == null || !HandleTable.TryGet(propertySchema, out PropertyInfo property))
+ return InvalidArg;
+ *name = NativeString.InternUni(property.Name);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryPropertyType")]
+ public static uint SpQueryPropertyType(IntPtr propertySchema, uint* type)
+ {
+ if (type == null || !HandleTable.TryGet(propertySchema, out PropertyInfo property))
+ return InvalidArg;
+ *type = SchemaRegistry.GetTypeId(property.PropertyType);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryPropertyCanRead")]
+ public static uint SpQueryPropertyCanRead(IntPtr propertySchema, int* canRead)
+ {
+ if (canRead == null || !HandleTable.TryGet(propertySchema, out PropertyInfo property))
+ return InvalidArg;
+ *canRead = property.CanRead ? 1 : 0;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryPropertyCanWrite")]
+ public static uint SpQueryPropertyCanWrite(IntPtr propertySchema, int* canWrite)
+ {
+ if (canWrite == null || !HandleTable.TryGet(propertySchema, out PropertyInfo property))
+ return InvalidArg;
+ *canWrite = property.CanWrite ? 1 : 0;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryPropertyIsStatic")]
+ public static uint SpQueryPropertyIsStatic(IntPtr propertySchema, int* isStatic)
+ {
+ if (isStatic == null || !HandleTable.TryGet(propertySchema, out PropertyInfo property))
+ return InvalidArg;
+ *isStatic = (property.GetMethod ?? property.SetMethod)?.IsStatic == true ? 1 : 0;
+ return OK;
+ }
+
+ // A property "notifies on change" if its declaring type raises a change event for it.
+ // The CLR convention for exactly that is INotifyPropertyChanged, or a per-property
+ // "Changed" event -- both checked here rather than assuming one or the other.
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryPropertyNotifiesOnChange")]
+ [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "Declaring types originate from runtime-loaded assemblies (SpLoadDll); see SchemaRegistration.FromAssembly.")]
+ public static uint SpQueryPropertyNotifiesOnChange(IntPtr propertySchema, int* notifiesOnChange)
+ {
+ if (notifiesOnChange == null || !HandleTable.TryGet(propertySchema, out PropertyInfo property))
+ return InvalidArg;
+
+ Type declaring = property.DeclaringType;
+ bool notifies =
+ typeof(System.ComponentModel.INotifyPropertyChanged).IsAssignableFrom(declaring) ||
+ declaring?.GetEvent(property.Name + "Changed") != null;
+
+ *notifiesOnChange = notifies ? 1 : 0;
+ return OK;
+ }
+
+ // ---- method-schema-level ---------------------------------------------------------
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryMethodName")]
+ public static uint SpQueryMethodName(IntPtr methodSchema, char** name)
+ {
+ if (name == null || !HandleTable.TryGet(methodSchema, out MethodInfo method))
+ return InvalidArg;
+ *name = NativeString.InternUni(method.Name);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryMethodReturnType")]
+ public static uint SpQueryMethodReturnType(IntPtr methodSchema, uint* type)
+ {
+ if (type == null || !HandleTable.TryGet(methodSchema, out MethodInfo method))
+ return InvalidArg;
+ *type = SchemaRegistry.GetTypeId(method.ReturnType);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryMethodParameterCount")]
+ public static uint SpQueryMethodParameterCount(IntPtr methodSchema, uint* count)
+ {
+ if (count == null || !HandleTable.TryGet(methodSchema, out MethodInfo method))
+ return InvalidArg;
+ *count = (uint)method.GetParameters().Length;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpGetMethodParameterTypes")]
+ public static uint SpGetMethodParameterTypes(IntPtr methodSchema, uint* ids, uint count)
+ {
+ if (ids == null || !HandleTable.TryGet(methodSchema, out MethodInfo method))
+ return InvalidArg;
+ return WriteParameterTypes(method.GetParameters(), ids, count);
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryMethodIsStatic")]
+ public static uint SpQueryMethodIsStatic(IntPtr methodSchema, int* isStatic)
+ {
+ if (isStatic == null || !HandleTable.TryGet(methodSchema, out MethodInfo method))
+ return InvalidArg;
+ *isStatic = method.IsStatic ? 1 : 0;
+ return OK;
+ }
+
+ // ---- event-schema-level ----------------------------------------------------------
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryEventName")]
+ public static uint SpQueryEventName(IntPtr eventSchema, char** name)
+ {
+ if (name == null || !HandleTable.TryGet(eventSchema, out EventInfo eventInfo))
+ return InvalidArg;
+ *name = NativeString.InternUni(eventInfo.Name);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryEventIsStatic")]
+ public static uint SpQueryEventIsStatic(IntPtr eventSchema, int* isStatic)
+ {
+ if (isStatic == null || !HandleTable.TryGet(eventSchema, out EventInfo eventInfo))
+ return InvalidArg;
+ *isStatic = (eventInfo.AddMethod ?? eventInfo.RemoveMethod)?.IsStatic == true ? 1 : 0;
+ return OK;
+ }
+
+ // ---- object lifetime / identity --------------------------------------------------
+
+ [UnmanagedCallersOnly(EntryPoint = "SpAddRefExternalObject")]
+ public static void SpAddRefExternalObject(IntPtr nativeObject)
+ {
+ if (HandleTable.TryGet(nativeObject, out SchemaObject obj))
+ obj.RefCount++;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpReleaseExternalObject")]
+ public static void SpReleaseExternalObject(IntPtr nativeObject)
+ {
+ if (!HandleTable.TryGet(nativeObject, out SchemaObject obj))
+ return;
+
+ if (--obj.RefCount <= 0)
+ HandleTable.Free(nativeObject);
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpGetTypeID")]
+ public static uint SpGetTypeID(IntPtr nativeObject, uint* typeID)
+ {
+ if (typeID == null || !HandleTable.TryGet(nativeObject, out SchemaObject obj))
+ return InvalidArg;
+ *typeID = SchemaRegistry.GetTypeId(obj.Schema.Type);
+ return OK;
+ }
+
+ // "Query for the interface this type marshals as" -- the CLR analogue of QueryInterface
+ // is a cast, so this succeeds when the object really does implement the requested
+ // registered type and hands back the same object handle (identity is preserved by a
+ // reference cast, exactly as QueryInterface on a C++ object would).
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryForMarshalAsInterface")]
+ public static uint SpQueryForMarshalAsInterface(IntPtr nativeObject, uint interfaceID, IntPtr* interfaceImpl)
+ {
+ if (interfaceImpl == null || !HandleTable.TryGet(nativeObject, out SchemaObject obj))
+ return InvalidArg;
+
+ Type requested = SchemaRegistry.GetType(interfaceID);
+ if (requested == null || !requested.IsInstanceOfType(obj.Instance))
+ {
+ *interfaceImpl = IntPtr.Zero;
+ return Fail;
+ }
+
+ obj.RefCount++;
+ *interfaceImpl = nativeObject;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpSetStateCache")]
+ public static void SpSetStateCache(IntPtr nativeObject, ulong state)
+ {
+ if (HandleTable.TryGet(nativeObject, out SchemaObject obj))
+ obj.StateCache = state;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpGetStateCache")]
+ public static void SpGetStateCache(IntPtr nativeObject, ulong* state)
+ {
+ if (state == null)
+ return;
+ *state = HandleTable.TryGet(nativeObject, out SchemaObject obj) ? obj.StateCache : 0UL;
+ }
+
+ // ---- enum-schema-level -----------------------------------------------------------
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryEnumName")]
+ public static uint SpQueryEnumName(IntPtr nativeObject, char** name)
+ {
+ if (name == null || !HandleTable.TryGet(nativeObject, out EnumSchema schema))
+ return InvalidArg;
+ *name = NativeString.InternUni(schema.Name);
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryEnumIsFlags")]
+ public static uint SpQueryEnumIsFlags(IntPtr nativeObject, int* isFlags)
+ {
+ if (isFlags == null || !HandleTable.TryGet(nativeObject, out EnumSchema schema))
+ return InvalidArg;
+ *isFlags = schema.IsFlags ? 1 : 0;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpQueryEnumValueCount")]
+ public static uint SpQueryEnumValueCount(IntPtr nativeObject, uint* valueCount)
+ {
+ if (valueCount == null || !HandleTable.TryGet(nativeObject, out EnumSchema schema))
+ return InvalidArg;
+ *valueCount = (uint)schema.Names.Length;
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpGetEnumNameValue")]
+ public static uint SpGetEnumNameValue(IntPtr nativeObject, uint index, char** name, int* value)
+ {
+ if (name == null || value == null || !HandleTable.TryGet(nativeObject, out EnumSchema schema))
+ return InvalidArg;
+ if (index >= schema.Names.Length)
+ return InvalidArg;
+
+ *name = NativeString.InternUni(schema.Names[index]);
+ *value = schema.Values[index];
+ return OK;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "SpInvokeEnumToString")]
+ public static uint SpInvokeEnumToString(IntPtr nativeObject, int value, IntPtr* result)
+ {
+ if (result == null || !HandleTable.TryGet(nativeObject, out EnumSchema schema))
+ return InvalidArg;
+
+ string text = Enum.ToObject(schema.Type, value).ToString();
+ *result = (IntPtr)NativeString.AllocUni(text);
+ return OK;
+ }
+
+ // ---- helpers ---------------------------------------------------------------------
+
+ private static object[] ToObjects(UIXVariant* parameters, uint count)
+ {
+ if (parameters == null || count == 0)
+ return Array.Empty