Files
MicrosoftIris/logs/UIXrender/EngineCore.md
T

306 lines
22 KiB
Markdown

# 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:**
1. **`SpInvoke`'s only in-repo caller is `IRenderEngine.InterThreadWake`, which passes a
*null* function pointer** (`SpInvoke(ctx, IntPtr.Zero, IntPtr.Zero, false)`) purely to
wake a pump. The earlier native shim returned `E_INVALIDARG` for a null pointer — which
would have been wrong for this caller. `EngineService.Invoke` now treats a null pointer
as a well-defined `S_OK` no-op (there is no blocking pump to wake in this
implementation), and both entry paths use that.
2. **The remote path had a handle-model mismatch that would have corrupted memory.**
`RemoteChannel.Connect` calls `SpObjectRelease` on the two stream handles from
`SpRemoteCreateServerStreams`. In managed-direct mode those handles are UIXrender
`GCHandle`s, **not** COM vtable pointers — so the native shim's generic "release through
the vtable slot 2" `SpObjectRelease` would have dereferenced garbage. Fixed by:
- `RemoteServerConnection` now hands out **two distinct** `GCHandle`s (it previously
aliased send == receive) with a **refcount of 2**; `ServerInit` adds 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 matches
`RemoteChannel.Connect`'s exact sequence (create → init → release send → release
receive → later uninit session).
- Managed-direct `SpObjectRelease` routes to `EngineService.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, like
`SpWrapBufferProc` already 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:**
1. `UIXrender/Engine/` refactored to be entirely pointer-free: `BufferReceivedHandler.cs`
(custom delegate, not `Action<>`, specifically so `ReadOnlySpan<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` (now `ConcurrentDictionary<uint, BufferReceivedHandler>`, no more
`IntPtr`), `RenderThread.cs` (`IDisposable`/`IRenderThreadHandle`, holds the delegate
directly, no function pointer casts), and the new public `EngineService.cs` facade
(`StartRenderThread`, `SendBuffer`) -- the one type meant to be called by anything
that wants managed-direct access.
2. `UIXrender/Interop/EngineApi.cs` rewritten to route through `EngineService` --
`SpRenderThreadInit` builds a `BufferReceivedHandler` closure that reconstructs a
`BufferInfo*`/`void*` call only at the point of invoking the raw native function
pointer it received; `SpBufferOpen` converts its `void*`/`cbSizeBuffer` into a
`ReadOnlySpan<byte>` and calls `EngineService.SendBuffer`. Verified this still builds
clean on Linux.
3. `UIX.RenderApi/Microsoft/Iris/Render/Protocol/EngineApi.cs` -- the 6 in-scope
methods' `[DllImport]` bodies replaced with real implementations calling
`EngineService` directly, converting between `UIX.RenderApi`'s own (unchanged,
pre-existing) `Protocol.ContextID`/`RENDERHANDLE`/`Internal.HRESULT`/nested
`EngineApi.BufferInfo` types and `UIXrender`'s `Interop.*` equivalents at the
boundary via the `ToUInt32`/constructor accessors those types already expose -- kept
as two separate struct sets rather than unifying them, since `UIXrender` must stay
independent of `UIX.RenderApi` (it's also P/Invoked directly by `UIX.dll`'s
`NativeApi.cs`, a different assembly entirely) and a reference in the other direction
would be circular. **`net461` handled explicitly, not overlooked**: `UIX.RenderApi.csproj`
has `EnableNetFXTarget=true` (still targets .NET Framework 4.6.1 on Windows), which
can't reference a net8.0-only project at all -- the new `ProjectReference` to
`UIXrender.csproj` is conditioned out for `net461`
(`Condition="'$(TargetFramework)' != 'net461'"` in the csproj), and all 6 modified
methods are wrapped `#if !NETFRAMEWORK` with the original, unmodified `[DllImport]`
declarations preserved in the `#else` branch -- net461 keeps calling the real native
`UIXRender.dll` exactly as before, nothing regresses for that target.
4. `Tests/UIXrender.Engine.Tests` (new) -- exercises `EngineService` directly via a
`ProjectReference` to `UIXrender.csproj`, no P/Invoke or published DLL involved. Since
`Engine/` 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, `SendBuffer`
correctly routes to the registered handler with the right source context/buffer
handle/payload bytes, and `SendBuffer` to a disposed/unregistered context fails
cleanly (`E_FAIL`) rather than hanging or throwing. All 8 checks passed. Wired into
CI (runs in the same `windows-latest` job 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`/`SpRenderThreadInit` are two separate calls in the original API,
and the only channel between them matching that shape is `InitArgs.pfnProcessBuffer`
as a plain `IntPtr`, 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 since `RenderPort.cs` still expects to receive calls that way, so
*something* has to reconstruct a pointer-based call no matter how clean
`EngineService`'s own API is; (3) `UIX.RenderApi`'s own `ContextID`/`RENDERHANDLE`/
`BufferInfo` and `UIXrender`'s `Interop.*` 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
inside `SpRenderThreadInit`, making the method read as one undifferentiated block
instead of clear steps -- and it's structurally the *same* pattern (build a
`BufferInfo`, get a pointer to the span, invoke) as `UIXrender/Interop/EngineApi.cs`'s
own `SpRenderThreadInit` uses for native callers, just invoking a stored C# delegate
at the end instead of a raw function pointer. Extracted into a new private
`AdaptCallback(MessageBufferEventHandler, ContextID) : BufferReceivedHandler` helper,
so `SpRenderThreadInit` itself 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.csproj` still
builds clean and `Tests/UIXrender.Engine.Tests` still 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:**
1. `UIXInterop` project (`UIXInterop/NativeString.cs`) — `AnsiToString`/`UniToString`
pointer conversion, shared by `UIXsup` (refactored to use it, removing its local
copy) and `UIXrender`.
2. `UIXrender/Interop/` wire structs — `HRESULT.cs`, `ContextID.cs`, `RENDERHANDLE.cs`,
`BufferInfo.cs` (+ `BufferFlags`), `InitArgs.cs` — bit-for-bit mirrors of the
decompiled `UIX.RenderApi/Microsoft/Iris/Render/{Internal,Protocol}/*.cs` structs
read in the first architecture session. `InitArgs.pfnTimeout` is `IntPtr` here, 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).
3. `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 for `SpRenderThreadInit`/`SpBufferOpen`
to mean anything together. Callback stored as raw `IntPtr`, cast to a
`delegate* unmanaged<...>` only at the actual invocation site, to avoid putting a
function-pointer-typed field into a generic collection's value type.
4. `UIXrender/Engine/RenderThread.cs` — real (not mocked) thread lifecycle: `Start`
spins up a genuine `System.Threading.Thread`, which invokes the registered callback
once with synthetic `BufferInfo` data then blocks on a `ManualResetEventSlim` until
`Stop` signals it, joins, and unregisters. Not the full message-pump/dispatch loop
(that's later, larger "Engine core" work) — this proves the interop mechanism itself.
5. `UIXrender/Subsystems/Tracing/` — `TracingState.cs` (minimal real init-flag state)
and `TracingApi.cs` (`SpInitializeTracing`/`SpUninitializeTracing` exports). Full
tracing (`SpUpdateTraceSettings`/`SpLogTrace`) deferred to when the Tracing subsystem
gets its own pass per the plan's sequencing.
6. `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`
(wrap `RenderThread` behind a `GCHandle`-backed opaque `IntPtr` handle, matching what
the managed side's `out IntPtr pThread` expects to receive and later pass back),
`SpBufferOpen` (looks up the destination context in `ContextRegistry`, invokes its
callback synchronously).
7. `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
reference `EngineApi.cs` directly — it's `internal` in `UIX.RenderApi.dll`, and this
harness is meant to exercise the published `UIXrender.dll` the way an arbitrary
external caller would anyway). Registers a callback via `SpWrapBufferProc`, starts a
thread via `SpRenderThreadInit`, waits up to 5s for the callback to fire with the
expected context id, then tears down via `SpRenderThreadUninit`. Explicit pass/fail
per check, non-zero exit code on any failure, wired into
`.github/workflows/uixrender-ci.yml` after the publish steps (locates the published
DLL and the harness's build output via `Get-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.