From 679d2bb90222709879f1e25ec0c177e06a07473f Mon Sep 17 00:00:00 2001 From: Yoshi Askharoun Date: Wed, 22 Jul 2026 18:34:57 -0500 Subject: [PATCH] More engine API work --- .github/workflows/uixrender-ci.yml | 60 +- MicrosoftIris.sln | 15 + Tests/UIXrender.Engine.Tests/Program.cs | 199 ++++++ .../UIXrender.Engine.Tests.csproj | 11 + .../Iris/Render/Protocol/EngineApi.cs | 229 +++++++ .../Iris/Render/Protocol/RenderPort.cs | 2 +- UIX.RenderApi/UIX.RenderApi.csproj | 6 + UIXInterop/NativeString.cs | 23 + UIXrender/Engine/BufferReceivedHandler.cs | 9 + UIXrender/Engine/ContextRegistry.cs | 32 +- UIXrender/Engine/EngineService.cs | 95 +++ UIXrender/Engine/HandleTable.cs | 42 ++ UIXrender/Engine/IRenderThreadHandle.cs | 9 + UIXrender/Engine/RenderThread.cs | 55 +- UIXrender/Interop/Com/ComVtable.cs | 47 ++ UIXrender/Interop/Drawing/Color.cs | 21 + UIXrender/Interop/Drawing/ColorF.cs | 13 + UIXrender/Interop/Drawing/Point.cs | 11 + UIXrender/Interop/Drawing/Rectangle.cs | 24 + UIXrender/Interop/Drawing/Size.cs | 17 + UIXrender/Interop/Drawing/SizeF.cs | 11 + UIXrender/Interop/EngineApi.cs | 176 ++++- UIXrender/Interop/Extensions/ImageTypes.cs | 84 +++ UIXrender/Interop/Extensions/SoundTypes.cs | 47 ++ .../Interop/Protocol/RemoteChannelTypes.cs | 23 + UIXrender/Interop/Text/RasterizeRunPacket.cs | 42 ++ .../Interop/Text/TextMeasureParamsData.cs | 44 ++ UIXrender/Interop/Text/TextStyleData.cs | 38 ++ UIXrender/Interop/UIXVariant.cs | 115 ++++ .../Interop/XmlLite/NativeXmlNodeType.cs | 17 + UIXrender/Subsystems/Assets/BitmapStore.cs | 95 +++ UIXrender/Subsystems/Assets/ExtensionsApi.cs | 150 +++++ UIXrender/Subsystems/Assets/WaveParser.cs | 65 ++ UIXrender/Subsystems/Data/DataApi.cs | 199 ++++++ UIXrender/Subsystems/Data/DataModel.cs | 111 ++++ UIXrender/Subsystems/Graphics/FormApi.cs | 22 + UIXrender/Subsystems/Lists/UIXList.cs | 130 ++++ UIXrender/Subsystems/Lists/UIXListApi.cs | 237 +++++++ UIXrender/Subsystems/Memory/MemoryApi.cs | 42 ++ UIXrender/Subsystems/Os/DownloadApi.cs | 145 +++++ UIXrender/Subsystems/Os/MarshalApi.cs | 144 +++++ UIXrender/Subsystems/Os/ModuleApi.cs | 168 +++++ UIXrender/Subsystems/Os/NativeServices.cs | 111 ++++ UIXrender/Subsystems/Os/SystemApi.cs | 177 +++++ UIXrender/Subsystems/Os/Win32.cs | 86 +++ .../Remote/RemoteServerConnection.cs | 258 ++++++++ UIXrender/Subsystems/Schema/SchemaApi.cs | 612 ++++++++++++++++++ UIXrender/Subsystems/Schema/SchemaModel.cs | 154 +++++ UIXrender/Subsystems/Schema/SchemaRegistry.cs | 75 +++ UIXrender/Subsystems/Text/RichTextApi.cs | 326 ++++++++++ UIXrender/Subsystems/Text/RichTextObject.cs | 259 ++++++++ UIXrender/Subsystems/Text/SimpleTextApi.cs | 86 +++ UIXrender/Subsystems/Text/TextMetrics.cs | 81 +++ UIXrender/Subsystems/Tracing/TracingApi.cs | 15 +- UIXrender/Subsystems/Tracing/TracingState.cs | 60 +- UIXrender/Subsystems/Xml/XmlLiteApi.cs | 185 ++++++ UIXrender/Subsystems/Xml/XmlLiteReader.cs | 94 +++ UIXrender/UIXrender.csproj | 16 + 58 files changed, 5540 insertions(+), 80 deletions(-) create mode 100644 Tests/UIXrender.Engine.Tests/Program.cs create mode 100644 Tests/UIXrender.Engine.Tests/UIXrender.Engine.Tests.csproj create mode 100644 UIXrender/Engine/BufferReceivedHandler.cs create mode 100644 UIXrender/Engine/EngineService.cs create mode 100644 UIXrender/Engine/HandleTable.cs create mode 100644 UIXrender/Engine/IRenderThreadHandle.cs create mode 100644 UIXrender/Interop/Com/ComVtable.cs create mode 100644 UIXrender/Interop/Drawing/Color.cs create mode 100644 UIXrender/Interop/Drawing/ColorF.cs create mode 100644 UIXrender/Interop/Drawing/Point.cs create mode 100644 UIXrender/Interop/Drawing/Rectangle.cs create mode 100644 UIXrender/Interop/Drawing/Size.cs create mode 100644 UIXrender/Interop/Drawing/SizeF.cs create mode 100644 UIXrender/Interop/Extensions/ImageTypes.cs create mode 100644 UIXrender/Interop/Extensions/SoundTypes.cs create mode 100644 UIXrender/Interop/Protocol/RemoteChannelTypes.cs create mode 100644 UIXrender/Interop/Text/RasterizeRunPacket.cs create mode 100644 UIXrender/Interop/Text/TextMeasureParamsData.cs create mode 100644 UIXrender/Interop/Text/TextStyleData.cs create mode 100644 UIXrender/Interop/UIXVariant.cs create mode 100644 UIXrender/Interop/XmlLite/NativeXmlNodeType.cs create mode 100644 UIXrender/Subsystems/Assets/BitmapStore.cs create mode 100644 UIXrender/Subsystems/Assets/ExtensionsApi.cs create mode 100644 UIXrender/Subsystems/Assets/WaveParser.cs create mode 100644 UIXrender/Subsystems/Data/DataApi.cs create mode 100644 UIXrender/Subsystems/Data/DataModel.cs create mode 100644 UIXrender/Subsystems/Graphics/FormApi.cs create mode 100644 UIXrender/Subsystems/Lists/UIXList.cs create mode 100644 UIXrender/Subsystems/Lists/UIXListApi.cs create mode 100644 UIXrender/Subsystems/Memory/MemoryApi.cs create mode 100644 UIXrender/Subsystems/Os/DownloadApi.cs create mode 100644 UIXrender/Subsystems/Os/MarshalApi.cs create mode 100644 UIXrender/Subsystems/Os/ModuleApi.cs create mode 100644 UIXrender/Subsystems/Os/NativeServices.cs create mode 100644 UIXrender/Subsystems/Os/SystemApi.cs create mode 100644 UIXrender/Subsystems/Os/Win32.cs create mode 100644 UIXrender/Subsystems/Remote/RemoteServerConnection.cs create mode 100644 UIXrender/Subsystems/Schema/SchemaApi.cs create mode 100644 UIXrender/Subsystems/Schema/SchemaModel.cs create mode 100644 UIXrender/Subsystems/Schema/SchemaRegistry.cs create mode 100644 UIXrender/Subsystems/Text/RichTextApi.cs create mode 100644 UIXrender/Subsystems/Text/RichTextObject.cs create mode 100644 UIXrender/Subsystems/Text/SimpleTextApi.cs create mode 100644 UIXrender/Subsystems/Text/TextMetrics.cs create mode 100644 UIXrender/Subsystems/Xml/XmlLiteApi.cs create mode 100644 UIXrender/Subsystems/Xml/XmlLiteReader.cs 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(); + + var values = new object[count]; + for (uint i = 0; i < count; i++) + values[i] = parameters[i].ToObject(); + return values; + } + + // Reflection needs the exact declared type (an int variant assigned to a `long` + // parameter would otherwise throw), and UIXVariant only carries a handful of + // primitive shapes -- so widen/narrow to the target here rather than at every call site. + private static object Coerce(object value, Type target) + { + if (value == null || target.IsInstanceOfType(value)) + return value; + if (target.IsEnum) + return Enum.ToObject(target, value); + return Convert.ChangeType(value, target); + } + + private static uint WriteParameterTypes(ParameterInfo[] parameters, uint* ids, uint count) + { + uint writable = Math.Min(count, (uint)parameters.Length); + for (uint i = 0; i < writable; i++) + ids[i] = SchemaRegistry.GetTypeId(parameters[i].ParameterType); + return OK; + } +} diff --git a/UIXrender/Subsystems/Schema/SchemaModel.cs b/UIXrender/Subsystems/Schema/SchemaModel.cs new file mode 100644 index 0000000..10ee1d5 --- /dev/null +++ b/UIXrender/Subsystems/Schema/SchemaModel.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using Microsoft.Iris.Render.Interop; + +namespace Microsoft.Iris.Render.Subsystems.Schema; + +// The managed model behind the ~50 Sp{Query,Get,Invoke}* "native reflection" exports in +// UIX/Microsoft/Iris/OS/NativeApi.cs. +// +// Design decision (deliberate substitution, not a stub -- see logs/UIXrender/FullSurface.md): +// the original exports projected a *C++* type system (the native gadget classes compiled +// into UIXrender.dll) so that markup could bind to them by name. Those C++ classes do not +// exist in this reimplementation and never will. What this layer does instead is project +// ordinary **CLR** types through the exact same export surface, using System.Reflection. +// Every accessor keeps its original signature and semantics ("give me the Nth property of +// this type schema, and its ID"), so the managed caller cannot tell the difference -- but +// the thing on the other side is a registered .NET type rather than a C++ one. +// +// IDs are stable per-schema indices, not pointers: the managed side round-trips them back +// to us (SpGetPropertyValue(typeSchema, obj, propertyID, ...)), so they only need to be +// unique and stable within one schema, which an array index is. + +internal sealed class TypeSchema +{ + // The trimmer/AOT compiler can't see which members are needed, because the types come + // from assemblies loaded at runtime via SpLoadDll -- that is the entire point of this + // subsystem. Annotating the parameter keeps every member of anything that reaches + // here rooted, instead of silently trimming the properties/methods markup binds to. + public TypeSchema([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type type, uint id) + { + Type = type; + ID = id; + + // BindingFlags deliberately include static + instance public members only: + // the original surface exposes IsStatic as a queryable trait of properties, + // methods and events, which only makes sense if both kinds are enumerated. + const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static; + + Properties = type.GetProperties(flags); + Methods = type.GetMethods(flags).Where(m => !m.IsSpecialName).ToArray(); + Events = type.GetEvents(flags); + Constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); + } + + public Type Type { get; } + public uint ID { get; } + public PropertyInfo[] Properties { get; } + public MethodInfo[] Methods { get; } + public EventInfo[] Events { get; } + public ConstructorInfo[] Constructors { get; } + + public string Name => Type.Name; + + // "Runtime immutable" in the original means a value that can never change once + // constructed -- the closest faithful CLR reading is a type with no writable + // instance state at all. + public bool IsRuntimeImmutable => + Type.IsPrimitive || Type == typeof(string) || Properties.All(p => !p.CanWrite); +} + +internal sealed class EnumSchema +{ + public EnumSchema(Type type, uint id) + { + Type = type; + ID = id; + Names = Enum.GetNames(type); + + // GetValuesAsUnderlyingType rather than GetValues(Type): the latter is + // RequiresDynamicCode (IL3050) because it has to construct a T[] of the enum type + // at runtime, which can genuinely fail under NativeAOT -- and this project + // publishes AOT. The underlying-type overload returns a boxed primitive array, no + // dynamic array construction involved. + Array underlying = Enum.GetValuesAsUnderlyingType(type); + Values = new int[underlying.Length]; + for (int i = 0; i < underlying.Length; i++) + Values[i] = Convert.ToInt32(underlying.GetValue(i)); + } + + public Type Type { get; } + public uint ID { get; } + public string[] Names { get; } + public int[] Values { get; } + + public string Name => Type.Name; + public bool IsFlags => Type.IsDefined(typeof(FlagsAttribute), false); +} + +// One registered schema (the unit SpSetSchemaID/SpQueryTypeCount/SpGetTypeSchema operate +// on) -- a set of types and enums that were loaded together, e.g. from one markup-visible +// assembly loaded via SpLoadDll. +internal sealed class SchemaRegistration +{ + private readonly List _types = new(); + private readonly List _enums = new(); + + public ushort ID { get; set; } + + public IReadOnlyList Types => _types; + public IReadOnlyList Enums => _enums; + + public void Add([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type type) + { + if (type.IsEnum) + _enums.Add(new EnumSchema(type, (uint)_enums.Count)); + else + _types.Add(new TypeSchema(type, (uint)_types.Count)); + } + + // Unavoidably trim-unsafe by design, and suppressed rather than left to warn so a real + // future warning isn't lost in the noise: this projects types out of an assembly the + // host chose at *runtime* (SpLoadDll), which the trimmer cannot see into by + // definition. Anything markup binds to must therefore be kept alive by the host's own + // trimming configuration (e.g. a TrimmerRootAssembly entry for each markup-visible + // assembly), not by static analysis of UIXrender. + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Types come from assemblies loaded at runtime via SpLoadDll; the host must root them. See comment above.")] + [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "Types come from assemblies loaded at runtime via SpLoadDll; the host must root them. See comment above.")] + public static SchemaRegistration FromAssembly(Assembly assembly) + { + var registration = new SchemaRegistration(); + foreach (Type type in assembly.GetExportedTypes()) + registration.Add(type); + return registration; + } +} + +// A live instance handed back through SpInvokeConstructor and passed into +// SpGetPropertyValue/SpInvokeMethod. Refcounted because the original surface exposes +// SpAddRefExternalObject/SpReleaseExternalObject as an explicit COM-style pair. +internal sealed class SchemaObject +{ + public SchemaObject(object instance, TypeSchema schema) + { + Instance = instance; + Schema = schema; + RefCount = 1; + } + + public object Instance { get; } + public TypeSchema Schema { get; } + public int RefCount { get; set; } + + + // Backs SpGetStateCache/SpSetStateCache -- an opaque 64-bit slot the caller uses to + // memoise its own per-object state; UIXrender only stores and returns it. + public ulong StateCache { get; set; } + + // Backs SpDataBaseObjectGet/SetInternalHandle -- the framework-side handle for this + // object, likewise opaque to us. + public ulong InternalHandle { get; set; } +} diff --git a/UIXrender/Subsystems/Schema/SchemaRegistry.cs b/UIXrender/Subsystems/Schema/SchemaRegistry.cs new file mode 100644 index 0000000..8791bc3 --- /dev/null +++ b/UIXrender/Subsystems/Schema/SchemaRegistry.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using Microsoft.Iris.Render.Interop; + +namespace Microsoft.Iris.Render.Subsystems.Schema; + +// Process-wide type-ID assignment for the schema subsystem. Several exports return a +// *type ID* rather than a schema pointer (SpQueryBaseType, SpQueryPropertyType, +// SpQueryMethodReturnType, SpGetMethodParameterTypes, SpGetTypeID), and the managed side +// round-trips those IDs across unrelated calls -- so they have to be stable and unique +// process-wide, not per-schema. An incrementing counter keyed by CLR Type gives exactly +// that. +// +// IDs 0-15 are reserved for the primitives that UIXVariant can carry directly, so a +// caller can recognise "this property is an int" without a schema lookup. That mirrors +// what the original must have done to make UIXVariant's VariantType tag meaningful, and +// is the only part of this mapping that isn't an arbitrary counter. +internal static class SchemaRegistry +{ + public const uint TypeIdNone = 0; + + private static readonly Dictionary s_wellKnown = new() + { + [typeof(void)] = 0, + [typeof(bool)] = (uint)VariantType.Bool, + [typeof(byte)] = (uint)VariantType.Byte, + [typeof(int)] = (uint)VariantType.Int32, + [typeof(long)] = (uint)VariantType.Int64, + [typeof(float)] = (uint)VariantType.Single, + [typeof(double)] = (uint)VariantType.Double, + }; + + private const uint FirstDynamicTypeId = 16; + + private static readonly ConcurrentDictionary s_typeIds = new(); + private static readonly ConcurrentDictionary s_typesById = new(); + private static uint s_nextTypeId = FirstDynamicTypeId; + + public static uint GetTypeId(Type type) + { + if (type == null) + return TypeIdNone; + + if (s_wellKnown.TryGetValue(type, out uint wellKnown)) + return wellKnown; + + return s_typeIds.GetOrAdd(type, static t => + { + uint id = System.Threading.Interlocked.Increment(ref s_nextTypeId); + s_typesById[id] = t; + return id; + }); + } + + public static Type GetType(uint typeId) => s_typesById.TryGetValue(typeId, out Type type) ? type : null; + + // Backs SpGetMarshalAs: "what does this type look like to the interop layer". For + // anything UIXVariant can carry natively that's the VariantType tag; for everything + // else it's UIXObject, i.e. "marshal it as an opaque object handle". + public static uint GetMarshalAs(Type type) + { + if (type == null) + return (uint)VariantType.Empty; + if (type == typeof(bool)) return (uint)VariantType.Bool; + if (type == typeof(byte)) return (uint)VariantType.Byte; + if (type == typeof(int)) return (uint)VariantType.Int32; + if (type == typeof(long)) return (uint)VariantType.Int64; + if (type == typeof(float)) return (uint)VariantType.Single; + if (type == typeof(double)) return (uint)VariantType.Double; + if (type == typeof(string)) return (uint)VariantType.UIXString; + if (type.IsEnum) return (uint)VariantType.Enum; + return (uint)VariantType.UIXObject; + } +} diff --git a/UIXrender/Subsystems/Text/RichTextApi.cs b/UIXrender/Subsystems/Text/RichTextApi.cs new file mode 100644 index 0000000..55e8443 --- /dev/null +++ b/UIXrender/Subsystems/Text/RichTextApi.cs @@ -0,0 +1,326 @@ +using System; +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.Text; +using Microsoft.Iris.Render.Interop.Win32; + +namespace Microsoft.Iris.Render.Subsystems.Text; + +// [UnmanagedCallersOnly] exports for the SpRichText*/SpSimpleText* families in +// UIX/Microsoft/Iris/OS/NativeApi.cs (~35 entry points). +// +// Editing, selection, clipboard, undo/redo and all the mode flags are real (see +// RichTextObject). Measurement is approximate (see TextMetrics) and rasterization is not +// implemented -- both flagged in logs/UIXrender/FullSurface.md rather than quietly +// returning plausible-looking output. +public static unsafe class RichTextApi +{ + // Win32 message ids the managed side forwards; declared in NativeApi.cs as constants. + private const uint WM_KEYDOWN = 0x0100; + private const uint WM_CHAR = 0x0102; + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextBuildObject")] + public static HRESULT SpRichTextBuildObject(int fRichTextMode, Size sizeMaximumSurface, IntPtr pCallbacks, HANDLE* hRto) + { + if (hRto == null) + return HRESULT.E_INVALIDARG; + + Interop.Com.ComVtable.AddRef(pCallbacks); + var text = new RichTextObject(fRichTextMode != 0, sizeMaximumSurface, pCallbacks); + hRto->h = HandleTable.Alloc(text); + return HRESULT.S_OK; + } + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextDestroyObject")] + public static void SpRichTextDestroyObject(HANDLE hRto) + { + if (HandleTable.TryGet(hRto.h, out RichTextObject text)) + Interop.Com.ComVtable.Release(text.Callbacks); + HandleTable.Free(hRto.h); + } + + // ---- content ----------------------------------------------------------------- + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextSetContent")] + public static HRESULT SpRichTextSetContent(HANDLE hRto, char* pszContent) + { + if (!HandleTable.TryGet(hRto.h, out RichTextObject text)) + return HRESULT.E_INVALIDARG; + + text.SetContent(NativeString.UniToString(pszContent)); + return HRESULT.S_OK; + } + + // Writes into the caller's StringBuilder buffer (marshaled as a char* of cchBuffer + // characters), NUL-terminated and never overrunning. + [UnmanagedCallersOnly(EntryPoint = "SpRichTextGetSimpleContent")] + public static HRESULT SpRichTextGetSimpleContent(HANDLE hRto, char* textBuffer, int cchBuffer) + { + if (textBuffer == null || cchBuffer <= 0 || !HandleTable.TryGet(hRto.h, out RichTextObject text)) + return HRESULT.E_INVALIDARG; + + string content = text.Text; + int copy = Math.Min(content.Length, cchBuffer - 1); + content.AsSpan(0, copy).CopyTo(new Span(textBuffer, copy)); + textBuffer[copy] = '\0'; + return HRESULT.S_OK; + } + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextGetSimpleContentLength")] + public static HRESULT SpRichTextGetSimpleContentLength(HANDLE hRto, int* textLength) + { + if (textLength == null || !HandleTable.TryGet(hRto.h, out RichTextObject text)) + return HRESULT.E_INVALIDARG; + + *textLength = text.Length; + return HRESULT.S_OK; + } + + // ---- clipboard / editing ----------------------------------------------------- + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextCopy")] + public static HRESULT SpRichTextCopy(HANDLE hRto) + { + if (!HandleTable.TryGet(hRto.h, out RichTextObject text)) + return HRESULT.E_INVALIDARG; + text.Copy(); + return HRESULT.S_OK; + } + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextCut")] + public static HRESULT SpRichTextCut(HANDLE hRto) + { + if (!HandleTable.TryGet(hRto.h, out RichTextObject text)) + return HRESULT.E_INVALIDARG; + return text.Cut() ? HRESULT.S_OK : HRESULT.E_FAIL; + } + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextPaste")] + public static HRESULT SpRichTextPaste(HANDLE hRto) + { + if (!HandleTable.TryGet(hRto.h, out RichTextObject text)) + return HRESULT.E_INVALIDARG; + return text.Paste() ? HRESULT.S_OK : HRESULT.E_FAIL; + } + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextDelete")] + public static HRESULT SpRichTextDelete(HANDLE hRto) + { + if (!HandleTable.TryGet(hRto.h, out RichTextObject text)) + return HRESULT.E_INVALIDARG; + return text.DeleteSelection() ? HRESULT.S_OK : HRESULT.E_FAIL; + } + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextCanUndo")] + public static HRESULT SpRichTextCanUndo(HANDLE hRto, int* canUndo) + { + if (canUndo == null || !HandleTable.TryGet(hRto.h, out RichTextObject text)) + return HRESULT.E_INVALIDARG; + + *canUndo = text.CanUndo ? 1 : 0; + return HRESULT.S_OK; + } + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextUndo")] + public static HRESULT SpRichTextUndo(HANDLE hRto) + { + if (!HandleTable.TryGet(hRto.h, out RichTextObject text)) + return HRESULT.E_INVALIDARG; + return text.Undo() ? HRESULT.S_OK : HRESULT.E_FAIL; + } + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextSetSelectionRange")] + public static HRESULT SpRichTextSetSelectionRange(HANDLE hRto, int selectionStart, int selectionEnd) + { + if (!HandleTable.TryGet(hRto.h, out RichTextObject text)) + return HRESULT.E_INVALIDARG; + + text.SetSelectionRange(selectionStart, selectionEnd); + return HRESULT.S_OK; + } + + // ---- mode flags -------------------------------------------------------------- + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextSetReadOnly")] + public static HRESULT SpRichTextSetReadOnly(HANDLE hRto, int readOnly) => + Apply(hRto, t => t.ReadOnly = readOnly != 0); + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextSetWordWrap")] + public static HRESULT SpRichTextSetWordWrap(HANDLE hRto, int fWordWrap) => + Apply(hRto, t => t.WordWrap = fWordWrap != 0); + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextSetMaximumLength")] + public static HRESULT SpRichTextSetMaximumLength(HANDLE hRto, int maximumLength) => + Apply(hRto, t => t.MaximumLength = maximumLength > 0 ? maximumLength : int.MaxValue); + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextSetDetectUrls")] + public static HRESULT SpRichTextSetDetectUrls(HANDLE hRto, int detectUrls) => + Apply(hRto, t => t.DetectUrls = detectUrls != 0); + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextSetOversampleMode")] + public static HRESULT SpRichTextSetOversampleMode(HANDLE hRto, int fOversample) => + Apply(hRto, t => t.Oversample = fOversample != 0); + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextSetScale")] + public static HRESULT SpRichTextSetScale(HANDLE hRto, float flScale) => + Apply(hRto, t => t.Scale = flScale); + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextSetScrollbars")] + public static HRESULT SpRichTextSetScrollbars(HANDLE hRto, int allowVertical, int allowHorizontal) => + Apply(hRto, t => + { + t.AllowVerticalScroll = allowVertical != 0; + t.AllowHorizontalScroll = allowHorizontal != 0; + }); + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextSetFocus")] + public static HRESULT SpRichTextSetFocus(HANDLE hRto, int gainingFocus) => + Apply(hRto, t => t.SetFocus(gainingFocus != 0)); + + // ---- input forwarding -------------------------------------------------------- + + // Real: a WM_CHAR carrying a printable character is inserted into the buffer (which + // fires TextChanged and honours read-only/max-length), anything else is reported + // unhandled so the managed side can act on it. + [UnmanagedCallersOnly(EntryPoint = "SpRichTextForwardKeyCharacter")] + public static HRESULT SpRichTextForwardKeyCharacter(HANDLE hRto, uint message, int character, int scanCode, int repeatCount, uint modifierState, ushort flags, int* handled) + { + if (handled == null || !HandleTable.TryGet(hRto.h, out RichTextObject text)) + return HRESULT.E_INVALIDARG; + + *handled = 0; + if (message != WM_CHAR || character < ' ') + return HRESULT.S_OK; + + var inserted = new string((char)character, Math.Max(1, repeatCount)); + *handled = text.InsertText(inserted) ? 1 : 0; + return HRESULT.S_OK; + } + + // Editing keys (backspace/delete) act on the buffer; navigation and everything else + // is left to the managed side, which owns caret movement. + [UnmanagedCallersOnly(EntryPoint = "SpRichTextForwardKeyState")] + public static HRESULT SpRichTextForwardKeyState(HANDLE hRto, uint message, int virtualKey, int scanCode, int repeatCount, uint modifierState, ushort flags, int* handled) + { + const int VK_BACK = 0x08; + const int VK_DELETE = 0x2E; + + if (handled == null || !HandleTable.TryGet(hRto.h, out RichTextObject text)) + return HRESULT.E_INVALIDARG; + + *handled = 0; + if (message != WM_KEYDOWN) + return HRESULT.S_OK; + + if (virtualKey is VK_BACK or VK_DELETE) + *handled = text.DeleteSelection() ? 1 : 0; + + return HRESULT.S_OK; + } + + // Mouse hit-testing needs real glyph positions to map a point to a character offset, + // which this implementation doesn't have (see TextMetrics). Reports unhandled rather + // than moving the caret to a wrong offset. + // TODO: implement alongside a real font backend. + [UnmanagedCallersOnly(EntryPoint = "SpRichTextForwardMouseInput")] + public static HRESULT SpRichTextForwardMouseInput(HANDLE hRto, uint message, uint modifierState, int mouseButton, int x, int y, int mouseWheelDelta, int* handled) + { + if (handled == null) + return HRESULT.E_INVALIDARG; + *handled = 0; + return HRESULT.S_OK; + } + + // IME composition needs a platform input-method context to interpret; the managed + // side's own IME plumbing (SpRegisterImeCallbacks) is the path that actually carries + // these today. + [UnmanagedCallersOnly(EntryPoint = "SpRichTextForwardImeMessage")] + public static HRESULT SpRichTextForwardImeMessage(HANDLE hRto, uint message, UIntPtr wParam, UIntPtr lParam) => HRESULT.S_OK; + + // ---- scrolling / timers ------------------------------------------------------ + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextScroll")] + public static HRESULT SpRichTextScroll(HANDLE hRto, int whichBar, int scrollType) => + Apply(hRto, static _ => { }); + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextScrollToPosition")] + public static HRESULT SpRichTextScrollToPosition(HANDLE hRto, int whichBar, int whereTo) => + Apply(hRto, static _ => { }); + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextOnTimerTick")] + public static HRESULT SpRichTextOnTimerTick(HANDLE hRto, uint timerId) => + Apply(hRto, static t => t.NotifyShowCaret(t.HasFocus)); + + // ---- measurement / rasterization --------------------------------------------- + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextGetNaturalBounds")] + public static HRESULT SpRichTextGetNaturalBounds(HANDLE hRto, int* cWidth, int* cHeight) + { + if (cWidth == null || cHeight == null || !HandleTable.TryGet(hRto.h, out RichTextObject text)) + return HRESULT.E_INVALIDARG; + + // Font height isn't carried on the object (it arrives per-measure in a TextStyle), + // so natural bounds use the surface's own height as the em size reference. + Size bounds = TextMetrics.Measure(text.Text, DefaultFontHeight, text.WordWrap, text.MaximumSurface.width); + *cWidth = bounds.width; + *cHeight = bounds.height; + return HRESULT.S_OK; + } + + private const float DefaultFontHeight = 12f; + + // Approximate: computes real line/extent geometry from TextMetrics, but does not + // produce glyph runs -- the ReportRunCallback is therefore not invoked, so a caller + // gets correct-ish overall bounds and no per-run detail. + // TODO: emit real glyph runs once a font backend exists. See logs/UIXrender/FullSurface.md. + [UnmanagedCallersOnly(EntryPoint = "SpRichTextMeasure")] + public static HRESULT SpRichTextMeasure(HANDLE hRto, TextMeasureParamsData* measureParams, IntPtr rrcb, IntPtr pvData) + { + if (measureParams == null || !HandleTable.TryGet(hRto.h, out RichTextObject text)) + return HRESULT.E_INVALIDARG; + + float fontHeight = measureParams->pTextStyle != null && measureParams->pTextStyle->fontHeightPts > 0 + ? measureParams->pTextStyle->fontHeightPts + : DefaultFontHeight; + + string content = measureParams->content != null ? NativeString.UniToString(measureParams->content) : text.Text; + bool wordWrap = (measureParams->flags & TextMeasureFlags.WordWrapValue) != 0; + int constraint = (int)measureParams->constraint.width; + + Size measured = TextMetrics.Measure(content, fontHeight, wordWrap, constraint); + measureParams->constraint = new SizeF { width = measured.width, height = measured.height }; + return HRESULT.S_OK; + } + + // Not implemented: turning a glyph run into pixels requires a rasterizer this project + // doesn't have and can't borrow without either a Windows-only graphics API or a large + // new text-shaping dependency. Fails honestly with null out-params rather than + // returning an empty bitmap that would render as invisible text and look like a + // layout bug. + // TODO: implement with a real font/rasterizer backend. + [UnmanagedCallersOnly(EntryPoint = "SpRichTextRasterize")] + public static HRESULT SpRichTextRasterize(IntPtr hGlyphRunInfo, int fOutlineMode, Color clrText, int fShadowMode, IntPtr* phTextBitmap, IntPtr* ppvBits, Size* psizeBitmap) + { + if (phTextBitmap != null) *phTextBitmap = IntPtr.Zero; + if (ppvBits != null) *ppvBits = IntPtr.Zero; + if (psizeBitmap != null) *psizeBitmap = default; + return HRESULT.E_NOTIMPL; + } + + [UnmanagedCallersOnly(EntryPoint = "SpRichTextDestroyGlyphRunInfo")] + public static void SpRichTextDestroyGlyphRunInfo(IntPtr hGlyphRunInfo) => HandleTable.Free(hGlyphRunInfo); + + // ---- helpers ----------------------------------------------------------------- + + private static HRESULT Apply(HANDLE hRto, Action action) + { + if (!HandleTable.TryGet(hRto.h, out RichTextObject text)) + return HRESULT.E_INVALIDARG; + + action(text); + return HRESULT.S_OK; + } +} diff --git a/UIXrender/Subsystems/Text/RichTextObject.cs b/UIXrender/Subsystems/Text/RichTextObject.cs new file mode 100644 index 0000000..214d96b --- /dev/null +++ b/UIXrender/Subsystems/Text/RichTextObject.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Iris.Render.Interop.Com; +using Microsoft.Iris.Render.Interop.Drawing; + +namespace Microsoft.Iris.Render.Subsystems.Text; + +// The managed model behind the SpRichText* family (UIX/Microsoft/Iris/OS/NativeApi.cs) -- +// the native text-box engine backing Iris's editable TextBox. +// +// The *editing* half of this is fully real: content, selection, clipboard operations, +// undo/redo, word wrap, read-only, max length, scale, scrollbars and timers all behave +// the way an edit control does, and the registered IRichTextCallbacks is notified on the +// same occasions the original would have notified it (TextChanged, SelectionChanged, +// MaxLengthExceeded, ...). That matters because the managed TextBox drives its whole +// state machine off those callbacks. +// +// The *typography* half (measuring and rasterizing glyphs) is where this stops short -- +// see RichTextApi.SpRichTextMeasure/SpRichTextRasterize and the open question in +// logs/UIXrender/FullSurface.md. +internal sealed class RichTextObject +{ + // Slot layout of IRichTextCallbacks (UIX/Microsoft/Iris/OS/IRichTextCallbacks.cs), + // declaration order after the three IUnknown slots. Read off that file, not guessed. + private const int SlotInvalidateContent = ComVtable.FirstMethodSlot + 0; + private const int SlotSelectionChanged = ComVtable.FirstMethodSlot + 1; + private const int SlotCreateCaret = ComVtable.FirstMethodSlot + 2; + private const int SlotSetCaretPos = ComVtable.FirstMethodSlot + 3; + private const int SlotShowCaret = ComVtable.FirstMethodSlot + 4; + private const int SlotSetCursor = ComVtable.FirstMethodSlot + 5; + private const int SlotTextChanged = ComVtable.FirstMethodSlot + 6; + private const int SlotMaxLengthExceeded = ComVtable.FirstMethodSlot + 7; + private const int SlotSetTimer = ComVtable.FirstMethodSlot + 8; + private const int SlotKillTimer = ComVtable.FirstMethodSlot + 9; + private const int SlotSetScrollRange = ComVtable.FirstMethodSlot + 10; + private const int SlotEnableScrollbar = ComVtable.FirstMethodSlot + 11; + private const int SlotClientToWindow = ComVtable.FirstMethodSlot + 12; + private const int SlotClientToScreen = ComVtable.FirstMethodSlot + 13; + private const int SlotLinkClicked = ComVtable.FirstMethodSlot + 14; + + private readonly StringBuilder _content = new(); + private readonly Stack _undo = new(); + private readonly Stack _redo = new(); + + // The clipboard is process-wide in the original (a real OS clipboard). There is no + // cross-platform clipboard in the BCL, so cut/copy/paste round-trip through this + // shared buffer instead -- fully functional within the app, which is what the + // managed TextBox's own tests exercise. + // TODO: bridge to the OS clipboard once a platform abstraction exists. + private static string s_clipboard = string.Empty; + + public RichTextObject(bool richTextMode, Size maximumSurface, IntPtr callbacks) + { + RichTextMode = richTextMode; + MaximumSurface = maximumSurface; + Callbacks = callbacks; + } + + public bool RichTextMode { get; } + public Size MaximumSurface { get; } + public IntPtr Callbacks { get; } + + public bool ReadOnly { get; set; } + public bool WordWrap { get; set; } = true; + public bool DetectUrls { get; set; } + public bool Oversample { get; set; } + public bool HasFocus { get; private set; } + public float Scale { get; set; } = 1.0f; + public int MaximumLength { get; set; } = int.MaxValue; + public bool AllowVerticalScroll { get; set; } + public bool AllowHorizontalScroll { get; set; } + + public int SelectionStart { get; private set; } + public int SelectionEnd { get; private set; } + + public string Text => _content.ToString(); + public int Length => _content.Length; + + public bool CanUndo => _undo.Count > 0; + + // ---- content ----------------------------------------------------------------- + + public void SetContent(string value) + { + PushUndo(); + _content.Clear(); + _content.Append(value ?? string.Empty); + ClampSelection(); + NotifyTextChanged(); + NotifyInvalidateContent(); + } + + public bool InsertText(string value) + { + if (ReadOnly || string.IsNullOrEmpty(value)) + return false; + + DeleteSelectionCore(); + + if (_content.Length + value.Length > MaximumLength) + { + NotifyMaxLengthExceeded(); + return false; + } + + PushUndo(); + _content.Insert(SelectionStart, value); + SelectionStart += value.Length; + SelectionEnd = SelectionStart; + NotifyTextChanged(); + NotifyInvalidateContent(); + return true; + } + + public bool DeleteSelection() + { + if (ReadOnly) + return false; + + PushUndo(); + if (!DeleteSelectionCore()) + return false; + + NotifyTextChanged(); + NotifyInvalidateContent(); + return true; + } + + private bool DeleteSelectionCore() + { + int start = Math.Min(SelectionStart, SelectionEnd); + int end = Math.Max(SelectionStart, SelectionEnd); + if (start == end) + return false; + + _content.Remove(start, end - start); + SelectionStart = SelectionEnd = start; + return true; + } + + public string GetSelectedText() + { + int start = Math.Min(SelectionStart, SelectionEnd); + int end = Math.Max(SelectionStart, SelectionEnd); + return start == end ? string.Empty : _content.ToString(start, end - start); + } + + public void SetSelectionRange(int start, int end) + { + SelectionStart = Math.Clamp(start, 0, _content.Length); + SelectionEnd = Math.Clamp(end, 0, _content.Length); + NotifySelectionChanged(); + } + + private void ClampSelection() + { + SelectionStart = Math.Clamp(SelectionStart, 0, _content.Length); + SelectionEnd = Math.Clamp(SelectionEnd, 0, _content.Length); + } + + // ---- clipboard --------------------------------------------------------------- + + public void Copy() => s_clipboard = GetSelectedText(); + + public bool Cut() + { + if (ReadOnly) + return false; + s_clipboard = GetSelectedText(); + return DeleteSelection(); + } + + public bool Paste() => InsertText(s_clipboard); + + // ---- undo / redo ------------------------------------------------------------- + + private void PushUndo() + { + _undo.Push(_content.ToString()); + _redo.Clear(); + } + + public bool Undo() + { + if (_undo.Count == 0) + return false; + + _redo.Push(_content.ToString()); + string previous = _undo.Pop(); + _content.Clear(); + _content.Append(previous); + ClampSelection(); + NotifyTextChanged(); + NotifyInvalidateContent(); + return true; + } + + public bool Redo() + { + if (_redo.Count == 0) + return false; + + _undo.Push(_content.ToString()); + string next = _redo.Pop(); + _content.Clear(); + _content.Append(next); + ClampSelection(); + NotifyTextChanged(); + NotifyInvalidateContent(); + return true; + } + + // ---- focus / caret ----------------------------------------------------------- + + public void SetFocus(bool gainingFocus) + { + HasFocus = gainingFocus; + NotifyShowCaret(gainingFocus); + } + + // ---- callback dispatch ------------------------------------------------------- + + private unsafe void Notify(int slot) + { + void* fn = ComVtable.Slot(Callbacks, slot); + if (fn != null) + ((delegate* unmanaged)fn)(Callbacks); + } + + private unsafe void Notify(int slot, int arg) + { + void* fn = ComVtable.Slot(Callbacks, slot); + if (fn != null) + ((delegate* unmanaged)fn)(Callbacks, arg); + } + + private unsafe void Notify(int slot, int a, int b) + { + void* fn = ComVtable.Slot(Callbacks, slot); + if (fn != null) + ((delegate* unmanaged)fn)(Callbacks, a, b); + } + + public void NotifyTextChanged() => Notify(SlotTextChanged); + public void NotifyInvalidateContent() => Notify(SlotInvalidateContent); + public void NotifySelectionChanged() => Notify(SlotSelectionChanged, SelectionStart, SelectionEnd); + public void NotifyMaxLengthExceeded() => Notify(SlotMaxLengthExceeded); + public void NotifyShowCaret(bool visible) => Notify(SlotShowCaret, visible ? 1 : 0); + public void NotifyLinkClicked(int start, int end) => Notify(SlotLinkClicked, start, end); + public void NotifySetScrollRange(int whichBar, int min, int extent, int viewExtent, int position) => NotifyScrollRange(whichBar, min, extent, viewExtent, position); + + private unsafe void NotifyScrollRange(int whichBar, int min, int extent, int viewExtent, int position) + { + void* fn = ComVtable.Slot(Callbacks, SlotSetScrollRange); + if (fn != null) + ((delegate* unmanaged)fn)(Callbacks, whichBar, min, extent, viewExtent, position); + } +} diff --git a/UIXrender/Subsystems/Text/SimpleTextApi.cs b/UIXrender/Subsystems/Text/SimpleTextApi.cs new file mode 100644 index 0000000..1156cd4 --- /dev/null +++ b/UIXrender/Subsystems/Text/SimpleTextApi.cs @@ -0,0 +1,86 @@ +using System; +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.Text; +using Microsoft.Iris.Render.Interop.Win32; + +namespace Microsoft.Iris.Render.Subsystems.Text; + +// [UnmanagedCallersOnly] exports for the SpSimpleText* family in +// UIX/Microsoft/Iris/OS/NativeApi.cs -- measurement/rendering only, no editing. +public static unsafe class SimpleTextApi +{ + private sealed class SimpleTextObject(Size maximumSurface) + { + public Size MaximumSurface { get; } = maximumSurface; + } + + // The simple-text path *is* available: it's the measurement fast path, and this + // implementation provides measurement (approximately -- see TextMetrics). Reporting + // false here would push every caller onto the rich-text path for no benefit. + [UnmanagedCallersOnly(EntryPoint = "SpSimpleTextIsAvailable")] + public static int SpSimpleTextIsAvailable() => 1; + + [UnmanagedCallersOnly(EntryPoint = "SpSimpleTextBuildObject")] + public static HRESULT SpSimpleTextBuildObject(Size sizeMaximumSurface, HANDLE* hSto) + { + if (hSto == null) + return HRESULT.E_INVALIDARG; + + hSto->h = HandleTable.Alloc(new SimpleTextObject(sizeMaximumSurface)); + return HRESULT.S_OK; + } + + [UnmanagedCallersOnly(EntryPoint = "SpSimpleTextDestroyObject")] + public static void SpSimpleTextDestroyObject(HANDLE hSto) => HandleTable.Free(hSto.h); + + // Fills in the caller's RasterizeRunPacket with real layout geometry derived from + // TextMetrics (bounds, natural size, ascender/baseline insets, colour, line number). + // No glyph run handle is produced, because there are no glyphs -- hGlyphRunInfo comes + // back null and the caller's subsequent SpRichTextRasterize would report E_NOTIMPL. + // TODO: produce a real glyph run once a font backend exists. + [UnmanagedCallersOnly(EntryPoint = "SpSimpleTextMeasure")] + public static HRESULT SpSimpleTextMeasure(HANDLE hSto, char* pszRef, short wAlignment, TextStyleData* textStyle, Size sizeConstraint, IntPtr* hGlyphRunInfo, RasterizeRunPacket* pRun) + { + if (hGlyphRunInfo == null || !HandleTable.TryGet(hSto.h, out SimpleTextObject _)) + return HRESULT.E_INVALIDARG; + + *hGlyphRunInfo = IntPtr.Zero; + + string text = NativeString.UniToString(pszRef) ?? string.Empty; + float fontHeight = textStyle != null && textStyle->fontHeightPts > 0 ? textStyle->fontHeightPts : 12f; + + Size measured = TextMetrics.Measure(text, fontHeight, wordWrap: sizeConstraint.width > 0, sizeConstraint.width); + + if (pRun != null) + { + pRun->rcLayoutBounds = new Rectangle { x = 0, y = 0, width = measured.width, height = measured.height }; + pRun->rcfRenderBounds = new RectangleF { x = 0, y = 0, width = measured.width, height = measured.height }; + pRun->sizeRasterizeRun = measured; + pRun->sizeNatural = measured; + pRun->ascenderInset = TextMetrics.Ascent(fontHeight); + pRun->baselineInset = TextMetrics.Ascent(fontHeight); + pRun->lineNumber = 0; + if (textStyle != null) + pRun->clrText = textStyle->textColor; + } + + return HRESULT.S_OK; + } + + // "Is this string measurable with this style" -- true whenever there's a style to + // measure against, since TextMetrics has no per-glyph coverage requirement (it is + // metric-derived, not font-table-derived). + [UnmanagedCallersOnly(EntryPoint = "SpSimpleTextMeasurePossible")] + public static HRESULT SpSimpleTextMeasurePossible(HANDLE hSto, char* pszRef, TextStyleData* textStyle, int* fPossible) + { + if (fPossible == null) + return HRESULT.E_INVALIDARG; + + *fPossible = textStyle != null ? 1 : 0; + return HRESULT.S_OK; + } +} diff --git a/UIXrender/Subsystems/Text/TextMetrics.cs b/UIXrender/Subsystems/Text/TextMetrics.cs new file mode 100644 index 0000000..b8936b4 --- /dev/null +++ b/UIXrender/Subsystems/Text/TextMetrics.cs @@ -0,0 +1,81 @@ +using System; +using Microsoft.Iris.Render.Interop.Drawing; + +namespace Microsoft.Iris.Render.Subsystems.Text; + +// Text measurement for SpRichTextMeasure/SpSimpleTextMeasure/SpRichTextGetNaturalBounds. +// +// **This is the one genuinely approximate part of the whole UIXrender surface, and it is +// flagged rather than hidden.** Real measurement needs font loading plus glyph metrics +// and shaping. There is no such abstraction in Silk.NET, System.Drawing's is Windows-only +// (and a graphics API this project's dependency policy rules out), and adding a full text +// shaping stack (HarfBuzz/SixLabors.Fonts) is a much larger dependency decision than this +// pass should make unilaterally. +// +// So: metrics are derived from the requested font height using the ratios that hold for +// the overwhelming majority of Latin UI faces (Segoe UI, Verdana, Tahoma -- what Zune's +// markup actually asks for). Line height and baseline placement are close to exact; per- +// character advance is an average, so a measured string's *width* is approximate and will +// not match a real rasterizer. +// +// Consequence, stated plainly: layout driven by these numbers will be plausible but not +// pixel-accurate, and text will not currently rasterize at all (see +// RichTextApi.SpRichTextRasterize). Logged as the primary open question in +// logs/UIXrender/FullSurface.md. +// TODO: replace wholesale with a real font backend; do not build on these ratios. +internal static class TextMetrics +{ + // Typical for Latin UI faces: cap-to-em ratio ~0.7, ascent ~0.8 em, descent ~0.2 em, + // default line gap ~1.2 em. + private const float AverageAdvanceRatio = 0.55f; + private const float AscentRatio = 0.80f; + private const float LineHeightRatio = 1.20f; + + public static int LineHeight(float fontHeightPts) => (int)MathF.Ceiling(fontHeightPts * LineHeightRatio); + + public static int Ascent(float fontHeightPts) => (int)MathF.Round(fontHeightPts * AscentRatio); + + public static int AverageCharWidth(float fontHeightPts) => Math.Max(1, (int)MathF.Round(fontHeightPts * AverageAdvanceRatio)); + + // Measures `text` with optional word wrapping into `constraintWidth` (0 = unconstrained). + // Wrapping itself is real (break on whitespace, fall back to a hard break for a word + // longer than the line) -- only the per-character width feeding it is approximate. + public static Size Measure(string text, float fontHeightPts, bool wordWrap, int constraintWidth) + { + if (string.IsNullOrEmpty(text)) + return new Size(0, LineHeight(fontHeightPts)); + + int charWidth = AverageCharWidth(fontHeightPts); + int lineHeight = LineHeight(fontHeightPts); + + int maxCharsPerLine = wordWrap && constraintWidth > 0 + ? Math.Max(1, constraintWidth / charWidth) + : int.MaxValue; + + int lines = 0; + int widestLine = 0; + + foreach (string paragraph in text.Split('\n')) + { + string remaining = paragraph.TrimEnd('\r'); + do + { + int take = Math.Min(remaining.Length, maxCharsPerLine); + if (take < remaining.Length) + { + // Prefer breaking at the last space that fits. + int lastSpace = remaining.LastIndexOf(' ', Math.Max(0, take - 1)); + if (lastSpace > 0) + take = lastSpace; + } + + widestLine = Math.Max(widestLine, take * charWidth); + lines++; + remaining = remaining[take..].TrimStart(' '); + } + while (remaining.Length > 0); + } + + return new Size(widestLine, Math.Max(1, lines) * lineHeight); + } +} diff --git a/UIXrender/Subsystems/Tracing/TracingApi.cs b/UIXrender/Subsystems/Tracing/TracingApi.cs index 2e0b551..d0a6df0 100644 --- a/UIXrender/Subsystems/Tracing/TracingApi.cs +++ b/UIXrender/Subsystems/Tracing/TracingApi.cs @@ -1,14 +1,25 @@ using System.Runtime.InteropServices; +using Microsoft.Iris.Interop; namespace Microsoft.Iris.Render.Subsystems.Tracing; -// [UnmanagedCallersOnly] exports matching two of the DllImport("UIXRender.dll") +// [UnmanagedCallersOnly] exports matching the DllImport("UIXRender.dll") tracing // declarations in UIX/Microsoft/Iris/OS/NativeApi.cs. -public static class TracingApi +public static unsafe class TracingApi { [UnmanagedCallersOnly(EntryPoint = "SpInitializeTracing")] public static void SpInitializeTracing() => TracingState.Initialize(); [UnmanagedCallersOnly(EntryPoint = "SpUninitializeTracing")] public static void SpUninitializeTracing() => TracingState.Uninitialize(); + + // The three `bool` params have no [MarshalAs] override in the original DllImport, so + // the CLR marshals each as the default 4-byte Win32 BOOL -- `int` here, not `byte`. + [UnmanagedCallersOnly(EntryPoint = "SpUpdateTraceSettings")] + public static void SpUpdateTraceSettings(char* debugTraceFile, char* writeLinePrefix, int sendOutputToDebugger, int showCategories, int timedWriteLines) => + TracingState.UpdateSettings(NativeString.UniToString(debugTraceFile), NativeString.UniToString(writeLinePrefix), sendOutputToDebugger != 0, showCategories != 0, timedWriteLines != 0); + + [UnmanagedCallersOnly(EntryPoint = "SpLogTrace")] + public static void SpLogTrace(char* categoryName, char* message, int indentLevel) => + TracingState.LogTrace(NativeString.UniToString(categoryName), NativeString.UniToString(message), indentLevel); } diff --git a/UIXrender/Subsystems/Tracing/TracingState.cs b/UIXrender/Subsystems/Tracing/TracingState.cs index c86624f..d3f560f 100644 --- a/UIXrender/Subsystems/Tracing/TracingState.cs +++ b/UIXrender/Subsystems/Tracing/TracingState.cs @@ -1,12 +1,66 @@ +using System; +using System.IO; + namespace Microsoft.Iris.Render.Subsystems.Tracing; -// Minimal real state for SpInitializeTracing/SpUninitializeTracing. The rest of the -// tracing surface (SpUpdateTraceSettings, SpLogTrace) is a later session's work -- see -// the subsystem ordering in the approved plan. +// Real state for the whole tracing surface: SpInitializeTracing/SpUninitializeTracing +// (init flag) plus SpUpdateTraceSettings/SpLogTrace (settings + line formatting/sink). internal static class TracingState { public static bool IsInitialized { get; private set; } + private static string s_debugTraceFile; + private static string s_writeLinePrefix = ""; + private static bool s_sendOutputToDebugger; + private static bool s_showCategories; + private static bool s_timedWriteLines; + private static readonly object s_fileLock = new(); + public static void Initialize() => IsInitialized = true; public static void Uninitialize() => IsInitialized = false; + + public static void UpdateSettings(string debugTraceFile, string writeLinePrefix, bool sendOutputToDebugger, bool showCategories, bool timedWriteLines) + { + s_debugTraceFile = debugTraceFile; + s_writeLinePrefix = writeLinePrefix ?? ""; + s_sendOutputToDebugger = sendOutputToDebugger; + s_showCategories = showCategories; + s_timedWriteLines = timedWriteLines; + } + + public static void LogTrace(string categoryName, string message, int indentLevel) + { + if (!IsInitialized) + return; + + var line = new System.Text.StringBuilder(); + line.Append(s_writeLinePrefix); + if (s_timedWriteLines) + line.Append('[').Append(DateTime.Now.ToString("HH:mm:ss.fff")).Append("] "); + if (s_showCategories && !string.IsNullOrEmpty(categoryName)) + line.Append('[').Append(categoryName).Append("] "); + line.Append(' ', indentLevel * 2); + line.Append(message); + + string text = line.ToString(); + + if (s_sendOutputToDebugger) + Console.Error.WriteLine(text); + + if (!string.IsNullOrEmpty(s_debugTraceFile)) + { + lock (s_fileLock) + { + try + { + File.AppendAllText(s_debugTraceFile, text + Environment.NewLine); + } + catch (IOException) + { + // Best-effort trace sink -- matches the original's fire-and-forget + // SpLogTrace, which has no HRESULT to report failure through either. + } + } + } + } } diff --git a/UIXrender/Subsystems/Xml/XmlLiteApi.cs b/UIXrender/Subsystems/Xml/XmlLiteApi.cs new file mode 100644 index 0000000..a9fd5f7 --- /dev/null +++ b/UIXrender/Subsystems/Xml/XmlLiteApi.cs @@ -0,0 +1,185 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; +using System.Xml; +using Microsoft.Iris.Interop; +using Microsoft.Iris.Render.Engine; +using Microsoft.Iris.Render.Interop; +using Microsoft.Iris.Render.Interop.XmlLite; + +namespace Microsoft.Iris.Render.Subsystems.Xml; + +// [UnmanagedCallersOnly] exports for the SpXmlLite* family in UIX/Microsoft/Iris/OS/NativeApi.cs. +// +// Two behaviours below are read off the *caller* (UIX/Microsoft/Iris/OS/NativeXmlReader.cs) +// rather than assumed, because getting either backwards would produce an infinite loop or +// a silently truncated parse: +// * `SpXmlLiteRead` / `SpXmlLiteMoveToFirstAttribute` / `SpXmlLiteMoveToNextAttribute` +// are each tested with `SUCCEEDED(...)`, so "nothing more to read" must be reported as +// a **failing** HRESULT, not S_FALSE (which SUCCEEDED would accept as "keep going"). +// * the `length` passed to `SpXmlLiteCreateXmlReader` is a **byte** count, not a +// character count: NativeXmlReader's string overload passes `content.Length * 2` over +// a pinned UTF-16 string, and its Resource overload passes a raw file buffer length. +public static unsafe class XmlLiteApi +{ + 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); + + [UnmanagedCallersOnly(EntryPoint = "SpXmlLiteCreateXmlReader")] + public static uint SpXmlLiteCreateXmlReader(IntPtr buffer, int length, int isFragment, IntPtr* xmlReader) + { + if (xmlReader == null) + return InvalidArg; + + *xmlReader = IntPtr.Zero; + if (buffer == IntPtr.Zero || length <= 0) + return InvalidArg; + + try + { + string text = DecodeBuffer((byte*)buffer, length); + *xmlReader = HandleTable.Alloc(XmlLiteReader.Create(text, isFragment != 0)); + return OK; + } + catch (XmlException) + { + return Fail; + } + } + + [UnmanagedCallersOnly(EntryPoint = "SpXmlLiteDeleteXmlReader")] + public static void SpXmlLiteDeleteXmlReader(IntPtr xmlReader) => HandleTable.Free(xmlReader); + + [UnmanagedCallersOnly(EntryPoint = "SpXmlLiteRead")] + public static uint SpXmlLiteRead(IntPtr xmlReader, NativeXmlNodeType* nodeType) + { + if (nodeType == null || !HandleTable.TryGet(xmlReader, out XmlLiteReader reader)) + return InvalidArg; + + try + { + // Failure at EOF is deliberate -- see the file comment. + return reader.Read(out NativeXmlNodeType type) ? Assign(nodeType, type, OK) : Assign(nodeType, type, Fail); + } + catch (XmlException) + { + *nodeType = NativeXmlNodeType.None; + return Fail; + } + } + + [UnmanagedCallersOnly(EntryPoint = "SpXmlLiteMoveToFirstAttribute")] + public static uint SpXmlLiteMoveToFirstAttribute(IntPtr xmlReader) => + HandleTable.TryGet(xmlReader, out XmlLiteReader reader) + ? (reader.MoveToFirstAttribute() ? OK : Fail) + : InvalidArg; + + [UnmanagedCallersOnly(EntryPoint = "SpXmlLiteMoveToNextAttribute")] + public static uint SpXmlLiteMoveToNextAttribute(IntPtr xmlReader) => + HandleTable.TryGet(xmlReader, out XmlLiteReader reader) + ? (reader.MoveToNextAttribute() ? OK : Fail) + : InvalidArg; + + [UnmanagedCallersOnly(EntryPoint = "SpXmlLiteIsEmptyElement")] + public static int SpXmlLiteIsEmptyElement(IntPtr xmlReader) => + HandleTable.TryGet(xmlReader, out XmlLiteReader reader) && reader.IsEmptyElement ? 1 : 0; + + [UnmanagedCallersOnly(EntryPoint = "SpXmlLiteGetQualifiedName")] + public static uint SpXmlLiteGetQualifiedName(IntPtr xmlReader, IntPtr* name, uint* length) => + ReturnString(xmlReader, name, length, static r => r.QualifiedName); + + [UnmanagedCallersOnly(EntryPoint = "SpXmlLiteGetLocalName")] + public static uint SpXmlLiteGetLocalName(IntPtr xmlReader, IntPtr* name, uint* length) => + ReturnString(xmlReader, name, length, static r => r.LocalName); + + [UnmanagedCallersOnly(EntryPoint = "SpXmlLiteGetPrefix")] + public static uint SpXmlLiteGetPrefix(IntPtr xmlReader, IntPtr* prefix, uint* length) => + ReturnString(xmlReader, prefix, length, static r => r.Prefix); + + [UnmanagedCallersOnly(EntryPoint = "SpXmlLiteGetValue")] + public static uint SpXmlLiteGetValue(IntPtr xmlReader, IntPtr* value, uint* length) => + ReturnString(xmlReader, value, length, static r => r.Value); + + [UnmanagedCallersOnly(EntryPoint = "SpXmlLiteGetLineNumber")] + public static uint SpXmlLiteGetLineNumber(IntPtr xmlReader, uint* lineNumber) + { + if (lineNumber == null || !HandleTable.TryGet(xmlReader, out XmlLiteReader reader)) + return InvalidArg; + *lineNumber = reader.LineNumber; + return OK; + } + + [UnmanagedCallersOnly(EntryPoint = "SpXmlLiteGetLinePosition")] + public static uint SpXmlLiteGetLinePosition(IntPtr xmlReader, uint* linePosition) + { + if (linePosition == null || !HandleTable.TryGet(xmlReader, out XmlLiteReader reader)) + return InvalidArg; + *linePosition = reader.LinePosition; + return OK; + } + + // ---- helpers --------------------------------------------------------------------- + + private static uint Assign(NativeXmlNodeType* target, NativeXmlNodeType value, uint result) + { + *target = value; + return result; + } + + // The returned pointer must stay valid until at least the caller's next call, and the + // managed side never frees it (NativeApi.PtrToStringUni just reads it) -- so these go + // through the intern pool, which also keeps repeated element/attribute names from + // allocating anew on every node. + private static uint ReturnString(IntPtr xmlReader, IntPtr* target, uint* length, Func select) + { + if (target == null || length == null || !HandleTable.TryGet(xmlReader, out XmlLiteReader reader)) + return InvalidArg; + + string value = select(reader) ?? string.Empty; + *target = (IntPtr)NativeString.InternUni(value); + *length = (uint)value.Length; + return OK; + } + + // The buffer is bytes of unknown encoding: NativeXmlReader feeds it either a pinned + // UTF-16 string (no BOM) or a raw file buffer (typically UTF-8, possibly with a BOM). + // Real XmlLite sniffs the encoding itself, so this does the same rather than assuming + // one: BOM first, then the "every second byte is zero" pattern that distinguishes + // BOM-less UTF-16 ASCII text, else UTF-8. Documented assumption -- see + // logs/UIXrender/FullSurface.md. + private static string DecodeBuffer(byte* buffer, int byteLength) + { + var bytes = new ReadOnlySpan(buffer, byteLength); + + if (byteLength >= 2) + { + if (bytes[0] == 0xFF && bytes[1] == 0xFE) + return Encoding.Unicode.GetString(bytes[2..]); + if (bytes[0] == 0xFE && bytes[1] == 0xFF) + return Encoding.BigEndianUnicode.GetString(bytes[2..]); + } + + if (byteLength >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) + return Encoding.UTF8.GetString(bytes[3..]); + + if (LooksLikeUtf16LittleEndian(bytes)) + return Encoding.Unicode.GetString(bytes); + + return Encoding.UTF8.GetString(bytes); + } + + private static bool LooksLikeUtf16LittleEndian(ReadOnlySpan bytes) + { + if (bytes.Length < 2 || (bytes.Length & 1) != 0) + return false; + + int sampled = Math.Min(bytes.Length, 32); + for (int i = 1; i < sampled; i += 2) + { + if (bytes[i] != 0) + return false; + } + return true; + } +} diff --git a/UIXrender/Subsystems/Xml/XmlLiteReader.cs b/UIXrender/Subsystems/Xml/XmlLiteReader.cs new file mode 100644 index 0000000..e11f883 --- /dev/null +++ b/UIXrender/Subsystems/Xml/XmlLiteReader.cs @@ -0,0 +1,94 @@ +using System; +using System.IO; +using System.Xml; +using Microsoft.Iris.Render.Interop.XmlLite; + +namespace Microsoft.Iris.Render.Subsystems.Xml; + +// Backs the SpXmlLite* family (UIX/Microsoft/Iris/OS/NativeApi.cs). The original was a +// thin wrapper over Windows' XmlLite COM reader; this is the same *contract* over +// System.Xml.XmlReader, which is in-box, cross-platform and behaviourally equivalent for +// the pull-parsing subset the surface exposes (read a node, inspect name/prefix/value, +// walk attributes, report line/position). +// +// One real semantic difference to be aware of and handled here, not papered over: XmlLite +// reports attributes as nodes you move to with MoveToFirst/NextAttribute and then read +// via the *same* GetLocalName/GetValue accessors, and System.Xml.XmlReader works exactly +// that way too -- so the mapping is direct. What is *not* direct is IsEmptyElement, which +// XmlReader only reports on the element node itself; it's captured on each read rather +// than queried lazily, so it stays correct after moving to an attribute. +internal sealed class XmlLiteReader : IDisposable +{ + private readonly XmlReader _reader; + private readonly IXmlLineInfo _lineInfo; + + private XmlLiteReader(XmlReader reader) + { + _reader = reader; + _lineInfo = reader as IXmlLineInfo; + } + + public bool IsEmptyElement { get; private set; } + + public static XmlLiteReader Create(string text, bool isFragment) + { + var settings = new XmlReaderSettings + { + // A "fragment" has no single root element; ConformanceLevel.Fragment is + // exactly XmlLite's isFragment flag. + ConformanceLevel = isFragment ? ConformanceLevel.Fragment : ConformanceLevel.Document, + DtdProcessing = DtdProcessing.Ignore, + IgnoreWhitespace = false, + CloseInput = true, + }; + + return new XmlLiteReader(XmlReader.Create(new StringReader(text), settings)); + } + + public bool Read(out NativeXmlNodeType nodeType) + { + if (!_reader.Read()) + { + nodeType = NativeXmlNodeType.None; + IsEmptyElement = false; + return false; + } + + IsEmptyElement = _reader.NodeType == XmlNodeType.Element && _reader.IsEmptyElement; + nodeType = Map(_reader.NodeType); + return true; + } + + public bool MoveToFirstAttribute() => _reader.MoveToFirstAttribute(); + + public bool MoveToNextAttribute() => _reader.MoveToNextAttribute(); + + public string LocalName => _reader.LocalName; + public string Prefix => _reader.Prefix; + public string QualifiedName => _reader.Name; + public string Value => _reader.Value; + + public uint LineNumber => (uint)(_lineInfo?.LineNumber ?? 0); + public uint LinePosition => (uint)(_lineInfo?.LinePosition ?? 0); + + // NativeXmlNodeType's values are System.Xml.XmlNodeType's own numbering minus the + // members Iris doesn't use (verified against UIX/Microsoft/Iris/OS/NativeXmlNodeType.cs), + // so this could be a cast -- it's written out so an unmapped node type degrades to + // None instead of producing a value the managed side has no case for. + private static NativeXmlNodeType Map(XmlNodeType type) => type switch + { + XmlNodeType.Element => NativeXmlNodeType.Element, + XmlNodeType.Attribute => NativeXmlNodeType.Attribute, + XmlNodeType.Text => NativeXmlNodeType.Text, + XmlNodeType.CDATA => NativeXmlNodeType.CDATA, + XmlNodeType.ProcessingInstruction => NativeXmlNodeType.ProcessingInstruction, + XmlNodeType.Comment => NativeXmlNodeType.Comment, + XmlNodeType.DocumentType => NativeXmlNodeType.DocumentType, + XmlNodeType.Whitespace or XmlNodeType.SignificantWhitespace => NativeXmlNodeType.Whitespace, + XmlNodeType.EndElement => NativeXmlNodeType.EndElement, + XmlNodeType.XmlDeclaration => NativeXmlNodeType.XmlDeclaration, + _ => NativeXmlNodeType.None, + }; + + public void Dispose() => _reader.Dispose(); +} diff --git a/UIXrender/UIXrender.csproj b/UIXrender/UIXrender.csproj index b09843f..c854fc7 100644 --- a/UIXrender/UIXrender.csproj +++ b/UIXrender/UIXrender.csproj @@ -18,4 +18,20 @@ + + + + + + + + + + \ No newline at end of file