mirror of
https://github.com/ZuneDev/MicrosoftIris.git
synced 2026-07-27 13:13:29 -07:00
306 lines
22 KiB
Markdown
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.
|