D3D窗口化Hook实现:老游戏全屏转窗口的兼容性方案

D3D窗口化Hook实现:老游戏全屏转窗口的兼容性方案 简介一份面向游戏开发与逆向调试爱好者的 D3D 窗口化源代码用于将原本全屏运行的 Direct3D 游戏改为窗口模式覆盖设备创建、交换链、呈现目标、消息循环等关键切换环节。资源共42个文件以C/C头文件.h和源文件.cpp为主体辅以工程配置.dsp/.dsw/.opt、初始化参数.ini、图标.ico及资源脚本.rc等整体仅71KB结构精简便于定位和改造。已有190人学习下载。通过源码可梳理D3D设备钩挂、窗口尺寸调整、UI与输入适配的实现路径还能看到针对兼容性和稳定性的处理细节对研究Cocos2d等引擎的D3D封装或自研窗口化辅助工具均有直接参考价值适合具备一定C与DirectX基础的读者深入阅读。1. 为什么老游戏卡在全屏D3D窗口化的切入点2010年前后的很多3D游戏默认全屏独占模式。一旦切到桌面屏幕黑上几秒严重的直接崩溃。做兼容性维护时第一反应就是让游戏改成窗口化运行。D3D窗口化源代码做的事就是在游戏进程里拦截D3D对象创建和呈现调用把设备初始化参数从全屏改到窗口模式。这套源码包里的dxhook.cpp、hd3d.cpp、hddraw.cpp分别对应Direct3D和DirectDraw的钩子实现对做老游戏兼容、直播采集、多开测试的场景尤其实用。它解决的核心矛盾是游戏不允许用户选择窗口模式但底层渲染接口是标准的只要在初始化阶段改几个参数窗口化就生效了。后面几章会把设备创建、消息循环、渲染后端差异和验证方式一条线讲完照着改就能复现。2. 从设备创建到交换链Hook链路的三个关键点2.1 为什么选择在CreateDevice层动手窗口化不是简单改一下窗口样式就行的。D3D游戏在启动时会调用CreateDevice或者CreateDeviceEx传入D3DPRESENT_PARAMETERS结构体。这个结构体里有一堆决定全屏还是窗口的字段Windowed表示是否窗口化BackBufferWidth和BackBufferHeight是后备缓冲区尺寸SwapEffect决定呈现方式PresentationInterval控制垂直同步。几乎所有老游戏都会硬编码把Windowed设为FALSE以全屏独占模式启动。直接在函数入口改参数比事后用PostMessage调整窗口更可靠。因为D3D设备创建后后备缓冲区的格式、深度格式都和窗口绑定如果等设备建好了再强行改窗口样式画面会拉伸、闪烁甚至直接无法渲染。所以在CreateDevice层拦截把Present Parameters改掉是最干净的做法。这套源代码里的hd3d.cpp就是干这个的它导出的是一个钩子函数在游戏调用原始CreateDevice之前插入修改逻辑属于典型的D3D窗口化hook。2.2 修改Present Parameters的代码骨架常见做法是用Detours或者IAT Hook替换D3D的创建函数。以D3D9为例Hook点有两个Direct3DCreate9返回的IDirect3D9对象的CreateDevice方法。在C里类方法本质是虚表指针可以直接替换虚表里的地址先保存原始CreateDevice指针再换成自己写的代理函数。下面的代码演示了最核心的替换逻辑。typedef HRESULT(WINAPI* CreateDeviceFunc)( IDirect3D9* pD3D, UINT Adapter, D3DDEVTYPE DeviceType, HWND hFocusWindow, DWORD BehaviorFlags, D3DPRESENT_PARAMETERS* pPresentationParameters, IDirect3DDevice9** ppReturnedDeviceInterface); static CreateDeviceFunc RealCreateDevice nullptr; HRESULT WINAPI HookCreateDevice( IDirect3D9* pD3D, UINT Adapter, D3DDEVTYPE DeviceType, HWND hFocusWindow, DWORD BehaviorFlags, D3DPRESENT_PARAMETERS* pParams, IDirect3DDevice9** ppDevice) { D3DPRESENT_PARAMETERS oldParams *pParams; // 备份失败时回退 // 核心把全屏独占改成窗口化后缓冲尺寸交给D3D自动匹配 pParams-Windowed TRUE; pParams-BackBufferWidth 0; pParams-BackBufferHeight 0; // 窗口模式下FLIP交换效果容易失败改COPY pParams-SwapEffect D3DSWAPEFFECT_COPY; // 关闭垂直同步避免窗口化后帧率锁半 pParams-PresentationInterval D3DPRESENT_INTERVAL_IMMEDIATE; HRESULT hr RealCreateDevice(pD3D, Adapter, DeviceType, hFocusWindow, BehaviorFlags, pParams, ppDevice); if (FAILED(hr)) { // 设备创建失败时回退原始参数最小程度干预 return RealCreateDevice(pD3D, Adapter, DeviceType, hFocusWindow, BehaviorFlags, oldParams, ppDevice); } return hr; }这段代码的关键是Windowed TRUE和BackBufferWidth 0。当后缓冲宽高为0时D3D会自动使用窗口客户区的大小去匹配后备缓冲区这样窗口拖动后画面不会留黑边。SwapEffect改成COPY有一个副作用旧的翻转效果D3DSWAPEFFECT_FLIP在全屏独占下才有意义窗口模式下部分驱动会报D3DERR_INVALIDCALL所以必须改。PresentationInterval设成IMMEDIATE是为了让窗口不锁帧率否则某些游戏在窗口化后帧率会被锁到桌面刷新率的一半体感就是明显的卡顿。如果游戏用的是D3D9Ex的CreateDeviceEx还需要额外处理D3DDDI_DRIVERESCAPE之类的扩展参数老游戏基本不涉及。源码包里把这段逻辑放在hd3d.cpp里实际接入时只需要把RealCreateDevice替换成从原始虚表取出的指针Hook过程不需要碰游戏的代码段安全性高很多。2.3 交换链配置与窗口样式的联动CreateDevice修改完之后设备创建好了但窗口本身还是游戏原来的全屏窗口。D3D9在窗口化模式下要求窗口具备WS_OVERLAPPED等样式不能是一层不可见的全屏窗口。所以Hook里通常还要调SetWindowLongPtrA去改窗口风格同时把窗口尺寸设置成合理的初始大小。下面的表格是这套源码里常见的参数改动参数或样式全屏模式窗口化模式说明WindowedFALSETRUE主开关决定D3D设备创建目标BackBufferWidth如192000表示跟随窗口客户区BackBufferHeight如10800同上SwapEffectD3DSWAPEFFECT_FLIPD3DSWAPEFFECT_COPY避免全屏独占要求Window styleWS_POPUPWS_OVERLAPPEDWINDOW让窗口有标题栏和边框ShowWindow命令SW_SHOWMAXIMIZEDSW_SHOW初始状态改为普通显示窗口样式的修改时机要在Present参数生效之后但不能抢在游戏自己的窗口初始化代码之前否则游戏后续可能重新设置回全屏风格。通常的做法是延迟到第一次Present调用时再改这样既躲开了游戏自身的窗口初始化流程也保证画面已经由D3D接管。这份源代码的MainFrm.cpp和dxwnd.cpp里就有类似处理先挂接Present再一次性调整窗口。3. 窗口Proc与消息循环窗口化后的事件适配3.1 子类化窗口接管WM_SIZE与WM_MOVE游戏从全屏切到窗口后第一个暴露的问题就是消息循环。全屏独占模式下游戏通常只处理键盘和鼠标事件窗口尺寸不需要关心。但窗口模式下用户会拖动窗口边框、最小化、最大化游戏内部的视口和后备缓冲区如果不跟着变画面就停在一个固定大小周围全是黑边。解决办法是对游戏窗口做子类化。通过SetWindowLongPtr把窗口过程替换成自己写的WndProc在挂接函数里先处理窗口消息再把不需要的消息交给原始窗口过程。这里有个细节子类化必须拿到正确的游戏主窗口句柄一般从EnumWindows或者HookCreateWindowExA/W时截获源码包里的TargetDlg.cpp处理的就是这个查找逻辑。LONG_PTR originalWndProc GetWindowLongPtrA(hwnd, GWLP_WNDPROC); LRESULT CALLBACK HostWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_SIZE: { int newWidth LOWORD(lParam); int newHeight HIWORD(lParam); // 窗口尺寸变化通知游戏逻辑重新布局 if (newWidth 0 newHeight 0) { g_windowSizeChanged true; g_clientSize.cx newWidth; g_clientSize.cy newHeight; } break; } case WM_MOVE: // 记录窗口位置多显示器场景下调整鼠标映射 GetWindowRect(hwnd, g_windowRect); break; case WM_ACTIVATE: // 失活时释放鼠标锁定避免光标困在窗口内 if (LOWORD(wParam) WA_INACTIVE) { ReleaseCapture(); } break; } return CallWindowProc((WNDPROC)originalWndProc, hwnd, msg, wParam, lParam); }这里不直接调用Reset是有原因的WM_SIZE是窗口过程里实时触发的如果在这里重置D3D设备设备背后的交换链和渲染目标会被释放而游戏正在渲染中途绝大多数老游戏都会崩溃。所以先用一个全局标记记录尺寸变化等游戏自己的渲染循环跑到安全点再处理。D3D9在窗口模式下其实会自动缩放后备缓冲区WM_SIZE里真正要通知的是游戏逻辑层让UI重算布局。如果游戏没有布局逻辑那什么都不做也能正常显示只是拉伸后有点糊。3.2 处理鼠标锁定与输入焦点全屏游戏为了让鼠标不出界通常用SetCursorPos把鼠标钉在屏幕中心或者直接设置裁剪区域。窗口化之后这些逻辑会造成鼠标被限制在一个看不见的矩形里。处理这类问题需要在窗口失去焦点时自动释放鼠标捕获重新获得焦点时再恢复游戏原本的鼠标锁定逻辑。常见消息包括WM_CAPTURECHANGED立即释放内部鼠标锁定标志。WM_ACTIVATEwParam低16位等于WA_ACTIVE时表示窗口获得焦点可以恢复游戏自己的输入处理WA_INACTIVE时暂停输入。WM_SETFOCUS与WM_KILLFOCUS同步更新游戏的焦点状态很多老游戏用焦点判断是否暂停渲染。如果游戏使用了DirectInput的GetDeviceState去读鼠标相对位移窗口化后鼠标一直在窗口内移动相对位移数据反而正常不需要额外处理。真正的问题是使用绝对坐标的老游戏这时候需要在WndProc里做一次坐标换算把屏幕坐标转换成游戏逻辑坐标。映射公式逻辑X (屏幕X - 窗口客户区左上角X) * 游戏分辨率宽度 / 客户区宽度Y方向同理。这个映射可以放在WM_MOUSEMOVE分支里修改lParam之后再往下传避免游戏拿到的坐标还是旧的全屏坐标。3.3 与原始消息循环的共存策略窗口化之后最怕出现两个消息循环同时跑。游戏自己有一个PeekMessage循环子类化的窗口过程也在收消息两者如果都在处理渲染画面就会撕裂或卡顿。成熟的做法是不要介入游戏的主循环只修改窗口过程和D3D函数的返回结果让游戏自己继续跑原来的PeekMessage。不过这里有个坑很多游戏的主循环是while (true) { if (PeekMessage) DispatchMessage; game_render(); }。如果子类化WndProc里调用了Sleep或者弹窗调试游戏的主循环会停住外部看起来像界面卡死。我一般会把调试日志写在单独线程里而不是直接在WndProc里输出。另一个共存策略是定期从窗口过程里提取状态比如窗口是否被最大化用原子变量标记游戏渲染循环里再读取这个标记决定是否调整D3D后缓冲这样两边互不阻塞。4. 兼容性边界老D3D、Cocos2d与渲染后端的差异4.1 D3D8/D3D9接口差异这套源码里的hd3d.cpp同时处理D3D8和D3D9但两者的Hook方式差很多。D3D8的CreateDevice在IDirect3D8::CreateDevice虚表结构跟D3D9基本一致可以直接替换。不过D3D8的D3DPRESENT_PARAMETERS里没有PresentationInterval垂直同步由FullScreen_PresentationInterval和Windowed_PresentationInterval两个字段控制窗口化时要改的是后者。字段/方法D3D8D3D9设备创建方法IDirect3D8::CreateDeviceIDirect3D9::CreateDevice / CreateDeviceEx窗口化开关WindowedWindowed后缓冲尺寸BackBufferWidth/Height同左垂直同步FullScreen_PresentationInterval / Windowed_PresentationIntervalPresentationInterval扩展呈现参数无D3DPRESENT_PARAMETERS 已包含需要特别留意D3D8的SwapEffect如果原来是FLIP窗口化后改成D3DSWAPEFFECT_COPY个别显卡驱动会忽略这个设置仍然使用覆盖层翻转表现就是窗口化后帧率反而更低。这种情况只能保留FLIP并用DwmFlush来同步帧。老游戏还有一个常见问题设备创建时用D3DCREATE_PUREDEVICE这种设备不保留软件顶点处理状态窗口化后如果UI渲染依赖SetTransform容易出现画面闪烁需要在Hook里把BehaviorFlags里的PUREDEVICE去掉。4.2 Cocos2d游戏引擎下的窗口化注意点Cocos2d在大多数平台上是OpenGL渲染但在一些Windows移植版里也有用D3D作为后端的选择。如果你的目标是这类游戏不能只依赖拦截D3D的CreateDevice因为Cocos2d-x在Windows上主要走CCEGLView_win32.cpp里的OpenGL路径跟D3D窗口化代码没有交集。所以对Cocos2d游戏更实用的入口是Win32Window的创建过程直接修改初始化的窗口风格让窗口正常显示。Cocos2d这类游戏引擎通常有自己的分辨率适配策略比如setDesignResolutionSize。窗口化后设计分辨率和实际窗口客户区不一致渲染出来会按缩放模式拉伸。因为缩放逻辑在引擎内部外部Hook很难精确介入比较省事的办法是保持窗口客户区等于设计分辨率禁止用户调整窗口大小。也就是给窗口加上WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX去掉WS_THICKFRAME和WS_MAXIMIZEBOX这样窗口比例固定UI不会乱。4.3 渲染目标和视口重置窗口化之后另一个隐蔽的问题是视口。游戏初始化时会把视口设置成全屏宽高比如1600x900。窗口化后客户区变成800x600D3D的视口却没有更新结果只渲染了画面左上角800x600的区域其余部分全是黑色。所以必须在第一次Present之后主动调用SetViewport把视口重设为窗口客户区大小。D3DVIEWPORT9 vp; vp.X 0; vp.Y 0; vp.Width clientWidth; // 通过GetClientRect获得 vp.Height clientHeight; vp.MinZ 0.0f; vp.MaxZ 1.0f; device-SetViewport(vp);这里MinZ和MaxZ保持默认的0和1绝大部分游戏都会在后面自行重置我们只需要保证设备Create之后没有立即用一个错误尺寸的视口去渲染。渲染目标同理窗口化后不需要再设置ColorBuffer和DepthBuffer为全屏尺寸直接用默认的RenderTarget和DepthStencilSurface就行。一旦这两处都改对画面撕裂和黑边的问题能消除一半。5. 验证与调试如何确认窗口化真正生效5.1 用日志确认拦截点窗口化没有效果时第一步是确认Hook到底有没有命中。在HookCreateDevice的入口写一行日志输出原始参数中的Windowed值和被修改之后的值然后在RealCreateDevice返回后再输出一次HRESULT。如果日志里连入口都没有说明Hook没有挂上要么是游戏启动时先加载了D3D要么是源码包里的注入时机不对。dxwnd.ini或dxhook.cpp里通常有调试开关打开后输出的日志会记录每一次调用的虚拟地址。5.2 用RenderDoc验证帧呈现RenderDoc支持D3D9但需要以application capture方式启动游戏。启动后手动切换窗口模式抓一帧查看D3D9 Device的创建参数。在RenderDoc的API Inspector里能看到Present调用的源和目标如果PresentationInterval一直是IMMEDIATE说明D3D层已经生效。再检查Texture View如果纹理尺寸是游戏原始分辨率而窗口较小说明视口部分没有完全适配回看4.3的SetViewport是否正确调用。窗口模式下不推荐抓取过多帧D3D9窗口化后的Present会触发相对缓慢的重绘抓帧速度会明显下降。5.3 快速验证窗口样式和常见失败对照下面的PowerShell命令可以快速读取游戏进程的主窗口句柄和标题配合Spy确认窗口样式是否正确应用。powershell -Command Get-Process game | ForEach-Object { $_.MainWindowTitle; $_.MainWindowHandle }获取到句柄后用GetWindowLong检查样式值如果WS_POPUP还在说明窗口样式没有被替换回到2.3的时机问题。常见失败现象直接对照这张表现象可能原因检查点游戏还是全屏启动CreateDevice没被拦截日志是否有HookCreateDevice入口窗口有边框但画面黑屏SwapEffect或视口错误RenderDoc查看第一次Present前视口窗口化后鼠标点击错位鼠标坐标未换算WM_MOUSEMOVE分支是否修改lParam拖窗口边缘画面不跟着变未处理WM_SIZE确认窗口过程是否被子类化帧率比全屏低一半垂直同步冲突确认PresentationInterval为IMMEDIATE最小化后再恢复黑屏设备Reset被跳过在WM_SIZE里是否触发了延迟Reset标记最后再补充一个验证点窗口化后的分区暗盘或数值显示类游戏如果窗口尺寸被锁定可以用ChangeDisplaySettingsEx配合窗口位置微调。不过对大多数D3D老游戏做到上面这些就已经能稳定窗口化了。本文还有配套的精品资源点击获取