Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 1 addition & 38 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,40 +104,11 @@ jobs:
path: WineFix/bin/Debug/net48/win-x64/WineFix.dll
if-no-files-found: error

# Build d2d1.dll for Wine (x86_64-unix)
build-d2d1:
name: Build d2d1.dll (Wine Native)
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Install Wine development tools
run: |
sudo dpkg --add-architecture i386
sudo mkdir -pm755 /etc/apt/keyrings
sudo wget -O /etc/apt/keyrings/winehq-archive.key https://dl.winehq.org/wine-builds/winehq.key
sudo wget -NP /etc/apt/sources.list.d/ https://dl.winehq.org/wine-builds/ubuntu/dists/jammy/winehq-jammy.sources
sudo apt update
sudo apt install -y --install-recommends wine64-tools

- name: Build d2d1.dll for x86_64-unix
working-directory: WineFix/lib/d2d1
run: make TARGET=x86_64-unix

- name: Upload d2d1 artifact
uses: actions/upload-artifact@v4
with:
name: d2d1
path: WineFix/lib/d2d1/build/x86_64-unix/d2d1.dll.so
if-no-files-found: error

# Package all artifacts into release archives
package:
name: Package Release Artifacts
runs-on: ubuntu-latest
needs: [build-bootstrap, build-csharp, build-d2d1]
needs: [build-bootstrap, build-csharp]

steps:
- name: Checkout code
Expand Down Expand Up @@ -181,12 +152,6 @@ jobs:
name: WineFix
path: build/winefix

- name: Download d2d1 artifact
uses: actions/download-artifact@v4
with:
name: d2d1
path: build/d2d1

- name: Create affinitypluginloader package structure
run: |
mkdir -p package/affinitypluginloader
Expand All @@ -202,7 +167,6 @@ jobs:
mkdir -p package/winefix/apl/plugins
cp build/winefix/WineFix.dll package/winefix/apl/plugins/
cp WineFix/LICENSE package/winefix/apl/plugins/LICENSE
cp build/d2d1/d2d1.dll.so package/winefix/d2d1.dll

- name: Create combined package structure
run: |
Expand All @@ -212,7 +176,6 @@ jobs:
cp build/pluginloader/AffinityPluginLoader.dll package/combined/
cp build/pluginloader/0Harmony.dll package/combined/
cp build/winefix/WineFix.dll package/combined/apl/plugins/
cp build/d2d1/d2d1.dll.so package/combined/d2d1.dll

- name: Create release archives
run: |
Expand Down
1 change: 1 addition & 0 deletions AffinityPluginLoader/AffinityPluginLoader.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<PropertyGroup>
<Product>Affinity Plugin Loader</Product>
Expand Down
74 changes: 74 additions & 0 deletions AffinityPluginLoader/Native/ComHook.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using AffinityPluginLoader.Core;

namespace AffinityPluginLoader.Native
{
/// <summary>
/// Hooks COM interface methods by patching vtable entries in memory.
/// COM vtables are shared across all instances of a given implementation,
/// so hooking one object's vtable affects all objects of that type.
/// </summary>
public static class ComHook
{
// prevent GC of hook delegates whose function pointers are written into vtables
private static readonly List<Delegate> _pinnedDelegates = new List<Delegate>();

/// <summary>
/// Replace a COM vtable entry with a managed delegate.
/// Returns a delegate wrapping the original native method.
/// </summary>
/// <typeparam name="T">
/// Delegate type decorated with [UnmanagedFunctionPointer].
/// First parameter must be IntPtr (the COM 'this' pointer).
/// </typeparam>
/// <param name="comObject">Pointer to any COM instance using the target vtable</param>
/// <param name="vtableIndex">Zero-based method index in the vtable</param>
/// <param name="hook">Replacement delegate</param>
/// <returns>Delegate wrapping the original native method</returns>
public static T Hook<T>(IntPtr comObject, int vtableIndex, T hook) where T : class
{
if (comObject == IntPtr.Zero)
throw new ArgumentNullException(nameof(comObject));
if (!(hook is Delegate hookDelegate))
throw new ArgumentException("T must be a delegate type");

IntPtr vtable = Marshal.ReadIntPtr(comObject);
IntPtr entryAddr = vtable + vtableIndex * IntPtr.Size;
IntPtr original = Marshal.ReadIntPtr(entryAddr);

IntPtr hookPtr = Marshal.GetFunctionPointerForDelegate(hookDelegate);

VirtualProtect(entryAddr, (UIntPtr)IntPtr.Size, PAGE_READWRITE, out uint oldProtect);
Marshal.WriteIntPtr(entryAddr, hookPtr);
VirtualProtect(entryAddr, (UIntPtr)IntPtr.Size, oldProtect, out _);

_pinnedDelegates.Add(hookDelegate);

Logger.Debug($"ComHook: patched vtable[{vtableIndex}] at 0x{entryAddr.ToInt64():X} " +
$"(0x{original.ToInt64():X} -> 0x{hookPtr.ToInt64():X})");

return Marshal.GetDelegateForFunctionPointer<T>(original);
}

/// <summary>
/// Read a COM vtable entry and return it as a callable delegate without modifying the vtable.
/// </summary>
public static T GetMethod<T>(IntPtr comObject, int vtableIndex) where T : class
{
if (comObject == IntPtr.Zero)
throw new ArgumentNullException(nameof(comObject));

IntPtr vtable = Marshal.ReadIntPtr(comObject);
IntPtr original = Marshal.ReadIntPtr(vtable + vtableIndex * IntPtr.Size);
return Marshal.GetDelegateForFunctionPointer<T>(original);
}

private const uint PAGE_READWRITE = 0x04;

[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool VirtualProtect(IntPtr lpAddress, UIntPtr dwSize,
uint flNewProtect, out uint lpflOldProtect);
}
}
117 changes: 117 additions & 0 deletions AffinityPluginLoader/Native/NativePatch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
using System;
using System.Runtime.InteropServices;
using AffinityPluginLoader.Core;

namespace AffinityPluginLoader.Native
{
/// <summary>
/// Utilities for patching native DLLs in memory at runtime.
/// </summary>
public static class NativePatch
{
/// <summary>
/// Get the start address and size of a named section in a loaded module.
/// </summary>
/// <param name="moduleName">DLL name (e.g. "d2d1")</param>
/// <param name="sectionName">Section name (e.g. ".text")</param>
/// <param name="start">Start address of the section in memory</param>
/// <param name="size">Size of the section in bytes</param>
/// <returns>True if the module and section were found</returns>
public static unsafe bool TryGetSection(string moduleName, string sectionName,
out IntPtr start, out int size)
{
start = IntPtr.Zero;
size = 0;

IntPtr moduleBase = GetModuleHandle(moduleName);
if (moduleBase == IntPtr.Zero)
return false;

byte* basePtr = (byte*)moduleBase;
int peOffset = *(int*)(basePtr + 0x3C);
short numSections = *(short*)(basePtr + peOffset + 6);
short optHeaderSize = *(short*)(basePtr + peOffset + 20);
byte* sectionTable = basePtr + peOffset + 24 + optHeaderSize;

for (int i = 0; i < numSections; i++)
{
byte* sec = sectionTable + i * 40;
bool match = true;
for (int j = 0; j < sectionName.Length && j < 8; j++)
{
if (sec[j] != (byte)sectionName[j]) { match = false; break; }
}
if (match && (sectionName.Length >= 8 || sec[sectionName.Length] == 0))
{
size = *(int*)(sec + 8);
int rva = *(int*)(sec + 12);
start = (IntPtr)(basePtr + rva);
return true;
}
}

return false;
}

/// <summary>
/// Scan a memory region for a byte pattern with optional wildcard mask,
/// and replace the first match with the given bytes.
/// </summary>
/// <param name="moduleName">DLL name</param>
/// <param name="sectionName">Section to scan</param>
/// <param name="pattern">Byte pattern to find</param>
/// <param name="replacement">Bytes to write at the match site</param>
/// <param name="mask">
/// Optional mask the same length as pattern. 0xFF = must match, 0x00 = wildcard.
/// Null means all bytes must match exactly.
/// </param>
/// <returns>The address where the patch was applied, or IntPtr.Zero if not found</returns>
public static unsafe IntPtr Patch(string moduleName, string sectionName,
byte[] pattern, byte[] replacement, byte[] mask = null)
{
if (!TryGetSection(moduleName, sectionName, out IntPtr start, out int size))
{
Logger.Warning($"NativePatch: section {sectionName} not found in {moduleName}");
return IntPtr.Zero;
}

byte* ptr = (byte*)start;
int scanLimit = size - pattern.Length;

for (int i = 0; i <= scanLimit; i++)
{
bool found = true;
for (int j = 0; j < pattern.Length; j++)
{
byte m = mask != null ? mask[j] : (byte)0xFF;
if ((ptr[i + j] & m) != (pattern[j] & m)) { found = false; break; }
}

if (found)
{
IntPtr site = (IntPtr)(ptr + i);
VirtualProtect(site, (UIntPtr)replacement.Length,
PAGE_EXECUTE_READWRITE, out uint oldProtect);
Marshal.Copy(replacement, 0, site, replacement.Length);
VirtualProtect(site, (UIntPtr)replacement.Length,
oldProtect, out _);

IntPtr moduleBase = GetModuleHandle(moduleName);
Logger.Debug($"NativePatch: patched {moduleName}+0x{(ptr + i - (byte*)moduleBase):X}");
return site;
}
}

return IntPtr.Zero;
}

private const uint PAGE_EXECUTE_READWRITE = 0x40;

[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr GetModuleHandle(string lpModuleName);

[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool VirtualProtect(IntPtr lpAddress, UIntPtr dwSize,
uint flNewProtect, out uint lpflOldProtect);
}
}
Loading
Loading