Killing Floor: долгий alt-tab и фризы при сворачивании - причина и решение
Если Killing Floor 1 после alt-tab надолго подвисает с черным экраном, монитор "моргает", а возвращение в игру занимает секунды - это не "просто старый движок". У проблемы одна конкретная причина: потеря Direct3D 9-устройства при выходе из эксклюзивного полноэкранного режима. Ниже - что именно происходит и что с этим делать, от самого простого способа к самому сложному.
Меню Display в Killing Floor: рендер, разрешение и уровни детализации. Скриншот: PCGamingWiki.
Разбор сделан для Killing Floor build 3339 (Unreal Engine 2.5, 32-bit): реверс D3D9Drv.dll и WinDrv.dll, сверка с retail-исходниками UT2004 v3369 и анализ реального System/KillingFloor.log.
Коротко: что делать
- Попробуйте чекбокс "Отключить оптимизации во весь экран" (5 секунд, без риска).
- Рекомендуется: играйте в оконном режиме с разрешением монитора и уберите рамку внешним скриптом - файлы игры не трогаются.
- Если нужен ускоренный вариант и вы принимаете риски - 32-битный DXVK
d3d9.dll. - Дополнительно:
UsePrecaching=Falseубирает около 0.9 с прекеша на каждом alt-tab.
Как проверить результат: после alt-tab откройте System/KillingFloor.log и поищите строку Creating device. Нет строки - проблема решена.
Важно про VAC. Killing Floor 1 защищена VAC. Подмена
WinDrv.dll/D3D9Drv.dllи прокси-d3d9.dll(DXVK, dgVoodoo2, ReShade) - зона ненулевого риска. Подробнее - в разделе Предупреждения.
Что происходит при alt-tab
Как проблема видна в логе
Если в System/KillingFloor.log после каждого alt-tab появляется такой блок, у вас именно этот случай:
Log: Enter SetRes: 2560x1600 Fullscreen 1 <- разрешение НЕ менялось
Log: Creating device <- Reset провалился, полное пересоздание
Log: POSTFX - TripPostFX::Init successful
Log: Precaching: KF-Offices-KFN.LevelInfo0
Log: Finished precaching geometry in 0.493 seconds
Log: Finished precaching textures in 0.400 seconds
Разрешение не менялось, но движок все равно пересоздает устройство с нуля и прекеширует карту заново.
Из чего складывается цена одного alt-tab
- выход из exclusive fullscreen и вход обратно + ресинк монитора;
- повторы
CreateDeviceс паузой по 500 мс; - пересоздание D3D9-устройства и перекомпиляция эффектов PostFX;
- повторная загрузка рабочего набора текстур в видеопамять (папка
Textures/- 2.8 ГБ на диске); - прекеш всей карты (около 0.9 с по логу).
Почему так (механизм)
Каждый кадр UD3D9RenderDevice::Lock вызывает TestCooperativeLevel(). Пока окно свернуто, устройство в состоянии D3DERR_DEVICELOST и кадры просто выбрасываются. При возврате приходит D3DERR_DEVICENOTRESET, и движок вызывает SetRes, который:
- Первым делом делает
Flush- выгружает все текстуры, вершинные и индексные буферы, шейдеры. - Пробует
Device->Reset()- и это не удается, потому что остаются живые ресурсыD3DPOOL_DEFAULT(render-target'ы PostFX,xD3D9Helper). - Полностью освобождает и создает устройство заново (в логе -
Creating device). - При неудаче ждет
appSleep(0.5f)и повторяет - главный поток блокируется квантами по 500 мс. - Заново инициализирует PostFX и все render/sampler state.
- Прекеширует всю карту, а не только видимую часть.
Текстуры лежат в D3DPOOL_MANAGED - такой пул пережил бы обычный Reset() бесплатно. Но раз движок пересоздает устройство целиком, системные копии рантайма умирают вместе с ним, и каждая текстура заливается в видеопамять заново.
Что причиной не является
| Гипотеза | Вердикт |
|---|---|
WM_KILLFOCUS / WM_SIZE выгружают ресурсы | Нет. EndFullscreen() вызывается только для OpenGL / Pixomatic. На D3D9 при сворачивании движок ничего не делает. |
| Движок сам меняет видеорежим | Нет. ChangeDisplaySettings в WinDrv.dll отсутствует. Режим переключает D3D9-рантайм через CreateDevice / Release. |
Нужен LAA-патч (/LARGEADDRESSAWARE) | Уже стоит: у KillingFloor.exe Characteristics = 0x012F (бит 0x0020 установлен). Пик памяти в логе - 492 МБ. |
| Сборка мусора или чтение с диска | Нет. CollectGarbage на этом пути нет, текстуры остаются в памяти UObject. Цена - пересоздание D3D-объектов на CPU и заливка по PCIe. |
Почему в CS 1.6 такого нет
Half-Life/hw.dll работает через SDL2.dll + OPENGL32.dll. В OpenGL нет понятия "потерянное устройство": контекст переживает alt-tab, текстуры остаются у драйвера. SDL2-fullscreen в современных сборках GoldSrc - это borderless desktop без реальной смены видеорежима. Да и рабочий набор там около 100 МБ против 2.8 ГБ.
Решения: от простого к сложному
Каждый шаг обратим.
Шаг 0. Чекбокс FSO - 5 секунд, без риска
Свойства KillingFloor.exe → Совместимость → "Отключить оптимизации во весь экран". Проверьте оба положения.
С включенными fullscreen optimizations Windows может подменять эксклюзивный полноэкранный режим на borderless-окно: игра "думает", что работает в Fullscreen Exclusive, а на деле Windows запускает ее в окне без рамки. Ожидания низкие - по снятому логу проблема сохранялась, - но проверка ничего не стоит.
Шаг 1. Оконный режим без рамки - рекомендуемый вариант
Ни один файл игры не меняется. В System/KillingFloor.ini укажите оконный режим и разрешение монитора (пример для 2560x1600):
[WinDrv.WindowsClient]
StartupFullscreen=False
WindowedViewportX=2560
WindowedViewportY=1600
Затем, пока игра запущена, выполните скрипт kf-borderless.ps1 (ниже): он делает окно WS_POPUP|WS_VISIBLE и растягивает его на монитор. В процесс игры скрипт не лезет.
Картинка не искажается. AdjustWindowRect со стилем 0x10CF0000 уже дает клиентскую область ровно 2560x1600, рамка рисуется снаружи. После смены стиля и позиционирования client rect остается того же размера, поэтому Lock не видит изменения GetClientRect - ни пересоздания, ни растяжения.
Оконное D3D9-устройство теряется только при смене видеорежима или TDR, сворачивание его не теряет. Весь путь SetRes / Flush / прекеш исчезает.
Прецедент. OldUnreal Patch 3374 для UT2004 (тот же UE2.5) сделал ровно это: "The Windows client will now use borderless windows rather than exclusive fullscreen mode. This makes alt+tabbing and resolution switching much faster and more reliable."
Скрипт kf-borderless.ps1
Запускайте, когда игра уже работает в оконном режиме.
# Makes the Killing Floor windowed-mode window borderless and snaps it to the monitor.
# No game file is modified. Run it while KF is running in WINDOWED mode
# (System\KillingFloor.ini -> [WinDrv.WindowsClient] StartupFullscreen=False,
# WindowedViewportX/Y set to the monitor resolution).
param(
[string]$ProcessName = 'KillingFloor',
[int]$TimeoutSec = 120
)
Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class U32 {
[DllImport("user32.dll", SetLastError=true)] public static extern int GetWindowLong(IntPtr h, int i);
[DllImport("user32.dll", SetLastError=true)] public static extern int SetWindowLong(IntPtr h, int i, int v);
[DllImport("user32.dll", SetLastError=true)] public static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags);
[DllImport("user32.dll")] public static extern IntPtr MonitorFromWindow(IntPtr h, uint flags);
[DllImport("user32.dll")] public static extern bool GetMonitorInfo(IntPtr m, ref MONITORINFO mi);
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }
[StructLayout(LayoutKind.Sequential)] public struct MONITORINFO { public int cbSize; public RECT rcMonitor; public RECT rcWork; public int dwFlags; }
}
'@
$GWL_STYLE = -16; $GWL_EXSTYLE = -20
$WS_POPUP = -2147483648 # 0x80000000 as signed int
$WS_VISIBLE = 0x10000000
$SWP_FRAMECHANGED = 0x0020; $SWP_NOZORDER = 0x0004
$MONITOR_DEFAULTTONEAREST = 2
$deadline = (Get-Date).AddSeconds($TimeoutSec)
do {
$p = Get-Process -Name $ProcessName -ErrorAction SilentlyContinue |
Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1
if ($p) { break }
Start-Sleep -Milliseconds 500
} while ((Get-Date) -lt $deadline)
if (-not $p) { Write-Error "no $ProcessName window within ${TimeoutSec}s"; exit 1 }
$h = $p.MainWindowHandle
$mi = New-Object U32+MONITORINFO
$mi.cbSize = [Runtime.InteropServices.Marshal]::SizeOf($mi)
[void][U32]::GetMonitorInfo([U32]::MonitorFromWindow($h, $MONITOR_DEFAULTTONEAREST), [ref]$mi)
$x = $mi.rcMonitor.L; $y = $mi.rcMonitor.T
$w = $mi.rcMonitor.R - $x; $hgt = $mi.rcMonitor.B - $y
[void][U32]::SetWindowLong($h, $GWL_STYLE, $WS_POPUP -bor $WS_VISIBLE)
[void][U32]::SetWindowLong($h, $GWL_EXSTYLE, 0x00040000) # WS_EX_APPWINDOW
[void][U32]::SetWindowPos($h, [IntPtr]::Zero, $x, $y, $w, $hgt, $SWP_FRAMECHANGED -bor $SWP_NOZORDER)
"borderless: hwnd=0x{0:X} rect={1},{2} {3}x{4}" -f [int64]$h, $x, $y, $w, $hgt
Шаг 2. DXVK - второй по соотношению эффект/усилие
Положите 32-битный d3d9.dll из DXVK (из папки x32/) в System/. D3D9Drv.dll импортирует из d3d9 только Direct3DCreate9, все остальное идет через COM-vtable, поэтому враппер подхватывается чисто.
Почему это работает (по исходникам DXVK):
src/d3d9/d3d9_options.cpp:deviceLossOnFocusLossпо умолчаниюfalse;src/d3d9/d3d9_device.cpp:NotifyWindowActivatedсразу делаетreturn;TestCooperativeLevelчитает толькоm_deviceLostState, который выставляется лишь внутриReset();dxvk.allowFseпо умолчаниюfalse- настоящего эксклюзивного режима нет вообще.
В итоге Lock никогда не увидит DEVICENOTRESET. d3dx9_26.dll / d3dx9_27.dll остаются нативными: они работают на CPU и общаются с GPU через публичные COM-vtable, так что PostFX не ломается.
Полезные ключи dxvk.conf для fixed-function UE2.5:
| Ключ | Зачем |
|---|---|
d3d9.deviceLossOnFocusLoss=False | Значение по умолчанию, не трогать |
dxvk.allowFse=False | Значение по умолчанию |
d3d9.maxFrameLatency=1 | Минимальная очередь кадров |
d3d9.forceAspectRatio | Спрятать ultrawide-режимы от перечислителя разрешений KF |
d3d9.countLosableResources=False | Если Reset вдруг начнут отклонять |
Риски:
- DXVK на Windows официально не поддерживается upstream;
- issue #4730 - KF1 + DXVK: черный экран на части видеокарт AMD (на NVIDIA не воспроизводится), закрыт как "not planned";
- issue #1373 - статтер на компиляции шейдеров в начале сессии.
Шаг 3. Бинарный патч
Делает то же, что шаг 1, но без внешнего процесса: окно в оконном режиме сразу создается как borderless popup в точке 0,0 (нужен StartupFullscreen=False). Вторая правка сокращает паузу между повторами CreateDevice с 500 до 50 мс. Таблица смещений и патчер - в разделе Технические детали.
Заметка: штатная ветка SetWindowLong(GWL_STYLE, WS_POPUP|WS_VISIBLE) в WinDrv.dll (0x1026e) при обычном запуске не выполняется - она под условием if (ParentWindow != NULL), то есть только для HWND= из командной строки и для редактора.
Чего дешево сделать нельзя. Убрать Flush из SetRes, чтобы managed-текстуры пережили Reset, не получится: Reset требует освободить ресурсы D3DPOOL_DEFAULT, а Flush - единственное место, где они освобождаются. Выборочный flush - это переписывание функции, а не hex-патч. Оставленные указатели на мертвое устройство гарантированно приведут к крашу.
Шаг 4. Отключить прекеш (настройка, не патч)
[D3D9Drv.D3D9RenderDevice]
UsePrecaching=False
Это ровно тот ключ, который читается в Flush и включает прекеш после пересоздания устройства. Он убирает шаг 6 механизма - около 0.9 с на каждый alt-tab по логу. Цена - микростаттер при первом появлении объектов на карте.
Не рекомендуется: прокси на D3D9Ex
По спецификации устройство Direct3D 9Ex никогда не возвращает D3DERR_DEVICELOST, то есть это лечило бы проблему в корне. Но D3DPOOL_MANAGED под 9Ex запрещен, а UE2.5 держит все в managed и делает LockRect для заливки мипов. Понадобилась бы трансляция MANAGED→DEFAULT со staging-поверхностями и UpdateTexture. Автор dxwrapper про свой режим D3d9to9Ex=1 прямо пишет, что ни одна 3D-игра с ним нормально не заработала. Много работы ради того, что DXVK дает из коробки.
Побочная находка: спам ошибок в логе съедает FPS
В исследованном логе 6368 строк Error: из 9549, по строке на каждый кадр:
Error: ShopGUIVertList Package.ShopGUIVertList (Function KF15BetaMutators.ShopGUIVertList.DrawStat:0062) Accessed array '_infos' out of bounds (4/2)
Error: ShopGUIVertList Package.ShopGUIVertList (Function KF15BetaMutators.ShopGUIVertList.DrawStat:014E) Accessed array '_requirements' out of bounds (4/2)
Каждый кадр - форматирование строк и запись на диск. Источник здесь - сторонний мутатор KF15BetaMutators: в ShopGUIVertList.DrawStat индексы массивов _infos / _requirements выходят за границы, чинится это в UnrealScript. К alt-tab это отношения не имеет, но клиентский FPS ест постоянно. Если у вас лог растет тысячами одинаковых Error:, ищите виновный мутатор.
Предупреждения
Killing Floor 1 защищена VAC.
- Модификация
WinDrv.dll/D3D9Drv.dll- изменение файлов игры на VAC-защищенном тайтле. - Прокси-
d3d9.dll(DXVK / dgVoodoo2 / ReShade) - внедрение стороннего кода в процесс. - Риск бана ненулевой, гарантий не дает никто.
- Вариант из шага 1 (внешний скрипт, только
SetWindowLongпо чужому окну) файлов игры не трогает и в процесс не лезет - из всего перечисленного только он вне этой зоны.
Steam "Проверить целостность файлов игры" откатит любой байтовый патч. Патчер к тому же сам делает .bak.
Что проверено, а что нет
Патчи и скрипт не запускались на живой игре. Проверено только:
- смещения патча против реальных установленных файлов - совпали 5 из 5;
- логика функций по дизассемблеру;
- механизм подтверждается реальным
KillingFloor.log.
Проверка тривиальна: после alt-tab ищите Creating device в System/KillingFloor.log. Предлагаемый порядок, каждый шаг обратим:
1. FSO-чекбокс -> grep "Creating device"
2. windowed + kf-borderless.ps1 -> grep "Creating device"
3. DXVK x32 d3d9.dll -> grep "Creating device"
4. kf-patch.py <System> apply borderless fastretry -> grep "Creating device"
Технические детали
Адреса в бинарниках (build 3339)
UD3D9RenderDevice::Lock (D3D9Drv.dll, RVA 0x128b0), вызывается каждый кадр:
hr = Direct3DDevice9->TestCooperativeLevel() ; call [edx+0xc] @0x12958
if hr == 0x88760868 (D3DERR_DEVICELOST) -> кадр выброшен, крутимся вхолостую
if hr == 0x88760869 (D3DERR_DEVICENOTRESET) -> ForceReset=1; SetRes(...) @0x129a3
UD3D9RenderDevice::SetRes (RVA 0x15630):
| Адрес | Что происходит |
|---|---|
0x15708 | Flush(Viewport): сносит весь ResourceList (текстуры / VB / IB), релизит vertex- и pixel-шейдеры, вызывает UMaterial::ClearFallbacks(), ставит PrecacheOnFlip = UsePrecaching (mov [esi+0x48], eax @ 0x16977; ключ читается из [esi+0x42f8]) |
0x15ed2 | Device->Reset() - падает из-за живых ресурсов D3DPOOL_DEFAULT |
0x15ede | Release() |
0x15ef6 | лог "Creating device", полное пересоздание устройства |
0x16247 | при неудаче appSleep(0.5f) и повтор |
Далее - TripPostFX::Init, все SetRenderState / SetSamplerState, а PrecacheOnFlip заставляет UGameEngine::Draw прекешировать всю карту.
Быстрый выход из SetRes ("Enter SetRes: %dx%d Fullscreen %d. Already..." @ 0x15694) не срабатывает никогда: Lock выставляет ForceReset до вызова, и проверка cmp [esi+0x492c],0 его пропускает. В логе нет ни одной строки Already.
Проверка размера клиентской области в Lock - GetClientRect @ 0x129f7. В WinDrv.dll EndFullscreen() вызывается только при GIsOpenGL || GIsPixomatic (@ 0x135ab). Пул текстур задан в D3D9Resource.cpp: #define RESOURCE_POOL D3DPOOL_MANAGED.
Таблица смещений патча
В секции .text этих DLL file offset равен RVA, поэтому смещения ниже - прямые смещения в файле.
borderless WinDrv.dll UWindowsViewport::OpenWindow
0x101C9 00 00 CF 10 -> 00 00 00 90 push <style> (аргумент AdjustWindowRect)
0x101D3 00 00 CF 10 -> 00 00 00 90 mov [ebp+0C], <style> (аргумент PerformCreateWindowEx)
0x101E7 00 00 00 80 -> 00 00 00 00 CW_USEDEFAULT X -> 0
0x101F4 00 00 00 80 -> 00 00 00 00 CW_USEDEFAULT Y -> 0
; WS_VISIBLE|WS_OVERLAPPEDWINDOW (0x10CF0000) -> WS_POPUP|WS_VISIBLE (0x90000000)
; 0x10CF0000 встречается в файле ровно дважды, оба раза здесь
fastretry D3D9Drv.dll UD3D9RenderDevice::SetRes, цикл повтора CreateDevice
0x16248 00 00 00 3F -> CD CC 4C 3D appSleep(0.5f) -> appSleep(0.05f)
Патчер kf-patch.py
Каждая правка проверяет исходные байты перед записью и сохраняет .bak. Запуск: python kf-patch.py <папка System> list|apply|revert [имя патча ...].
#!/usr/bin/env python3
"""Reversible byte patches for the Killing Floor 1 client (build 3339, D3D9Drv/WinDrv).
python kf-patch.py <KF System dir> list|apply|revert [patch-name ...]
Every patch verifies the expected original bytes before writing and keeps a .bak.
Steam "Verify integrity of game files" restores the originals too.
"""
import os, shutil, sys
# name -> (file, [(file_offset, original_bytes_hex, patched_bytes_hex)], description)
PATCHES = {
'borderless': ('WinDrv.dll', [
# UWindowsViewport::OpenWindow: window style WS_VISIBLE|WS_OVERLAPPEDWINDOW -> WS_POPUP|WS_VISIBLE
(0x101C9, '0000cf10', '00000090'), # push <style> (arg to AdjustWindowRect)
(0x101D3, '0000cf10', '00000090'), # mov [ebp+0xC], <style> (arg to PerformCreateWindowEx)
# CW_USEDEFAULT -> 0 so the popup lands at the monitor origin
(0x101E7, '00000080', '00000000'),
(0x101F4, '00000080', '00000000'),
], 'windowed mode becomes a borderless popup at 0,0 (needs StartupFullscreen=False)'),
'fastretry': ('D3D9Drv.dll', [
# UD3D9RenderDevice::SetRes CreateDevice retry loop: appSleep(0.5f) -> appSleep(0.05f)
(0x16248, '0000003f', 'cdcc4c3d'),
], 'CreateDevice retry backoff 500ms -> 50ms'),
}
def sites(name):
f, s, _ = PATCHES[name]
return f, s
def run(root, action, names):
for name in names:
fname, spots = sites(name)
path = os.path.join(root, fname)
data = bytearray(open(path, 'rb').read())
want = 1 if action == 'apply' else 2
have = 2 if action == 'apply' else 1
for off, orig, new in spots:
cur = bytes(data[off:off + len(bytes.fromhex(orig))])
src = bytes.fromhex((orig, new)[have - 1])
dst = bytes.fromhex((orig, new)[want - 1])
if cur == dst:
print(' %s+%#x already %s' % (fname, off, action + 'd'))
continue
if cur != src:
sys.exit('ABORT %s+%#x: expected %s, found %s (wrong build or already modified)'
% (fname, off, src.hex(), cur.hex()))
data[off:off + len(dst)] = dst
print(' %s+%#x %s -> %s' % (fname, off, src.hex(), dst.hex()))
bak = path + '.bak'
if action == 'apply' and not os.path.exists(bak):
shutil.copy2(path, bak)
print(' backup: %s' % bak)
open(path, 'wb').write(data)
print('%s: %s' % (name, action + 'd'))
if __name__ == '__main__':
if len(sys.argv) < 3:
sys.exit(__doc__)
root, action = sys.argv[1], sys.argv[2]
names = sys.argv[3:] or list(PATCHES)
if action == 'list':
for n, (f, s, d) in PATCHES.items():
print('%-12s %-12s %d site(s) %s' % (n, f, len(s), d))
elif action in ('apply', 'revert'):
run(root, action, names)
else:
sys.exit(__doc__)
Источники
| Ссылка | Что подтверждает |
|---|---|
Wallz/UnrealEngineSRC - UT2004 [v3369] (Retail)/D3D9Drv/Src/D3D9RenderDevice.cpp | SetRes с комментарием //!!vogel: always recreate the device, Flush первой строкой, повтор с appSleep(0.5f), Lock с TestCooperativeLevel |
То же дерево, WinDrv/Src/WinViewport.cpp | WM_KILLFOCUS вызывает EndFullscreen только для OpenGL/Pixomatic; ChangeDisplaySettings отсутствует |
jmarshall23/Unreal2004 - D3D9Drv/Src/D3D9Resource.cpp | #define RESOURCE_POOL D3DPOOL_MANAGED |
| OldUnreal UT2004 Release Notes | Официальное подтверждение: решение - переход с exclusive fullscreen на borderless |
| Microsoft: Lost Devices (Direct3D 9) | "A Direct3D 9Ex device never returns D3DERR_DEVICELOST"; alt-tab как причина потери устройства для обычного D3D9 |
| Microsoft: D3DPOOL | D3DPOOL_MANAGED недопустим с IDirect3DDevice9Ex |
DXVK d3d9_options.cpp | deviceLossOnFocusLoss по умолчанию false |
DXVK d3d9_device.cpp | Ранний return в NotifyWindowActivated; TestCooperativeLevel читает только m_deviceLostState |
| DXVK Wiki: Windows | Windows официально не поддерживается; 32-битные DLL из x32/ |
| DXVK issue #4730 | KF1 + DXVK: черный экран на части AMD, закрыт как "not planned" |
| DXVK issue #2605 | UT2004 не восстанавливается после alt-tab из fullscreen |
| Microsoft DirectX blog: Demystifying Full Screen Optimizations | Как FSO подменяет Fullscreen Exclusive на borderless windowed |
| dxwrapper: Configuration | D3d9to9Ex и оговорка автора про неработающие 3D-игры |
| metallicafan212/UE2SelectionFixes | Прецедент бинарного патча D3DDrv.dll именно под Killing Floor 3339 |
| PCGamingWiki: Killing Floor | Borderless только через внешний инструмент; API D3D 8/9 + OpenGL 2.1, только 32-bit |