rs-ddraw:用 Rust 重写 DirectDraw 兼容层,让老游戏在新系统上复活

DirectDraw 早已被时代抛弃,老游戏却还在用它。rs-ddraw 是一个用 Rust 编写的 ddraw.dll 兼容层:导出真实 DD 接口、钩住游戏导入表、用独立渲染线程接管画面输出,让红色警戒这类老 2D 游戏在现代驱动与 Windows 10/11 上重新完美运行。


项目地址:[Github] (https://github.com/AOME-C/rs-ddraw)

背景:为什么老游戏需要一张"假" ddraw.dll

在 DirectX 9 之前,2D 游戏大多走 DirectDraw:游戏进程加载 ddraw.dll,通过导出的工厂函数创建 IDirectDraw / IDirectDrawSurface 等 COM 接口,再用 Blt、Flip 之类的方法往显存/系统内存里画帧。红色警戒、帝国时代、星际争霸这些老 RTS 都是这么画的。

问题在于:DirectDraw 在新系统上已经事实废弃。Windows 10/11 的图形栈为了安全与统一改用了 WDDM 驱动模型,DirectDraw 的全屏独占(DDSCL_EXCLUSIVE)模式在混合 GPU(核显 + 独显)笔记本上尤其糟糕——最常见的症状就是:

  • 启动黑屏、只有声音没有画面
  • 全屏切换(Alt+Tab)黑屏、花屏甚至直接崩溃
  • 老 2D 代码在 DWM 合成器下撕裂、卡顿
  • 鼠标坐标对不上(游戏分辨率与窗口分辨率不一致)
  • 驱动更新后原本能玩的游戏突然打不开了

社区里流传最广的解法是 wnayes/cnc-ddraw:一张 ddraw.dll 补丁,把老游戏对 DirectDraw 的调用截胡,转成窗口化 + OpenGL/D3D9 渲染,于是游戏无视驱动和系统版本,永远以"现代"的方式画帧。我的新项目 rs-ddraw 就是受它启发,用 Rust 从头重写的兼容层(部分实现也参考了 ts-ddraw)。

架构总览

一个"补丁 DLL"的完整链路可以分成四层:

┌──────────────────────────────┐
│ 游戏进程(32 位,RA2 等)      │
│   import ddraw.dll + user32/gdi32
├──────────────────────────────┤
│ ddraw.dll(rs-ddraw 本体)     │
│  ├─ 导出表:DirectDrawCreate… │  ← Windows 加载器按名字/序号解析
│  ├─ IAT Hook:user32/gdi32…  │  ← 把游戏的导入表条目换成我们的 fake_*
│  └─ 渲染线程:D3D9/OpenGL/GDI │  ← 独立线程负责把 surface 画到窗口
├──────────────────────────────┤
│ 系统层:user32、gdi32、DWM…   │
└──────────────────────────────┘

Cargo 里对应四块:exports.rs(导出表)、hook.rs(IAT Hook,最大的一个文件)、ddraw/(COM 接口实现)、render/(渲染后端)。

第一步:让自己的 DLL 被游戏加载

游戏不会主动配合,所以第一件事是把编译产物变成一张"看起来就是 ddraw.dll"的库:

  • cdylib + 32 位目标:老游戏是 32 位进程,必须在 i686-pc-windows-msvc target 下编译,否则进程加载不了。Cargo.toml 里 crate-type = ["cdylib"],lib.name = "ddraw"。
  • 导出表对齐原版:ddraw.def 里目录样式地写了 DirectDrawCreate @8、DirectDrawCreateClipper @9 这类序号导出。老游戏有些是按序号而不是按名字导入的,序号必须和原版 ddraw.dll 一致,二进制兼容才成立。
  • #[no_mangle] extern "system":exports.rs 里实现了 DirectDrawCreate / DirectDrawCreateEx / DirectDrawEnumerateA/W/ExA/ExW / DirectInputCreateA/W/Ex / DirectInput8Create 以及 DDIsWindowed、pvBmpBits 等辅助导出——红警这类游戏会从同一张 DLL 里拿 DirectInput,所以连输入创建函数也要包一层。
  • DllMain 里做引导:dll.rs 在 DLL_PROCESS_ATTACH 时依次做 DPI 感知(GetProcAddress 拿 SetProcessDPIAware,不硬链接)、安装崩溃处理、初始化 MCI 媒体与 DirectInput,最后 DisableThreadLibraryCalls。
  • 双显卡适配标记:lib.rs 里导出了两个全局变量 NvOptimusEnablement 和 AmdPowerXpressRequestHighPerformance——这是 NVIDIA/AMD 留给应用层"请用独显"的信号,笔记本混合显卡下不写的话游戏会一直跑在核显上。

还留了一个有趣的小设计:dll.rs 里有个独立线程监听 Ctrl+Shift+Q 强杀。为什么不用窗口消息?因为游戏主线程死锁时消息循环根本转不动,但独立线程还能干活——干死游戏时至少有急停手段。

工作核心:钩住游戏的导入表(IAT Hook)

只输出接口还不够。老游戏会直接调用 user32.dll / gdi32.dll 的函数来管窗口、画光标、查系统信息——这些调用在新系统上同样会踩坑。rs-ddraw 的做法和 cnc-ddraw 一致:把游戏 IAT(导入地址表)里的条目替换成我们自己的 fake_* 函数。hook.rs 里 hook 了约 90 个 API,按功能分几类:

类别被替换的函数为什么
窗口尺寸GetWindowRect / GetClientRect给游戏"报假尺寸"(FakeWidth/FakeHeight),配合实际窗口缩放
窗口创建CreateWindowExA/W子窗口布局修正(FixChilds,例如把游戏创建的菜单子窗口重新摆位/贴图)
消息泵PeekMessageA/W / GetMessageA/W在这里做帧节奏控制(maybe_wait_game_tick)
光标SetCursorPos / ClipCursor / GetCursorInfo鼠标坐标按游戏/窗口分辨率缩放(AdjMouse),锁定在游戏逻辑区域
GDI 绘图BitBlt / StretchBlt / StretchDIBits游戏若用 GDI 画 UI,将其捕获进主表面画布
系统信息GetSystemMetrics / GetDeviceCaps伪造分辨率与位深(游戏认 640×480,实际跑在任意分辨率)
版本欺骗GetVersionExA/W / RtlGetVersion / GetVersionWinVersion 配置项,把"Win11"报成"Win7",绕过老游戏的系统版本检查
显示模式EnumDisplaySettingsA/W / EnumDisplayDevicesA/W注入/裁剪分辨率列表(InjectResolution、MaxResolutions),报一个 FakeMode 给游戏
字体CreateFontA/W / CreateFontIndirectA/W老游戏 96 DPI 时代的小字号字体在新 DPI 下缩成蚂蚁,按 MinFontSize 自动放大
键盘GetKeyboardState / SetKeyboardStateAlt 键"卡死"修复(FixAltKeyStuck)——老 RTS 玩家都懂的痛
防护LoadLibraryA/W / GetProcAddress / CoCreateInstance防止游戏拿到真实 ddraw / 绕过我们的实例

hook_iat 的实现思路:找到进程导入表,按名字匹配目标模块与函数,把 thunk 指针改写成 fake_* 的地址,并把真实地址缓存到 REAL_* 静态量里——fake_* 内部需要时再调用真函数。只补丁游戏自己 IAT 里的条目,不动系统全局。

渲染管线:一个独立线程 + 三个后端

这是整个工程最"讲究"的部分(render/mod.rs)。补丁有一件事必须保证:老游戏往 surface 里画,补丁把 surface 里的东西以现代方式显示出来。rs-ddraw 的做法:

  1. 独立渲染线程:等待主表面创建后,在自己的线程里循环 Present。这样即使游戏主线程忙得不可开交,画面仍按自己的节奏输出。
  2. 后端自动降级:auto 时按 D3D9 → OpenGL → GDI 的顺序尝试初始化,失败就降级,保底永远是 GDI。显式指定 Renderer 时失败同样回退 GDI。
  3. "等帧画完再上传":这是最容易踩的坑,代码注释里写得很清楚——cnc-ddraw 是在 surface_updated 信号(Blt/Flip/Unlock/ReleaseDC 之后)才上传纹理,绝不在 WaitForVerticalBlank 时上传。因为很多游戏在绘制之前调用 vblank 等待,此刻上传抓到的是一帧画了一半的黑色画面——这就是很多"补丁打了还是黑屏"的真相。
  4. 窗口/分辨率变化兜底:主表面尺寸变了(比如过场加载之后游戏重建表面),强制强制重传一次,否则画面会冻结在最后一帧"好帧"上。
  5. 垂直同步尊重 DWM:用 GetProcAddress 运行时解析 dwmapi.dll 的 DwmFlush(不硬链接、不需要开特性开关),跟合成器同步来避免撕裂;没有合成器时退回普通 sleep 节奏。
  6. 帧率护理:FPS 指数平滑、maxfps/minfps 节奏控制,其中 minfps 有个聪明的细节——当实际帧率掉到最小帧率以下时跳过 sleep 让循环空转,保证窗口在游戏卡顿时也不会被系统判定"未响应"。
  7. GDI 后端的子窗口合成:游戏的内嵌菜单窗口(红警的选项面板之类)在改窗后容易变成白板,这里用一个 EnumChildWindows 回调把主表面对应区域 BitBlt 进每个子窗口(移植自 ts-ddraw 的做法)。

兼容性配置:一张 INI 全搞定

所有行为都可以用游戏目录下的 rs-ddraw.ini 调整(Windows INI API 读取,键不区分大小写),风格与 cnc-ddraw 一脉相承。常用项:

  • 渲染:Renderer=auto|gdi|opengl|openglcore|d3d9、VSync、Filter(nearest/bilinear/catmull-rom/lanczos/xBR)、Shader/ShaderPath(upsample shader、双 Pass CRT 类)
  • 画面:MaintainAspectRatio、Windowboxing、StretchToFullscreen、Width/Height/RefreshRate(分辨率/刷新率覆盖)、FixedOutput
  • 窗口:Border、Resizable、CenterWindow、PosX/PosY、FakeWidth/FakeHeight
  • 帧率:MinFPS、MaxFPS、MaxGameTicks、LimiterType(auto/testcooperativelevel/bltfast/unlock/peekmessage)
  • 兼容开关:TShack(泰伯利亚之日专用 hacks)、FlipClear、LockSurfaces、NoActivateApp、FixNotResponding、FixAltKeyStuck、FixChilds(0-4 级)、Nonexclusive(把全屏独占当成窗口处理,默认开启)
  • 热键:ToggleFullscreen(默认 Enter)、ToggleMaximize(PageDown)、UnlockCursor(Tab / 右 Ctrl)、Screenshot(F12,用 png crate 存盘)、ConfigKey(F11 打开游戏内配置面板,方向键导航、+/- 调值、R 还原、F 全屏、O 透明度)
  • 细节:GuardLines=200(为某些缺心眼游戏在帧缓冲下多分配 200 行保护扫描线)、SaveSettings(把窗口位置等写回 INI)

ConfigKey 这个游戏内面板很有意思——配置不用退出游戏就能改,调试兼容性问题时省了大量来回启动的功夫。

构建与使用

rustup target add i686-pc-windows-msvc
cargo build --release --target i686-pc-windows-msvc

需要 Rust nightly(项目用了 edition 2024)。把产物复制到游戏目录并重命名为 ddraw.dll(如果产物名还不是它),然后启动游戏即可。发布配置开了 lto、panic = "abort"、strip,最终 DLL 很小,直接扔进游戏文件夹。

ra2/
├── game.exe
├── ra2.ini
└── ddraw.dll   ← rs-ddraw

结语

用 Rust 写这种"浑身 unsafe"的兼容层,体验挺特别的:一方面每一行接口代码都贴着 ABI 走(extern "system" vtable、裸指针、#[no_mangle]),另一方面 cargo 的依赖管理、windows crate 0.62 的强类型绑定,以及编译期就能逮住的重名/签名错误,比 C 时代的写感舒服太多。项目以 MIT 协议开源,之后打算继续补 shader 滤镜、完善 D3D9 后端和更多老游戏的实测兼容矩阵。如果你也有一台上面躺着红色警戒的老笔记本,欢迎拿去试试。