项目地址:[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-msvctarget 下编译,否则进程加载不了。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 / GetVersion | WinVersion 配置项,把"Win11"报成"Win7",绕过老游戏的系统版本检查 |
| 显示模式 | EnumDisplaySettingsA/W / EnumDisplayDevicesA/W | 注入/裁剪分辨率列表(InjectResolution、MaxResolutions),报一个 FakeMode 给游戏 |
| 字体 | CreateFontA/W / CreateFontIndirectA/W | 老游戏 96 DPI 时代的小字号字体在新 DPI 下缩成蚂蚁,按 MinFontSize 自动放大 |
| 键盘 | GetKeyboardState / SetKeyboardState | Alt 键"卡死"修复(FixAltKeyStuck)——老 RTS 玩家都懂的痛 |
| 防护 | LoadLibraryA/W / GetProcAddress / CoCreateInstance | 防止游戏拿到真实 ddraw / 绕过我们的实例 |
hook_iat 的实现思路:找到进程导入表,按名字匹配目标模块与函数,把 thunk 指针改写成 fake_* 的地址,并把真实地址缓存到 REAL_* 静态量里——fake_* 内部需要时再调用真函数。只补丁游戏自己 IAT 里的条目,不动系统全局。
渲染管线:一个独立线程 + 三个后端
这是整个工程最"讲究"的部分(render/mod.rs)。补丁有一件事必须保证:老游戏往 surface 里画,补丁把 surface 里的东西以现代方式显示出来。rs-ddraw 的做法:
- 独立渲染线程:等待主表面创建后,在自己的线程里循环 Present。这样即使游戏主线程忙得不可开交,画面仍按自己的节奏输出。
- 后端自动降级:
auto时按 D3D9 → OpenGL → GDI 的顺序尝试初始化,失败就降级,保底永远是 GDI。显式指定 Renderer 时失败同样回退 GDI。 - "等帧画完再上传":这是最容易踩的坑,代码注释里写得很清楚——cnc-ddraw 是在
surface_updated信号(Blt/Flip/Unlock/ReleaseDC之后)才上传纹理,绝不在WaitForVerticalBlank时上传。因为很多游戏在绘制之前调用 vblank 等待,此刻上传抓到的是一帧画了一半的黑色画面——这就是很多"补丁打了还是黑屏"的真相。 - 窗口/分辨率变化兜底:主表面尺寸变了(比如过场加载之后游戏重建表面),强制强制重传一次,否则画面会冻结在最后一帧"好帧"上。
- 垂直同步尊重 DWM:用
GetProcAddress运行时解析dwmapi.dll的DwmFlush(不硬链接、不需要开特性开关),跟合成器同步来避免撕裂;没有合成器时退回普通 sleep 节奏。 - 帧率护理:FPS 指数平滑、maxfps/minfps 节奏控制,其中 minfps 有个聪明的细节——当实际帧率掉到最小帧率以下时跳过 sleep 让循环空转,保证窗口在游戏卡顿时也不会被系统判定"未响应"。
- 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,用pngcrate 存盘)、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 后端和更多老游戏的实测兼容矩阵。如果你也有一台上面躺着红色警戒的老笔记本,欢迎拿去试试。