22 KiB
UIXrender.dll engine core — implementation log
Append-only. Do not edit previous entries.
2026-07-22 — Wired the remaining EngineApi.cs methods managed-direct (all 9)
Task: the first managed-direct pass (entry below) converted only the 6 transport
methods LocalChannel uses. Now that the full surface is implemented, wire the other 9
UIX.RenderApi/.../Protocol/EngineApi.cs methods — SpPeekMessage, SpWaitMessage,
SpInvoke, the four SpRemote*, SpDx9CompileEffect, SpObjectRelease — to call
UIXrender's managed implementation instead of [DllImport], keeping the net461 #else
branch on the native path exactly as the existing 6 do.
Single source of truth, established deliberately. Rather than duplicate logic between
UIX.RenderApi and UIXrender's [UnmanagedCallersOnly] shims, I made EngineService (the
public facade both sides already share) the one implementation and routed both the
native shims (UIXrender/Interop/EngineApi.cs) and UIX.RenderApi through it. So each
operation now has exactly one body: EngineService.{PeekMessage,WaitMessage,Invoke, RemoteCreateServerStreams,RemoteWaitServerStreamsConnected,RemoteServerInit, RemoteServerUninit,ReleaseRemoteStream,Dx9CompileEffect}. EngineService's doc comment
was updated: it's no longer strictly pointer-free (opaque IntPtr handles pass through,
and Invoke has the one unsafe spot to call the function pointer a caller hands it),
and that's called out explicitly.
Two things that would have been silent bugs, verified against the real call sites rather than assumed:
-
SpInvoke's only in-repo caller isIRenderEngine.InterThreadWake, which passes a null function pointer (SpInvoke(ctx, IntPtr.Zero, IntPtr.Zero, false)) purely to wake a pump. The earlier native shim returnedE_INVALIDARGfor a null pointer — which would have been wrong for this caller.EngineService.Invokenow treats a null pointer as a well-definedS_OKno-op (there is no blocking pump to wake in this implementation), and both entry paths use that. -
The remote path had a handle-model mismatch that would have corrupted memory.
RemoteChannel.ConnectcallsSpObjectReleaseon the two stream handles fromSpRemoteCreateServerStreams. In managed-direct mode those handles are UIXrenderGCHandles, not COM vtable pointers — so the native shim's generic "release through the vtable slot 2"SpObjectReleasewould have dereferenced garbage. Fixed by:RemoteServerConnectionnow hands out two distinctGCHandles (it previously aliased send == receive) with a refcount of 2;ServerInitadds a third (the session). Each release drops one reference and frees its own handle; the sockets are torn down exactly once, when the last reference goes. This matchesRemoteChannel.Connect's exact sequence (create → init → release send → release receive → later uninit session).- Managed-direct
SpObjectReleaseroutes toEngineService.ReleaseRemoteStream(drop-a-reference), not the vtable path. The native shim keeps the vtable-release behaviour for genuine native COM callers — the two object models are distinct, likeSpWrapBufferProcalready is. Documented at both sites.
Callback representation, handled consistently. RemoteServerConnection.ServerInit was
refactored to take a pointer-free BufferReceivedHandler (like RenderThread), so its
ReadLoop no longer assumes a native function pointer. The native shim adapts the raw
InitArgs.pfnProcessBuffer function pointer into one; UIX.RenderApi's managed-direct path
resolves the GCHandle'd MessageBufferEventHandler and reuses the existing
AdaptCallback helper. In practice RemoteChannel connects with no receive callback
(it builds InitArgs with the 2-arg ctor → pfnProcessBuffer == 0), so the handler is
normally null — but the adaptation is correct for the non-null case too, rather than
mis-reading a GCHandle as a function pointer.
SpDx9CompileEffect routes to EngineService.Dx9CompileEffect() (E_NOTIMPL, per
FullSurface.md decision 1); the UIX.RenderApi side zeroes its out-params so a caller
ignoring the HRESULT still sees a well-defined "no blob". Note its caller
(Dx9EffectResource) wraps it in IFC, which throws on failure — same observable outcome
as before on net8.0, where there was no working native SpDx9CompileEffect to call
anyway; now it fails deterministically instead of at DLL-resolution time.
Verified: UIX.RenderApi (net8.0) builds clean (only pre-existing decompiled-code
warnings); whole solution builds except the same pre-existing SimpleDebugClient/
SimpleIrisApp errors; NativeAOT publish still exports exactly 193/193 (the wiring
changed no signatures); Tests/UIXrender.Engine.Tests extended to 53 checks, all
passing, including new ones that exercise the managed-direct remote path
(RemoteCreateServerStreams VC→E_NOTIMPL, TCP→two distinct handles, and release of both
tearing the connection down once without a double-free).
2026-07-22 — Managed-direct calling: EngineService layer + first modification of UIX.RenderApi
Task: per user direction, restructure so UIX.RenderApi can call directly into
UIXrender's managed implementation (no P/Invoke, no native marshaling) whenever both
are loaded in the same .NET process, while Interop/EngineApi.cs's
[UnmanagedCallersOnly] exports remain for genuine native callers. Logging before/while
writing code, not after.
Key fact driving the design: [UnmanagedCallersOnly]-attributed methods cannot be
called directly from C# at all (compiler error CS8901) -- they can only be reached via a
function pointer, mimicking how native code calls them. So "call directly" necessarily
means a separate idiomatic layer beneath the shim, not calling the shim methods
themselves with fewer steps.
Design: split UIXrender/Engine/ into a public, pointer-free API
(EngineService/IRenderThreadHandle/BufferReceivedHandler, using
ReadOnlySpan<byte> and ordinary C# delegates) that ContextRegistry/RenderThread
are refactored to use internally -- no IntPtr/raw function pointers anywhere in
Engine/ after this change. All pointer/unsafe marshaling concentrates in
Interop/EngineApi.cs (adapting native BufferInfo*/void*/raw function pointers to
and from the idiomatic API), which is exactly the "backwards-compatible entrypoint for
native callers" the user asked to keep.
Scope of the UIX.RenderApi change, decided deliberately, not implicitly: only the
6 EngineApi.cs methods UIXrender actually implements today get their [DllImport]
bodies replaced with real calls into EngineService: SpInit, SpUninit,
SpWrapBufferProc, SpRenderThreadInit, SpRenderThreadUninit, SpBufferOpen. These
happen to be the entire set RenderPort.cs/LocalChannel.cs (the real send/receive
path) use -- confirmed by re-checking call sites, not assumed. Everything else in
EngineApi.cs (SpPeekMessage, SpWaitMessage, SpInvoke, the SpRemote* family,
SpDx9CompileEffect, SpObjectRelease) stays untouched [DllImport] since UIXrender
doesn't implement those yet -- converting them now would break, not improve, anything
that still needs them. This is the first modification of a previously-decompiled,
until-now-untouched file in this whole effort (UIX.RenderApi/Microsoft/Iris/Render/Protocol/EngineApi.cs),
worth flagging explicitly: only method bodies change, not signatures or any other
file's call sites, and it's an internal-only class (nothing outside UIX.RenderApi.dll
depends on how it's implemented), so this doesn't touch the drop-in-replacement contract
CLAUDE.md cares about for this project's actual public surface.
A real, additional simplification found along the way, not just pointer-hiding:
SpWrapBufferProc's original job was handing a native caller a real function pointer to
invoke later. In the managed-direct path, the delegate the caller passes
(MessageBufferEventHandler) doesn't need to become a function pointer at all -- it can
be stored via a GCHandle and resolved straight back to the original delegate object,
invoked as a normal C# delegate call. No delegate* unmanaged<...>/calli anywhere on
this path. A BufferInfo*/void* pointer still gets reconstructed at the point of
calling the stored MessageBufferEventHandler delegate, because that delegate's own
signature (already-decompiled, unchanged) is pointer-shaped -- not something avoidable
without touching RenderPort.cs, which is out of scope here.
Implementation, extending this entry as each piece lands:
UIXrender/Engine/refactored to be entirely pointer-free:BufferReceivedHandler.cs(custom delegate, notAction<>, specifically soReadOnlySpan<byte>is legal as a parameter --Span<T>/ReadOnlySpan<T>are ref structs and can't be generic type arguments, but a hand-declared delegate can take one directly),IRenderThreadHandle.cs,ContextRegistry.cs(nowConcurrentDictionary<uint, BufferReceivedHandler>, no moreIntPtr),RenderThread.cs(IDisposable/IRenderThreadHandle, holds the delegate directly, no function pointer casts), and the new publicEngineService.csfacade (StartRenderThread,SendBuffer) -- the one type meant to be called by anything that wants managed-direct access.UIXrender/Interop/EngineApi.csrewritten to route throughEngineService--SpRenderThreadInitbuilds aBufferReceivedHandlerclosure that reconstructs aBufferInfo*/void*call only at the point of invoking the raw native function pointer it received;SpBufferOpenconverts itsvoid*/cbSizeBufferinto aReadOnlySpan<byte>and callsEngineService.SendBuffer. Verified this still builds clean on Linux.UIX.RenderApi/Microsoft/Iris/Render/Protocol/EngineApi.cs-- the 6 in-scope methods'[DllImport]bodies replaced with real implementations callingEngineServicedirectly, converting betweenUIX.RenderApi's own (unchanged, pre-existing)Protocol.ContextID/RENDERHANDLE/Internal.HRESULT/nestedEngineApi.BufferInfotypes andUIXrender'sInterop.*equivalents at the boundary via theToUInt32/constructor accessors those types already expose -- kept as two separate struct sets rather than unifying them, sinceUIXrendermust stay independent ofUIX.RenderApi(it's also P/Invoked directly byUIX.dll'sNativeApi.cs, a different assembly entirely) and a reference in the other direction would be circular.net461handled explicitly, not overlooked:UIX.RenderApi.csprojhasEnableNetFXTarget=true(still targets .NET Framework 4.6.1 on Windows), which can't reference a net8.0-only project at all -- the newProjectReferencetoUIXrender.csprojis conditioned out fornet461(Condition="'$(TargetFramework)' != 'net461'"in the csproj), and all 6 modified methods are wrapped#if !NETFRAMEWORKwith the original, unmodified[DllImport]declarations preserved in the#elsebranch -- net461 keeps calling the real nativeUIXRender.dllexactly as before, nothing regresses for that target.Tests/UIXrender.Engine.Tests(new) -- exercisesEngineServicedirectly via aProjectReferencetoUIXrender.csproj, no P/Invoke or published DLL involved. SinceEngine/is now fully pointer-free, this is the first thing in this whole effort that could actually be run and observed, not just compiled, from this (Linux) machine. Ran it:StartRenderThread's on-start synthetic invocation fires,SendBuffercorrectly routes to the registered handler with the right source context/buffer handle/payload bytes, andSendBufferto a disposed/unregistered context fails cleanly (E_FAIL) rather than hanging or throwing. All 8 checks passed. Wired into CI (runs in the samewindows-latestjob today for simplicity, but noted in the workflow as cross-platform-capable if a Linux job gets added later).
Verified on Linux: whole solution builds clean except the same pre-existing,
unrelated SimpleDebugClient/SimpleIrisApp errors; Tests/UIXrender.Engine.Tests
actually runs and all 8 checks pass. Not verified here, needs Windows CI: the
#else/net461 branches in EngineApi.cs (Directory.Build.props only adds net461 when
IsOsPlatform('Windows'), so this machine never compiles that branch at all -- a real
gap, flagged rather than assumed fine); the Phase 0 native-ABI spike from the entry
below, still pending its first CI run.
Follow-up cleanup, prompted by the user asking why SpRenderThreadInit had grown so
much logic: worth recording the answer, not just the fix. Three sources, sorted by
whether they're removable:
- Not removable -- inherent to preserving the original API exactly: (1)
SpWrapBufferProc/SpRenderThreadInitare two separate calls in the original API, and the only channel between them matching that shape isInitArgs.pfnProcessBufferas a plainIntPtr, which forces the GCHandle-store/GCHandle-resolve round trip rather than passing the delegate straight through; (2)MessageBufferEventHandler's signature is still pointer-shaped (BufferInfo* pBufferInfo, void* pvBufferData), unchanged sinceRenderPort.csstill expects to receive calls that way, so something has to reconstruct a pointer-based call no matter how cleanEngineService's own API is; (3)UIX.RenderApi's ownContextID/RENDERHANDLE/BufferInfoandUIXrender'sInterop.*equivalents are two genuinely separate type systems (by design, to avoid the circular-reference problem noted earlier), so crossing that boundary needs explicit conversion. - Removable, and fixed: the
BufferInfo-reconstruction closure was inlined directly insideSpRenderThreadInit, making the method read as one undifferentiated block instead of clear steps -- and it's structurally the same pattern (build aBufferInfo, get a pointer to the span, invoke) asUIXrender/Interop/EngineApi.cs's ownSpRenderThreadInituses for native callers, just invoking a stored C# delegate at the end instead of a raw function pointer. Extracted into a new privateAdaptCallback(MessageBufferEventHandler, ContextID) : BufferReceivedHandlerhelper, soSpRenderThreadInititself now reads as four named steps (resolve the stored delegate → adapt it → start the thread → wrap the handle) instead of one block with an inline lambda in the middle. Same behavior, verified:UIX.RenderApi.csprojstill builds clean andTests/UIXrender.Engine.Testsstill passes all 8 checks after the change.
2026-07-22 — Phase 0 spike: SpBufferOpen/SpWrapBufferProc/SpRenderThreadInit
Task: per the approved plan, implement UIXrender.dll's core transport primitives
well enough to prove the riskiest interop mechanism works: a native OS thread the CLR
didn't create calling back into a managed delegate. Logging as I go, per the user's
explicit reminder not to wait until done.
Structural decision, before writing code: creating a new shared project,
UIXInterop (Microsoft.Iris.Interop namespace), for native-marshaling helpers used by
both UIXsup and UIXrender (currently just ANSI/Unicode byte*/char* ↔ string
conversion — UIXsup/Interop/DebugApi.cs already has a private PtrToString that would
otherwise get copy-pasted here). Per the user's explicit go-ahead to split into
additional projects to cut duplication. UIXsup will be refactored to use it too, so
there's a single implementation instead of two copies drifting apart. This is narrower
than fully splitting UIXrender into per-subsystem projects (Engine, Graphics, ...) —
that's still a folder-level split inside UIXrender/, per the plan's project-structure
section; only genuinely cross-project shared code moves to its own assembly.
Scoping SpInit/SpUninit out of this spike, logged rather than guessed:
EngineApi.SpUninit() takes no parameters at all — no context id, nothing — which
is hard to reconcile with SpInit(ref InitArgs args) registering a specific
args.idContext. Checked whether LocalChannel.Connect() (the code path real Zune
actually uses) calls SpInit/SpUninit at all: it doesn't — it only calls
SpRenderThreadInit/SpRenderThreadUninit (UIX.RenderApi/Microsoft/Iris/Render/Protocol/LocalChannel.cs).
SpInit/SpUninit must serve some other call path not yet located (possibly the
alternate "IGMM_STANDARD messaging model" mentioned in one of EngineApi.IFC's HRESULT
error strings, implying a selectable threading model distinct from the dedicated-thread
one SpRenderThreadInit provides) — no confident answer, so no guess: implementing both
as plain HRESULT.S_OK-returning stubs for now (the explicitly-allowed stub convention),
real logic deferred until the actual caller/semantics are found. Not blocking this
session's work since the spike's own critical path (LocalChannel's path) never calls
either.
Design note, not an unknown — a legitimate simplification: SpWrapBufferProc's
purpose in the original was presumably to hand the CLR-marshaled delegate pointer to
native code in a form the original C++ implementation's internal calling convention
needed (possibly a real trampoline/thunk). Since this reimplementation is the native
side now, and controls both ends of the call, there's nothing to adapt — the pointer the
CLR hands us when marshaling MessageBufferEventHandler is already directly callable
from a delegate* unmanaged<...> field. SpWrapBufferProc becomes a real (not fake)
but simple implementation: validate, store/echo the pointer, return S_OK. Recording
this so a future reader doesn't wonder why it's "too simple" compared to what the
original probably did internally — the simplification is deliberate, not a missed spot.
Implementation complete for this phase:
UIXInteropproject (UIXInterop/NativeString.cs) —AnsiToString/UniToStringpointer conversion, shared byUIXsup(refactored to use it, removing its local copy) andUIXrender.UIXrender/Interop/wire structs —HRESULT.cs,ContextID.cs,RENDERHANDLE.cs,BufferInfo.cs(+BufferFlags),InitArgs.cs— bit-for-bit mirrors of the decompiledUIX.RenderApi/Microsoft/Iris/Render/{Internal,Protocol}/*.csstructs read in the first architecture session.InitArgs.pfnTimeoutisIntPtrhere, not a delegate type (see file comment: the CLR marshals the managed side's delegate field to a plain function pointer before the struct crosses over, so that's the actual wire representation).UIXrender/Engine/ContextRegistry.cs—ContextID -> (callback pointer, callback data)registry, real logic (ConcurrentDictionary), the smallest coherent slice of "Engine core" (the plan's subsystem 1) needed forSpRenderThreadInit/SpBufferOpento mean anything together. Callback stored as rawIntPtr, cast to adelegate* unmanaged<...>only at the actual invocation site, to avoid putting a function-pointer-typed field into a generic collection's value type.UIXrender/Engine/RenderThread.cs— real (not mocked) thread lifecycle:Startspins up a genuineSystem.Threading.Thread, which invokes the registered callback once with syntheticBufferInfodata then blocks on aManualResetEventSlimuntilStopsignals it, joins, and unregisters. Not the full message-pump/dispatch loop (that's later, larger "Engine core" work) — this proves the interop mechanism itself.UIXrender/Subsystems/Tracing/—TracingState.cs(minimal real init-flag state) andTracingApi.cs(SpInitializeTracing/SpUninitializeTracingexports). Full tracing (SpUpdateTraceSettings/SpLogTrace) deferred to when the Tracing subsystem gets its own pass per the plan's sequencing.UIXrender/Interop/EngineApi.cs— the remaining[UnmanagedCallersOnly]exports:SpInit/SpUninit(S_OK stubs, per the open question above),SpWrapBufferProc(pass-through, per the design note above),SpRenderThreadInit/SpRenderThreadUninit(wrapRenderThreadbehind aGCHandle-backed opaqueIntPtrhandle, matching what the managed side'sout IntPtr pThreadexpects to receive and later pass back),SpBufferOpen(looks up the destination context inContextRegistry, invokes its callback synchronously).Tests/UIXrender.Interop.Tests— the Phase 0 exit-criteria harness: a plain console app (no test framework dependency added) with its own P/Invoke declarations (can't referenceEngineApi.csdirectly — it'sinternalinUIX.RenderApi.dll, and this harness is meant to exercise the publishedUIXrender.dllthe way an arbitrary external caller would anyway). Registers a callback viaSpWrapBufferProc, starts a thread viaSpRenderThreadInit, waits up to 5s for the callback to fire with the expected context id, then tears down viaSpRenderThreadUninit. Explicit pass/fail per check, non-zero exit code on any failure, wired into.github/workflows/uixrender-ci.ymlafter the publish steps (locates the published DLL and the harness's build output viaGet-ChildItem, copies the DLL alongside the harness exe, runs it, fails the job on a non-zero exit code).
Verified on Linux: the whole solution builds clean except the same pre-existing,
unrelated Tests/SimpleDebugClient/SimpleIrisApp errors noted in UIXsup.md (not
touched by this work). Not yet verified: actual execution — the interop spike can
only run on Windows (P/Invoking a real NativeAOT-published UIXrender.dll), which
requires the CI workflow to actually run, not checked yet as of this entry.