Skip to content

Fix bootstrap DLL and hook injection under Wine, and select the resume mechanism per host#25

Open
NayiemW wants to merge 2 commits into
Phobos-developers:masterfrom
the-real-antares:wine-injection-jmp-redirect
Open

Fix bootstrap DLL and hook injection under Wine, and select the resume mechanism per host#25
NayiemW wants to merge 2 commits into
Phobos-developers:masterfrom
the-real-antares:wine-injection-jmp-redirect

Conversation

@NayiemW

@NayiemW NayiemW commented Jul 10, 2026

Copy link
Copy Markdown

Fix bootstrap DLL and hook injection under Wine, and select the resume mechanism per host

Problem

When Syringe runs under Wine, including Wine-based setups on macOS, Linux, and Proton, DLL injection and hook installation can silently fail.

The debugger loop continues running and the log may report the expected number of hooks, but the target process actually runs without any of them installed.

In CnCNet's Yuri's Revenge setup using Ares, Phobos, and CnCNet-Spawner, this causes the game to exit before spawn.ini is read.

Root cause

Syringe previously redirected execution by modifying the debuggee thread's Eip through SetThreadContext.

Wine's WoW64 debug-event resume path does not reliably honor these Eip changes when redirecting execution to a distant, dynamically allocated address, such as the LoadLibrary bootstrap stub in pAlloc.

This failure is particularly misleading:

  • SetThreadContext reports success.
  • A subsequent GetThreadContext call reports the requested Eip.
  • The thread nevertheless resumes from its original execution location.
  • DLL loading, feature resolution, and hook installation therefore silently do not occur.

This behavior was reproduced independently using a minimal target executable and debugger outside this codebase. In that test, redirects performed through WriteProcessMemory were honored, while redirects performed through SetThreadContext(Eip = ...) were not.

Fix

Execution is redirected by writing an actual relative JMP instruction at the CPU's real resume address, rather than relying on a thread-context mutation.

For an INT3 breakpoint, the CPU resumes at breakpointAddress + 1, so the redirect is written there using WriteProcessMemory.

The implementation adds the following:

WriteRedirectJmp

Writes a five-byte relative JMP at the debuggee's resume address, redirecting execution to the required bootstrap or continuation code.

DetermineOverwriteSize

Decodes complete instructions until enough bytes are available for a safe five-byte jump, avoiding partial instruction overwrites.

BuildEntryTrampoline

Creates a trampoline for returning to the executable entry point.

Because the entry point may contain instructions larger than the required jump, the trampoline:

  1. determines how many complete instructions must be overwritten;
  2. copies and rebuilds those instructions using the existing Zydis-based RebuildInstructions logic; and
  3. jumps back to the first untouched instruction at the original entry point.

CreateCodeHooks

The existing hook-generation and installation logic has been extracted into a dedicated method.

Hook creation now runs directly after DLL loading and feature-flag resolution complete. Syringe no longer redirects execution back to pcEntryPoint and waits for another breakpoint before creating the hooks, since that flow depended on the same unreliable SetThreadContext redirection.

Resume mechanism selection per host

The written-JMP redirect is required on mainline Wine, but it is not correct everywhere.

Under CrossOver on Apple Silicon, the x86-to-ARM translator faults when the freshly written entry trampoline is executed, raising an access violation at the entry point before the game starts. CrossOver, unlike mainline Wine, does honor a SetThreadContext Eip redirect — the original mechanism works there.

The two resume strategies are therefore complementary, so Syringe now detects the host once at startup and chooses accordingly:

  • Native Windows and CrossOver resume through SetThreadContext. On these hosts the entry trampoline is never built and the entry point is resumed unmodified.
  • Mainline Wine (including Whisky and other plain-Wine setups on macOS/Linux/Proton) resumes through the written-JMP trampoline described above.

This only changes the resume primitive per host; the surrounding bootstrap flow (synchronous hook creation, no single-stepping) is the same on both paths.

Detection uses registry and export signals rather than the Wine version string, which is not usable here — CrossOver reports a plain wine-11.0-... build indistinguishable from mainline Wine:

  • Wine vs. native Windows — the presence of Wine's ntdll exports (wine_get_version / wine_get_build_id), with the HKCU\Software\Wine configuration key as a fallback for Wine builds configured to hide those exports. Native Windows has neither.
  • CrossOver vs. mainline Wine — the registry keys CrossOver writes into its bottles, HKCU\Software\CrossOver or HKLM\Software\CodeWeavers\CrossOver; either is sufficient. Plain Wine has neither.

A single helper, RedirectExecution, encapsulates the branch: it sets Eip on the context path and writes a JMP on the trampoline path. All bootstrap and continuation redirects go through it, so the mainline-Wine path remains byte-for-byte the written-JMP behavior.

Additional corrections

The bootstrap stub's embedded INT3 is no longer restored during the feature-flag loop.

That breakpoint was created directly inside the allocated bootstrap code and was never registered through SetBP. Attempting to restore it through the breakpoint map therefore used an invalid default entry and corrupted the bootstrap stub during subsequent iterations.

The --detach completion condition now checks bHooksCreated directly.

Previously, detachment also required a single-step exception because hook creation was detected after redirecting execution back to the entry point. Hook creation now happens synchronously, so that single-step event no longer occurs and is no longer required.

Platform impact

On native Windows the resume primitive is SetThreadContext, exactly as stock Syringe used, and the entry trampoline is never built. The surrounding bootstrap follows this PR's synchronous hook-creation flow, shared with the Wine path. The WriteProcessMemory-based JMP redirect is used only on mainline Wine, and it relies on ordinary process-memory patching and relative jumps that are also valid on Windows.

Testing

Tested end-to-end on macOS using Red Alert 2 / Yuri's Revenge with Ares, Phobos, CnCNet-Spawner, and approximately 2,750 installed hooks, on both host types:

  • Mainline Wine — injection and hook installation complete; the game launches directly into a running match instead of exiting before spawn.ini is read. This is the case the written-JMP redirect fixes.
  • CrossOver on Apple Silicon — with per-host selection, the bootstrap resumes through SetThreadContext, feature-flag resolution and all hooks install, and the earlier entry-point access violation no longer occurs.

Native Windows has not been retested on hardware, but its resume path is unchanged from stock Syringe (SetThreadContext, no trampoline), and the memory-patching mechanism used on the Wine path is the same one Syringe already uses elsewhere.

…ext EIP redirects

Root cause: Wine's wow64 debug-event resume path does not reliably honor a
SetThreadContext-based EIP change when redirecting to a distant, dynamically-
allocated address (e.g. into the LoadLibrary bootstrap stub in pAlloc).
GetThreadContext falsely confirms the change took effect, but the debuggee
thread actually just continues from wherever it really was - meaning none of
Syringe's DLL injection or hook installation ever actually happens under Wine,
even though the debug loop appears to proceed normally. Confirmed with an
isolated minimal repro (custom target exe + custom debugger, independent of
this codebase) before touching this file.

Fix: redirect execution by writing a real JMP instruction via WriteProcessMemory
(proven reliable under Wine) at the actual CPU resume address (bpAddr+1, per
standard INT3 semantics), instead of mutating thread context:

- WriteRedirectJmp / DetermineOverwriteSize / BuildEntryTrampoline: new helpers.
  The entry point needs a proper trampoline (built via the existing Zydis-based
  RebuildInstructions) since we do not control how many bytes are safely
  overwritable there, unlike our own stub code where we just add padding.
- CreateCodeHooks: extracted the existing hook-installation logic into its own
  method, now called directly once DLL loading and feature-flag resolution
  finish, rather than relying on redirecting back to pcEntryPoint and waiting
  for a fresh breakpoint there (which depended on the same broken
  SetThreadContext mechanism).
- Do not restore the bootstrap stub's own embedded INT3 in the feature-flag
  loop - it is never registered via SetBP and must stay intact across
  re-entries, since execution is always redirected away from it via
  WriteRedirectJmp rather than ever falling through past it.
- Pad the LoadLibrary bootstrap stub so there is always room for a 5-byte JMP
  right after its embedded INT3.
- Simplify the --detach completion check to test bHooksCreated directly; it
  previously also required a single-step trap that no longer occurs now that
  hook creation runs synchronously.

Verified end-to-end against the actual game (RA2/YR with Ares, Phobos and
CnCNet-Spawner, 2750 hooks) under Wine on macOS: the game now launches directly
into a running match instead of exiting before spawn.ini is ever read.
@github-actions

Copy link
Copy Markdown

Nightly build for this pull request:

This comment is automatic and is meant to allow guests to get latest nightly builds for this pull request without registering. It is updated on every successful build.

The JMP-redirect bootstrap resume added for mainline Wine faults under
CrossOver's x86->ARM translator: executing the freshly-written entry
trampoline raises an access violation at the entry point (0xC0000005),
so the game never launches. CrossOver, unlike mainline Wine, honors a
SetThreadContext Eip change - the original resume primitive works there.

Detect the host at startup and choose accordingly:
- native Windows and CrossOver -> SetThreadContext (no entry trampoline)
- mainline/Whisky Wine -> JMP-redirect trampoline

Native Windows is identified by the absence of Wine's ntdll exports, with
the HKCU\Software\Wine key as a fallback for Wine builds configured to hide
them. CrossOver is distinguished from mainline Wine by the registry keys it
writes into its bottles - HKCU\Software\CrossOver or
HKLM\Software\CodeWeavers\CrossOver - since the Wine build-id string is
unreliable (CrossOver reports a plain wine-11.0 build indistinguishable from
mainline).

On the SetThreadContext path the entry trampoline is never built and the
entry point is resumed pristine; the rest of the bootstrap (synchronous hook
creation, no single-stepping) matches the JMP path rather than stock
Syringe's re-entry flow. The JMP path itself is unchanged from the previous
commit.
@NayiemW NayiemW changed the title Fix bootstrap DLL and hook injection under Wine without SetThreadContext EIP redirects Fix bootstrap DLL and hook injection under Wine, and select the resume mechanism per host Jul 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant