Skip to content

Fix: Skirmish auto-defeat (1-byte patch) + wined3d address-space leak (DXVK) — Wine 11, no Lutris #14

Description

@dariocazas

Sharing a fix in case it helps others here — not a code change, just documentation and a small
reproducible patch. Tested on Fedora 44, Wine 11.0 Staging, AMD RX 6700 XT (Mesa RADV), 4K
native, launching game.dat directly (no Lutris). Based on withmorten's SafeDisc-free game.dat.

Symptom

Campaign runs fine, but Skirmish dies around the 3-minute mark. It turned out to be two
independent problems
that happen to strike at nearly the same time — which is why single fixes
never fully worked.

Problem 1 — Auto-defeat DRM (the ~3:25 unit wipe)

The launcher is supposed to hand a GUID to game.dat via PostThreadMessageA(0xBEEF) +
MapViewOfFileEx. That IPC doesn't deliver under Wine, so the in-game validator never sees a
valid GUID and, on frame 1024 (~3:25 @ 5 logic-fps), destroys all your units. Campaign modes
(4 and 8) are checked earlier and skip it — hence Campaign is unaffected.

The DRM code at 0x502090–0x502290 looks like dead code (no direct refs) but is reached via
SafeDisc jump tables. The actual trigger lives in the game-tick function:

0078E190  cmp dword [ebp+0x3C], 0x400   ; frame == 1024?
0078E197  jne 0x78E1BB
0078E199  call 0x409999                 ; -> GUID validator (thunk to 0x502240)
0078E19E  test al, al
0078E1A0  jne 0x78E1BB                  ; GUID valid -> skip
0078E1A2  ...                           ; <-- destroys all player units

Fix (launcher-independent, 1 byte): make the skip unconditional so the auto-defeat block is
never reached:

VA 0x0078E1A0 (file offset 0x38E1A0):  75 -> EB   (JNE -> JMP)

Verified with a full Ghidra scan that this cmp …,0x400 is the only such timer and the GUID
validator has exactly two callers. (Note: don't force the validator 0x502240 to return true —
its other caller at 0x8E47AB then enters uninitialised online code and crashes. Patch the
branch instead.)

Problem 2 — wined3d virtual-address-space leak (the real crash)

With the DRM gone, Skirmish still crashed, just later (abnormal program termination). The Wine
log shows the cause:

err:virtual:allocate_virtual_memory out of memory for allocation, base (nil) size 04000000
err:opengl:buffer_vm_alloc NtAllocateVirtualMemory failed
err:d3d:wined3d_allocator_chunk_gl_map Failed to map chunk memory

It's not RAM (64 GB free) — the 32-bit process exhausts its virtual address space. Sampling
/proc/PID/status shows VmSize climbing linearly ~340 MB/min while VmRSS stays flat: a
leak in wined3d's OpenGL buffer-object allocator. Skirmish allocates faster than Campaign, so it
hits the wall sooner.

Fixes:

  • DXVK (D3D9→Vulkan) — the real cure. Copy DXVK's 32-bit d3d9.dll into the prefix's
    syswow64 and launch with WINEDLLOVERRIDES="d3d9=n". On RADV this is rock-solid.
  • LARGE_ADDRESS_AWAREgame.dat doesn't set it; enabling it (PE characteristics
    0x010F → 0x012F, offset 0x146) lifts the cap from 2 GB to 4 GB. On its own it only delays
    the crash (proves the cause); with DXVK the leak is gone entirely.

Result: validated a 49-minute Skirmish at 4K with DXVK — VmSize drifted just 69 MB
total (5105 → 5170 MB), essentially flat.

Reproducible patcher

patch_bfme1.py — applies both binary patches (idempotent, with backup)
#!/usr/bin/env python3
"""
patch_bfme1.py — fix BFME1 Skirmish on Linux/Wine with two 1-byte patches.

Operates on the SafeDisc-free, debuggable `game.dat` (withmorten's unpacked build).
Both patches are verified against expected bytes and are idempotent (safe to re-run).

  1. Auto-defeat DRM:  VA 0x0078E1A0  75 -> EB  (JNE -> JMP; auto-defeat becomes dead code)
  2. LARGE_ADDRESS_AWARE: PE Characteristics bit 0x0020 set (2 GB -> 4 GB address space)

Usage:
    python3 patch_bfme1.py "/path/to/game.dat"
    python3 patch_bfme1.py --revert "/path/to/game.dat"   # restore from .orig backup

After patching, install DXVK's 32-bit d3d9.dll into the prefix and launch with
WINEDLLOVERRIDES="d3d9=n". See README.md.
"""

import struct
import sys
import shutil
import os

IMAGE_BASE = 0x00400000

# Patch 1 — auto-defeat branch
DRM_VA = 0x0078E1A0
DRM_FOFF = DRM_VA - IMAGE_BASE          # 0x38E1A0
DRM_ORIG = 0x75                          # JNE rel8
DRM_NEW = 0xEB                           # JMP rel8

# Reference: the unpatched, SafeDisc-free game.dat this script targets
EXPECTED_SIZE = 16424960
EXPECTED_MD5 = "bd8630a0850c04cff1bfef1d3794e2b3"


def pe_characteristics_offset(data: bytes) -> int:
    pe_off = struct.unpack_from("<I", data, 0x3C)[0]
    if data[pe_off:pe_off + 4] != b"PE\x00\x00":
        raise ValueError("Not a PE file (bad NT signature)")
    return pe_off + 0x16


def patch(path: str) -> None:
    with open(path, "rb") as f:
        data = bytearray(f.read())

    if len(data) != EXPECTED_SIZE:
        print(f"  ! warning: size {len(data)} != expected {EXPECTED_SIZE} "
              f"(wrong/already-different game.dat?)")

    # Backup once
    backup = path + ".orig"
    if not os.path.exists(backup):
        shutil.copy2(path, backup)
        print(f"  backup -> {backup}")

    # --- Patch 1: auto-defeat branch ---
    cur = data[DRM_FOFF]
    if cur == DRM_NEW:
        print(f"  [1] DRM branch @ 0x{DRM_VA:08X}: already patched (EB)")
    elif cur == DRM_ORIG:
        data[DRM_FOFF] = DRM_NEW
        print(f"  [1] DRM branch @ 0x{DRM_VA:08X}: 75 -> EB  (auto-defeat disabled)")
    else:
        raise ValueError(f"[1] unexpected byte 0x{cur:02X} at 0x{DRM_VA:08X} "
                         f"(expected 0x75 or 0xEB)")

    # --- Patch 2: LARGE_ADDRESS_AWARE ---
    coff = pe_characteristics_offset(data)
    ch = struct.unpack_from("<H", data, coff)[0]
    if ch & 0x0020:
        print(f"  [2] LARGE_ADDRESS_AWARE: already set (0x{ch:04X})")
    else:
        struct.pack_into("<H", data, coff, ch | 0x0020)
        print(f"  [2] LARGE_ADDRESS_AWARE: 0x{ch:04X} -> 0x{ch | 0x0020:04X}  (4 GB)")

    with open(path, "wb") as f:
        f.write(data)
    print("  done.")


def revert(path: str) -> None:
    backup = path + ".orig"
    if not os.path.exists(backup):
        sys.exit(f"No backup found at {backup}")
    shutil.copy2(backup, path)
    print(f"  restored {path} from {backup}")


def main() -> None:
    args = sys.argv[1:]
    if not args:
        sys.exit(__doc__)
    if args[0] == "--revert":
        if len(args) != 2:
            sys.exit("usage: patch_bfme1.py --revert /path/to/game.dat")
        revert(args[1])
    else:
        patch(args[0])


if __name__ == "__main__":
    main()

Launch (no Lutris needed)

export WINEPREFIX=~/Games/bfme1/prefix
export WINE_LARGE_ADDRESS_AWARE=1
export WINEDLLOVERRIDES="d3d9=n"          # DXVK's d3d9.dll in syswow64
cd ".../EA GAMES/The Battle for Middle-earth (tm)"
wine game.dat

Optional dxvk.conf next to game.dat for sharper 4K: d3d9.samplerAnisotropy = 16.

Credits

The new bits here: a launcher-independent 1-byte auto-defeat patch (so no replacement launcher
is needed), and identifying the wined3d address-space leak (→ DXVK) as the real crash — so
Skirmish runs unlimited at 4K on Wine.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions