diff --git a/AffinityPluginLoader/Native/NativeHook.cs b/AffinityPluginLoader/Native/NativeHook.cs new file mode 100644 index 0000000..6946c72 --- /dev/null +++ b/AffinityPluginLoader/Native/NativeHook.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using AffinityPluginLoader.Core; + +namespace AffinityPluginLoader.Native +{ + /// + /// Inline function detouring for native code. Overwrites a function's prologue + /// with a jump to a managed delegate, and creates an executable trampoline + /// containing the original prologue bytes so the hook can call through to the + /// original function. + /// + /// Supports two modes: + /// - Large prologue (>= 12 bytes): direct absolute jump at target site + /// - Small prologue (>= 5 bytes): relative jump to a nearby relay thunk + /// + /// IMPORTANT: prologueSize must end on an instruction boundary, and the + /// relocated bytes must not contain RIP-relative instructions (use a larger + /// or smaller prologue to avoid them). + /// + public static class NativeHook + { + private const int AbsJmpSize = 12; // mov rax, imm64 (10) + jmp rax (2) + private const int RelJmpSize = 5; // jmp rel32 + private static readonly List _pinnedDelegates = new(); + private static readonly List _allocations = new(); + + /// + /// Hook a native function at the given address. + /// + /// Delegate type with [UnmanagedFunctionPointer] + /// Address of the function to hook + /// + /// Number of bytes to overwrite. Must be >= 5 and end on an instruction boundary. + /// The overwritten bytes must not contain RIP-relative instructions. + /// + /// Replacement delegate + /// Delegate that calls the original function + public static T Hook(IntPtr target, int prologueSize, T hook) where T : class + { + if (target == IntPtr.Zero) + throw new ArgumentNullException(nameof(target)); + if (prologueSize < RelJmpSize) + throw new ArgumentException($"prologueSize must be >= {RelJmpSize}"); + if (!(hook is Delegate hookDel)) + throw new ArgumentException("T must be a delegate type"); + + IntPtr hookPtr = Marshal.GetFunctionPointerForDelegate(hookDel); + + // Allocate trampoline (for calling original): prologue + absolute jump back + int trampolineSize = prologueSize + AbsJmpSize; + IntPtr trampoline = VirtualAlloc(IntPtr.Zero, (UIntPtr)trampolineSize, + MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); + if (trampoline == IntPtr.Zero) + throw new OutOfMemoryException("VirtualAlloc failed for trampoline"); + + // Copy original prologue to trampoline + jump back + byte[] originalBytes = new byte[prologueSize]; + Marshal.Copy(target, originalBytes, 0, prologueSize); + Marshal.Copy(originalBytes, 0, trampoline, prologueSize); + WriteAbsoluteJump(trampoline + prologueSize, target + prologueSize); + + // Overwrite target prologue + VirtualProtect(target, (UIntPtr)prologueSize, PAGE_EXECUTE_READWRITE, out uint oldProtect); + + if (prologueSize >= AbsJmpSize) + { + // Direct absolute jump to hook + WriteAbsoluteJump(target, hookPtr); + } + else + { + // Allocate relay near target for absolute jump, use rel32 at target + IntPtr relay = AllocateNear(target, AbsJmpSize); + if (relay == IntPtr.Zero) + throw new OutOfMemoryException("Failed to allocate relay near target"); + WriteAbsoluteJump(relay, hookPtr); + WriteRelativeJump(target, relay); + } + + // NOP remaining bytes + for (int i = (prologueSize >= AbsJmpSize ? AbsJmpSize : RelJmpSize); i < prologueSize; i++) + Marshal.WriteByte(target + i, 0x90); + + VirtualProtect(target, (UIntPtr)prologueSize, oldProtect, out _); + + _pinnedDelegates.Add(hookDel); + _allocations.Add(trampoline); + + T original = Marshal.GetDelegateForFunctionPointer(trampoline); + + Logger.Debug($"NativeHook: detoured 0x{target.ToInt64():X} -> 0x{hookPtr.ToInt64():X} " + + $"(trampoline at 0x{trampoline.ToInt64():X}, {prologueSize} bytes relocated)"); + + return original; + } + + private static unsafe void WriteAbsoluteJump(IntPtr site, IntPtr target) + { + byte* p = (byte*)site; + p[0] = 0x48; p[1] = 0xB8; // mov rax, imm64 + *(long*)(p + 2) = target.ToInt64(); + p[10] = 0xFF; p[11] = 0xE0; // jmp rax + } + + private static unsafe void WriteRelativeJump(IntPtr site, IntPtr target) + { + byte* p = (byte*)site; + p[0] = 0xE9; // jmp rel32 + *(int*)(p + 1) = (int)(target.ToInt64() - site.ToInt64() - 5); + } + + /// + /// Allocate executable memory within ±2GB of the given address. + /// + private static IntPtr AllocateNear(IntPtr target, int size) + { + long addr = target.ToInt64(); + long low = Math.Max(addr - 0x7FFF0000L, 0x10000L); + long high = addr + 0x7FFF0000L; + + // Scan in 64KB increments (allocation granularity) + for (long a = addr & ~0xFFFFL; a >= low; a -= 0x10000) + { + IntPtr result = VirtualAlloc((IntPtr)a, (UIntPtr)size, + MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); + if (result != IntPtr.Zero) + { + _allocations.Add(result); + return result; + } + } + for (long a = (addr + 0x10000) & ~0xFFFFL; a <= high; a += 0x10000) + { + IntPtr result = VirtualAlloc((IntPtr)a, (UIntPtr)size, + MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); + if (result != IntPtr.Zero) + { + _allocations.Add(result); + return result; + } + } + return IntPtr.Zero; + } + + private const uint MEM_COMMIT = 0x1000; + private const uint MEM_RESERVE = 0x2000; + private const uint PAGE_EXECUTE_READWRITE = 0x40; + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr VirtualAlloc(IntPtr lpAddress, UIntPtr dwSize, + uint flAllocationType, uint flProtect); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool VirtualProtect(IntPtr lpAddress, UIntPtr dwSize, + uint flNewProtect, out uint lpflOldProtect); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr GetModuleHandle(string lpModuleName); + + [DllImport("kernel32.dll", CharSet = CharSet.Ansi)] + private static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName); + } +} diff --git a/WineFix/Patches/BezierSplitBudgetPatch.cs b/WineFix/Patches/BezierSplitBudgetPatch.cs new file mode 100644 index 0000000..b6700f0 --- /dev/null +++ b/WineFix/Patches/BezierSplitBudgetPatch.cs @@ -0,0 +1,164 @@ +using System; +using System.Runtime.InteropServices; +using AffinityPluginLoader.Core; +using AffinityPluginLoader.Native; + +namespace WineFix.Patches +{ + /// + /// Caps the number of bezier control triangle splits in Wine's geometry + /// processing to prevent unbounded segment growth and application freezes. + /// + /// Wine's d2d_geometry_resolve_beziers (inlined in d2d_geometry_sink_Close) + /// calls d2d_geometry_split_bezier in an unbounded loop. Pathological + /// geometries (e.g. overlapping beziers in embedded SVGs) cause the loop + /// to run forever, hanging Affinity. + /// + /// This patch hooks d2d_geometry_split_bezier with a thread-local call + /// counter that returns failure after 512 calls, causing the caller to + /// stop splitting. + /// + /// Based on: + /// 0005-d2d1-prevent-runaway-bezier-splitting-and-recursion-.patch + /// by Arecsu (https://github.com/Arecsu/wine-affinity) + /// + public static class BezierSplitBudgetPatch + { + // d2d_geometry_split_bezier.isra.0(figures_ptr, segment_idx_ptr) -> BOOL + // .isra = GCC interprocedural SRA; takes two pointer args after optimization + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + private delegate int SplitBezierFn(IntPtr figures, IntPtr idx); + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + private delegate int SinkCloseFn(IntPtr self); + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + private delegate int CreatePathGeometryFn(IntPtr factory, out IntPtr pathGeometry); + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + private delegate int OpenFn(IntPtr pathGeometry, out IntPtr geometrySink); + + private static SplitBezierFn _original; + private static SinkCloseFn _origClose; + + // Prologue: push rsi (56) + push rbx (53) + sub rsp,0x48 (48 83 ec 48) + // + first 4 bytes of movq xmm2,[rip+disp] (f3 0f 7e 15) for pattern uniqueness + // Only the first 6 bytes are relocated (before the RIP-relative instruction) + private static readonly byte[] SplitBezierPattern = { + 0x56, 0x53, 0x48, 0x83, 0xEC, 0x48, 0xF3, 0x0F, 0x7E, 0x15 + }; + + private const int PrologueSize = 6; // push+push+sub = 6 bytes (>= 5 for rel32 jmp) + private const int MaxSplitsPerOperation = 512; + + // ID2D1SimplifiedGeometrySink::Close = vtable index 9 + private const int Sink_Close = 9; + // ID2D1Factory::CreatePathGeometry = vtable index 10 + private const int Factory_CreatePathGeometry = 10; + // ID2D1PathGeometry::Open = vtable index 17 + private const int PathGeometry_Open = 17; + + [ThreadStatic] private static int _splitCount; + + [DllImport("d2d1.dll")] + private static extern int D2D1CreateFactory( + int factoryType, [MarshalAs(UnmanagedType.LPStruct)] Guid riid, + IntPtr factoryOptions, out IntPtr factory); + + private static readonly Guid IID_ID2D1Factory = + new Guid("06152247-6f50-465a-9245-118bfd3b6007"); + + public static void Apply() + { + Logger.Info("Applying bezier split budget (NativeHook + ComHook)..."); + + try + { + // 1. Hook d2d_geometry_split_bezier via NativeHook + if (!NativePatch.TryGetSection("d2d1", ".text", out IntPtr textStart, out int textSize)) + { + Logger.Warning("Bezier split budget: .text section not found in d2d1.dll"); + return; + } + + IntPtr funcAddr = ScanForPattern(textStart, textSize, SplitBezierPattern); + if (funcAddr == IntPtr.Zero) + { + Logger.Warning("Bezier split budget: split_bezier pattern not found in d2d1.dll"); + return; + } + + _original = NativeHook.Hook( + funcAddr, PrologueSize, new SplitBezierFn(OnSplitBezier)); + + Logger.Info($"Bezier split budget: split_bezier hooked at d2d1+0x{(funcAddr.ToInt64() - GetModuleHandle("d2d1").ToInt64()):X}"); + + // 2. Hook ID2D1GeometrySink::Close via ComHook to reset counter + IntPtr factory = IntPtr.Zero, pathGeometry = IntPtr.Zero, sink = IntPtr.Zero; + try + { + int hr = D2D1CreateFactory(0, IID_ID2D1Factory, IntPtr.Zero, out factory); + if (hr < 0) return; + + var createPG = ComHook.GetMethod(factory, Factory_CreatePathGeometry); + hr = createPG(factory, out pathGeometry); + if (hr < 0) return; + + var open = ComHook.GetMethod(pathGeometry, PathGeometry_Open); + hr = open(pathGeometry, out sink); + if (hr < 0) return; + + _origClose = ComHook.Hook(sink, Sink_Close, new SinkCloseFn(OnClose)); + Logger.Info("Bezier split budget: sink Close hooked for counter reset"); + } + finally + { + if (sink != IntPtr.Zero) Marshal.Release(sink); + if (pathGeometry != IntPtr.Zero) Marshal.Release(pathGeometry); + if (factory != IntPtr.Zero) Marshal.Release(factory); + } + } + catch (Exception ex) + { + Logger.Error("Failed to install bezier split budget", ex); + } + } + + private static int OnSplitBezier(IntPtr figures, IntPtr idx) + { + if (++_splitCount > MaxSplitsPerOperation) + { + if (_splitCount == MaxSplitsPerOperation + 1) + Logger.Debug($"Bezier split budget: capped at {MaxSplitsPerOperation} splits"); + return 0; // FALSE — tell caller to stop splitting + } + + return _original(figures, idx); + } + + private static int OnClose(IntPtr self) + { + _splitCount = 0; + return _origClose(self); + } + + private static unsafe IntPtr ScanForPattern(IntPtr start, int size, byte[] pattern) + { + byte* ptr = (byte*)start; + int limit = size - pattern.Length; + for (int i = 0; i <= limit; i++) + { + bool match = true; + for (int j = 0; j < pattern.Length; j++) + { + if (ptr[i + j] != pattern[j]) { match = false; break; } + } + if (match) return (IntPtr)(ptr + i); + } + return IntPtr.Zero; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr GetModuleHandle(string lpModuleName); + } +} diff --git a/WineFix/Patches/BezierSplitGuardPatch.cs b/WineFix/Patches/BezierSplitGuardPatch.cs new file mode 100644 index 0000000..520deb5 --- /dev/null +++ b/WineFix/Patches/BezierSplitGuardPatch.cs @@ -0,0 +1,114 @@ +using System; +using System.Runtime.InteropServices; +using AffinityPluginLoader.Core; +using AffinityPluginLoader.Native; + +namespace WineFix.Patches +{ + /// + /// Prevents runaway recursion in d2d_geometry_intersect_bezier_bezier by + /// returning early when bezier parameter ranges shrink below 1e-6. + /// + /// Wine's geometry intersection code recurses without bound on overlapping + /// or collinear beziers, causing Affinity to hang on complex vector paths + /// (e.g. editing embedded SVGs). + /// + /// Based on: + /// 0005-d2d1-prevent-runaway-bezier-splitting-and-recursion-.patch + /// by Arecsu (https://github.com/Arecsu/wine-affinity) + /// + public static class BezierSplitGuardPatch + { + // Function signature (from Wine dlls/d2d1/geometry.c): + // static BOOL d2d_geometry_intersect_bezier_bezier( + // struct d2d_geometry *geometry, // rcx + // struct d2d_geometry_intersections *intersections, // rdx + // const struct d2d_segment_idx *idx_p, // r8 + // float start_p, // xmm3 + // float end_p, // [rsp+0x28] + // const struct d2d_segment_idx *idx_q, // [rsp+0x30] + // float start_q, // [rsp+0x38] + // float end_q) // [rsp+0x40] + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + private delegate int IntersectBezierBezierFn( + IntPtr geometry, IntPtr intersections, IntPtr idx_p, + float start_p, float end_p, IntPtr idx_q, float start_q, float end_q); + + private static IntersectBezierBezierFn _original; + + // Prologue pattern: 8 pushes + sub rsp,0xe8 — unique across EW 7.9, TKG 11.6, Staging 11.5 + private static readonly byte[] ProloguePattern = { + 0x41, 0x57, 0x41, 0x56, 0x41, 0x55, 0x41, 0x54, + 0x55, 0x57, 0x56, 0x53, + 0x48, 0x81, 0xEC, 0xE8, 0x00, 0x00, 0x00 + }; + + private const int PrologueSize = 12; // 8 pushes = 12 bytes, instruction boundary + private const float MinRange = 1e-6f; + + public static void Apply() + { + Logger.Info("Applying bezier split recursion guard (NativeHook)..."); + + try + { + if (!NativePatch.TryGetSection("d2d1", ".text", out IntPtr textStart, out int textSize)) + { + Logger.Warning("Bezier split guard: .text section not found in d2d1.dll"); + return; + } + + IntPtr funcAddr = ScanForPattern(textStart, textSize, ProloguePattern); + if (funcAddr == IntPtr.Zero) + { + Logger.Warning("Bezier split guard: function pattern not found in d2d1.dll " + + "(may already be fixed in this Wine version)"); + return; + } + + _original = NativeHook.Hook( + funcAddr, PrologueSize, new IntersectBezierBezierFn(OnIntersectBezierBezier)); + + Logger.Info($"Bezier split recursion guard installed at d2d1+0x{(funcAddr.ToInt64() - GetModuleHandle("d2d1").ToInt64()):X}"); + } + catch (Exception ex) + { + Logger.Error("Failed to install bezier split recursion guard", ex); + } + } + + private static int OnIntersectBezierBezier( + IntPtr geometry, IntPtr intersections, IntPtr idx_p, + float start_p, float end_p, IntPtr idx_q, float start_q, float end_q) + { + if (end_p - start_p < MinRange || end_q - start_q < MinRange) + { + Logger.Debug($"Bezier split guard: early abort (range_p={end_p - start_p:E2}, range_q={end_q - start_q:E2})"); + return 1; // TRUE — bail out + } + + return _original(geometry, intersections, idx_p, + start_p, end_p, idx_q, start_q, end_q); + } + + private static unsafe IntPtr ScanForPattern(IntPtr start, int size, byte[] pattern) + { + byte* ptr = (byte*)start; + int limit = size - pattern.Length; + for (int i = 0; i <= limit; i++) + { + bool match = true; + for (int j = 0; j < pattern.Length; j++) + { + if (ptr[i + j] != pattern[j]) { match = false; break; } + } + if (match) return (IntPtr)(ptr + i); + } + return IntPtr.Zero; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr GetModuleHandle(string lpModuleName); + } +} diff --git a/WineFix/Patches/WidenStubPatch.cs b/WineFix/Patches/WidenStubPatch.cs new file mode 100644 index 0000000..f5d54fb --- /dev/null +++ b/WineFix/Patches/WidenStubPatch.cs @@ -0,0 +1,122 @@ +using System; +using System.Runtime.InteropServices; +using AffinityPluginLoader.Core; +using AffinityPluginLoader.Native; + +namespace WineFix.Patches +{ + /// + /// Stubs ID2D1PathGeometry1::Widen to return S_OK with an empty closed sink + /// instead of E_NOTIMPL. Prevents Affinity from hanging indefinitely when + /// clicking stroked SVG vectors. + /// + /// Stroke rendering will be absent but the application remains usable. + /// + /// Based on: + /// 0002-d2d1-stub-Widen-with-empty-geometry-to-prevent-calle.patch + /// by Arecsu (https://github.com/Arecsu/wine-affinity) + /// + public static class WidenStubPatch + { + // ID2D1Geometry vtable layout (from d2d1.h): + // IUnknown(3) + ID2D1Resource(1) + GetBounds(4) + GetWidenedBounds(5) + + // StrokeContainsPoint(6) + FillContainsPoint(7) + CompareWithGeometry(8) + + // Simplify(9) + Tessellate(10) + CombineWithGeometry(11) + Outline(12) + + // ComputeArea(13) + ComputeLength(14) + ComputePointAtLength(15) + Widen(16) + private const int Geometry_Widen = 16; + + // ID2D1SimplifiedGeometrySink vtable: + // IUnknown(3) + SetFillMode(3) + SetSegmentFlags(4) + BeginFigure(5) + + // AddLines(6) + AddBeziers(7) + EndFigure(8) + Close(9) + private const int Sink_SetFillMode = 3; + private const int Sink_Close = 9; + + private const int D2D1_FILL_MODE_WINDING = 1; + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + private delegate int WidenFn(IntPtr self, float strokeWidth, IntPtr strokeStyle, + IntPtr transform, float tolerance, IntPtr sink); + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + private delegate void SetFillModeFn(IntPtr self, int fillMode); + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + private delegate int CloseFn(IntPtr self); + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + private delegate int CreatePathGeometryFn(IntPtr factory, out IntPtr pathGeometry); + + [DllImport("d2d1.dll")] + private static extern int D2D1CreateFactory( + int factoryType, [MarshalAs(UnmanagedType.LPStruct)] Guid riid, + IntPtr factoryOptions, out IntPtr factory); + + private static readonly Guid IID_ID2D1Factory = + new Guid("06152247-6f50-465a-9245-118bfd3b6007"); + + private const int Factory_CreatePathGeometry = 10; + + public static void Apply() + { + Logger.Info("Applying Widen stub fix (COM vtable hook)..."); + + IntPtr factory = IntPtr.Zero; + IntPtr pathGeometry = IntPtr.Zero; + + try + { + int hr = D2D1CreateFactory(0, IID_ID2D1Factory, IntPtr.Zero, out factory); + if (hr < 0 || factory == IntPtr.Zero) + { + Logger.Error($"Widen stub: D2D1CreateFactory failed: hr=0x{hr:X8}"); + return; + } + + var createPathGeometry = ComHook.GetMethod( + factory, Factory_CreatePathGeometry); + hr = createPathGeometry(factory, out pathGeometry); + if (hr < 0 || pathGeometry == IntPtr.Zero) + { + Logger.Error($"Widen stub: CreatePathGeometry failed: hr=0x{hr:X8}"); + return; + } + + ComHook.Hook(pathGeometry, Geometry_Widen, new WidenFn(OnWiden)); + + Logger.Info("Widen stub fix installed (ID2D1PathGeometry vtable patched)"); + } + catch (Exception ex) + { + Logger.Error("Failed to install Widen stub fix", ex); + } + finally + { + if (pathGeometry != IntPtr.Zero) Marshal.Release(pathGeometry); + if (factory != IntPtr.Zero) Marshal.Release(factory); + } + } + + private static int OnWiden(IntPtr self, float strokeWidth, IntPtr strokeStyle, + IntPtr transform, float tolerance, IntPtr sink) + { + if (sink == IntPtr.Zero) + return 0; // S_OK + + try + { + var setFillMode = ComHook.GetMethod(sink, Sink_SetFillMode); + var close = ComHook.GetMethod(sink, Sink_Close); + + setFillMode(sink, D2D1_FILL_MODE_WINDING); + close(sink); + } + catch + { + // Swallow — better to return S_OK with a possibly-unclosed sink + // than to let the app hang on E_NOTIMPL + } + + return 0; // S_OK + } + } +} diff --git a/WineFix/README.md b/WineFix/README.md index 30bc51e..4dd1733 100644 --- a/WineFix/README.md +++ b/WineFix/README.md @@ -16,6 +16,8 @@ For detailed instructions, see the [WineFix Installation Guide](https://apl.ncur - **Bezier rendering fix** — Cubic Bézier curves render incorrectly under Wine. Fixed by hooking the `ID2D1GeometrySink` COM vtable to subdivide cubic Béziers into quadratics at runtime using adaptive De Casteljau subdivision. Works across all Wine versions. - **Collinear join fix** — Spike artifacts appear at smooth curve joins after Bézier subdivision. Fixed by patching d2d1.dll in memory to zero the erroneous 25-unit vertex offset for collinear outline joins. +- **Widen stub fix** — Affinity can hang when interacting with stroked SVG vectors because Wine's `ID2D1PathGeometry::Widen` returns `E_NOTIMPL`. Fixed by hooking the vtable to return an empty geometry instead. +- **Bezier split recursion/budget fix** — Affinity hangs on complex vector paths (e.g. embedded SVGs with overlapping Bézier curves) due to unbounded recursion and splitting in Wine's geometry processing. Fixed by detouring the recursion and split functions with guards that cap recursion depth and total splits. - **Preferences save fix** — Preferences fail to save on application exit. Fixed via Harmony transpiler replacing `HasPreviousPackageInstalled()` with `false`. - **Color picker Wayland fix** — Color picker zoom preview displays a black image on Wayland. Fixed by patching the magnifier capture to use Wine's window capture instead of screen capture. - **Font enumeration fix** — Intermittent startup crash from parallel font enumeration. Fixed by forcing synchronous font loading. @@ -27,7 +29,7 @@ WineFix is configurable from Affinity's preferences dialog, TOML files, or envir ## Known Open Bugs -- Embedded SVG document editor crashes after being open for some time +- Crash reporting acceptance causes permanent crash until prefs cleared We are open to resolving any Wine-specific bugs. Feel free to [open an issue](https://github.com/noahc3/AffinityPluginLoader/issues) requesting a patch. @@ -45,7 +47,7 @@ WineFix is licensed under **GPLv2**. See the [LICENSE](LICENSE) file. Big thanks to the following projects: - [AffinityOnLinux](https://github.com/seapear/AffinityOnLinux) -- [Arecsu/wine-affinity](https://github.com/Arecsu/wine-affinity) — collinear outline join fix +- [Arecsu/wine-affinity](https://github.com/Arecsu/wine-affinity) — collinear outline join fix, Widen stub fix, bezier split recursion/budget fix - [Harmony](https://github.com/pardeike/Harmony) - [ElementalWarrior wine](https://gitlab.winehq.org/ElementalWarrior/wine) - [Upstream wine](https://gitlab.winehq.org/wine/wine) diff --git a/WineFix/WineFixPlugin.cs b/WineFix/WineFixPlugin.cs index 6aa109e..3128f6e 100644 --- a/WineFix/WineFixPlugin.cs +++ b/WineFix/WineFixPlugin.cs @@ -19,6 +19,8 @@ public class WineFixPlugin : AffinityPlugin public const string CollinearJoinFixKey = "collinear_join_fix"; public const string SettingForceSyncFontEnum = "force_sync_font_enum"; public const string SettingCanvaSignInHelper = "canva_sign_in_helper"; + public const string SettingWidenStubFix = "widen_stub_fix"; + public const string SettingBezierSplitGuard = "bezier_split_guard"; public override PluginSettingsDefinition DefineSettings() { @@ -36,6 +38,14 @@ public override PluginSettingsDefinition DefineSettings() defaultValue: true, restartRequired: true, description: "Apply patch to fix tangent line flicker artifacts between collinear bezier curve subdivisions.") + .AddBool(SettingWidenStubFix, "Stroked paths: Prevent freeze on Widen", + defaultValue: true, + restartRequired: true, + description: "Stub the unimplemented ID2D1PathGeometry::Widen method to return an empty geometry instead of E_NOTIMPL. Prevents Affinity from hanging when clicking stroked SVG vectors. Stroke rendering will be absent but the app remains usable.") + .AddBool(SettingBezierSplitGuard, "Bezier curves: Prevent freeze on complex paths", + defaultValue: true, + restartRequired: true, + description: "Guard against runaway recursion in Wine's bezier intersection code. Prevents Affinity from hanging when editing complex vector paths (e.g. embedded SVGs with overlapping bezier curves).") .AddEnum(ColorPickerMagnifierFixKey, "Color picker: Wayland zoom magnifier fix", new List { @@ -75,6 +85,17 @@ public override void OnPatch(Harmony harmony, IPluginContext context) Patches.CollinearJoinPatch.Apply(); } + if (context.Settings.GetEffectiveValue(SettingWidenStubFix)) + { + Patches.WidenStubPatch.Apply(); + } + + if (context.Settings.GetEffectiveValue(SettingBezierSplitGuard)) + { + Patches.BezierSplitGuardPatch.Apply(); + Patches.BezierSplitBudgetPatch.Apply(); + } + context.Patch("MainWindowLoaded fix", h => Patches.MainWindowLoadedPatch.ApplyPatches(h)); diff --git a/docs/dev/native-apis.md b/docs/dev/native-apis.md index 0f079ac..1c548a2 100644 --- a/docs/dev/native-apis.md +++ b/docs/dev/native-apis.md @@ -150,6 +150,66 @@ NativePatch.Patch("d2d1", ".text", |---|---| | Hook a COM interface method (D2D1, DirectWrite, DXGI, etc.) | `ComHook.Hook` | | Call a COM method without hooking it | `ComHook.GetMethod` | +| Detour a native function by address (inline hook) | `NativeHook.Hook` | | Patch a specific byte pattern in a native DLL | `NativePatch.Patch` | | Custom scanning of a native DLL's memory | `NativePatch.TryGetSection` | | Patch .NET methods (Affinity's managed code) | [Harmony](creating-a-plugin.md#patching-with-harmony) | + +## NativeHook + +Inline function detouring for native code. Overwrites a function's prologue with a jump to a managed delegate, and creates an executable trampoline so the hook can call through to the original function. + +Supports two prologue sizes: + +- **Large (≥ 12 bytes):** Direct absolute jump (`mov rax, imm64; jmp rax`) at the target site. +- **Small (≥ 5 bytes):** Relative jump (`jmp rel32`) to a nearby relay thunk allocated within ±2GB of the target. + +### NativeHook.Hook + +Detour a native function at a given address. + +```csharp +using AffinityPluginLoader.Native; + +[UnmanagedFunctionPointer(CallingConvention.StdCall)] +delegate int MyFuncFn(IntPtr arg1, IntPtr arg2); + +// Hook the function — returns a delegate that calls the original +MyFuncFn original = NativeHook.Hook(funcAddress, prologueSize, new MyFuncFn(MyHook)); + +static int MyHook(IntPtr arg1, IntPtr arg2) +{ + // Custom logic — can call original() to pass through + return original(arg1, arg2); +} +``` + +**Parameters:** + +| Parameter | Description | +|---|---| +| `target` | `IntPtr` address of the function to hook | +| `prologueSize` | Number of bytes to overwrite (≥ 5). Must end on an instruction boundary. | +| `hook` | Replacement delegate (kept alive internally to prevent GC) | + +**Returns:** A delegate wrapping the original function (via trampoline). + +!!! warning "Prologue constraints" + - `prologueSize` must be ≥ 5 (for relative jump) or ≥ 12 (for absolute jump). + - The overwritten bytes must end on an instruction boundary. + - The relocated bytes **must not** contain RIP-relative instructions — choose a prologue size that avoids them. + - The target function must not be executing on any thread when hooked. + +### Example: Hooking a Non-Exported Function by Pattern Scan + +```csharp +// Find the function by scanning for its unique prologue bytes +if (NativePatch.TryGetSection("d2d1", ".text", out IntPtr start, out int size)) +{ + byte[] pattern = { 0x56, 0x53, 0x48, 0x83, 0xEC, 0x48 }; // push rsi; push rbx; sub rsp,0x48 + IntPtr funcAddr = ScanForPattern(start, size, pattern); + + // Hook with 6-byte prologue (uses relative jump + nearby relay) + original = NativeHook.Hook(funcAddr, 6, new MyFuncFn(MyHook)); +} +``` diff --git a/docs/winefix/configuration.md b/docs/winefix/configuration.md index a673e5c..84fc059 100644 --- a/docs/winefix/configuration.md +++ b/docs/winefix/configuration.md @@ -12,6 +12,11 @@ WineFix uses plugin ID `winefix`. Settings are stored in `apl/config/winefix.tom | Key | Type | Default | Restart Required | Description | |---|---|---|---|---| +| `canva_sign_in_helper` | bool | `true` | Yes | Canva sign-in paste URL helper. | +| `bezier_rendering_fix` | bool | `true` | Yes | Cubic-to-quadratic Bézier subdivision for accurate path rendering. | +| `collinear_join_fix` | bool | `true` | Yes | Fix spike artifacts at collinear outline joins. | +| `widen_stub_fix` | bool | `true` | Yes | Stub `ID2D1PathGeometry::Widen` to prevent freeze on stroked paths. | +| `bezier_split_guard` | bool | `true` | Yes | Recursion guard and split budget to prevent freeze on complex vector paths. | | `color_picker_magnifier_fix` | enum | `auto` | Yes | Wayland zoom preview fix. Replaces `CopyFromScreen` (which returns black on Wayland) with a `BitBlt` from the canvas window. | | `color_picker_sampling_mode` | enum | `native` | No | Controls how the color picker samples color values. See [Sampling Modes](index.md#color-picker-sampling-modes). | diff --git a/docs/winefix/index.md b/docs/winefix/index.md index 52420c0..7df9814 100644 --- a/docs/winefix/index.md +++ b/docs/winefix/index.md @@ -25,6 +25,19 @@ When two adjacent outline segments are collinear, Wine's `d2d_geometry_outline_a The patch is applied by scanning d2d1.dll's `.text` section for the `movss xmm0, [25.0f]` instruction and replacing it with `xorps xmm0, xmm0` (0.0f). Based on a [Wine patch by Arecsu](https://github.com/Arecsu/wine-affinity). +### Widen stub fix + +Wine's `ID2D1PathGeometry::Widen` returns `E_NOTIMPL`, which can cause Affinity to hang indefinitely when interacting with stroked path geometries (e.g. stroked SVG vectors). WineFix hooks the `Widen` vtable entry via `ComHook` to return `S_OK` with an empty closed geometry sink instead. Stroke rendering will be absent but the application remains usable. Based on a [Wine patch by Arecsu](https://github.com/Arecsu/wine-affinity). + +### Bezier split recursion and budget fix + +Wine's geometry processing code can enter unbounded recursion or unbounded splitting loops on complex or pathological vector paths (e.g. overlapping Bézier curves in embedded SVGs), causing Affinity to hang. WineFix applies two guards using APL's `NativeHook` API: + +- **Recursion guard:** Detours `d2d_geometry_intersect_bezier_bezier` to return early when Bézier parameter ranges shrink below 1e-6, preventing infinite recursion on overlapping or collinear Béziers. +- **Split budget:** Detours `d2d_geometry_split_bezier` with a thread-local call counter that caps splits at 512 per geometry sink `Close` operation, preventing unbounded segment growth. + +Both functions are found by scanning d2d1.dll's `.text` section for their unique prologue byte patterns (verified across ElementalWarrior Wine 7.9, Wine Staging 11.5, and TKG Staging 11.6). Based on a [Wine patch by Arecsu](https://github.com/Arecsu/wine-affinity). + ### Preferences save fix Preferences fail to save on application exit under Wine. A Harmony transpiler replaces the call to `HasPreviousPackageInstalled()` with `false`, which otherwise throws an exception that blocks the preferences save path. @@ -55,7 +68,7 @@ Intermittent startup crash from parallel font enumeration in `libkernel.dll`. Fo These are under investigation and not yet patched: -- Embedded SVG document editor crashes after being open for some time +- Crash reporting acceptance causes permanent crash until prefs cleared We are open to resolving any Wine-specific bugs. Feel free to [open an issue](https://github.com/noahc3/AffinityPluginLoader/issues) requesting a patch — just keep in mind these bugs take time to research and develop patches for, especially when native code is involved.