Проблемы и оптимизация

Killing Floor: долгий alt-tab и фризы при сворачивании - причина и решение

Если Killing Floor 1 после alt-tab надолго подвисает с черным экраном, монитор "моргает", а возвращение в игру занимает секунды - это не "просто старый движок". У проблемы одна конкретная причина: потеря Direct3D 9-устройства при выходе из эксклюзивного полноэкранного режима. Ниже - что именно происходит и что с этим делать, от самого простого способа к самому сложному.

Меню настроек экрана Killing Floor Меню Display в Killing Floor: рендер, разрешение и уровни детализации. Скриншот: PCGamingWiki.

Разбор сделан для Killing Floor build 3339 (Unreal Engine 2.5, 32-bit): реверс D3D9Drv.dll и WinDrv.dll, сверка с retail-исходниками UT2004 v3369 и анализ реального System/KillingFloor.log.

Коротко: что делать

  1. Попробуйте чекбокс "Отключить оптимизации во весь экран" (5 секунд, без риска).
  2. Рекомендуется: играйте в оконном режиме с разрешением монитора и уберите рамку внешним скриптом - файлы игры не трогаются.
  3. Если нужен ускоренный вариант и вы принимаете риски - 32-битный DXVK d3d9.dll.
  4. Дополнительно: 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, который:

  1. Первым делом делает Flush - выгружает все текстуры, вершинные и индексные буферы, шейдеры.
  2. Пробует Device->Reset() - и это не удается, потому что остаются живые ресурсы D3DPOOL_DEFAULT (render-target'ы PostFX, xD3D9Helper).
  3. Полностью освобождает и создает устройство заново (в логе - Creating device).
  4. При неудаче ждет appSleep(0.5f) и повторяет - главный поток блокируется квантами по 500 мс.
  5. Заново инициализирует PostFX и все render/sampler state.
  6. Прекеширует всю карту, а не только видимую часть.

Текстуры лежат в 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):

АдресЧто происходит
0x15708Flush(Viewport): сносит весь ResourceList (текстуры / VB / IB), релизит vertex- и pixel-шейдеры, вызывает UMaterial::ClearFallbacks(), ставит PrecacheOnFlip = UsePrecaching (mov [esi+0x48], eax @ 0x16977; ключ читается из [esi+0x42f8])
0x15ed2Device->Reset() - падает из-за живых ресурсов D3DPOOL_DEFAULT
0x15edeRelease()
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.cppSetRes с комментарием //!!vogel: always recreate the device, Flush первой строкой, повтор с appSleep(0.5f), Lock с TestCooperativeLevel
То же дерево, WinDrv/Src/WinViewport.cppWM_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: D3DPOOLD3DPOOL_MANAGED недопустим с IDirect3DDevice9Ex
DXVK d3d9_options.cppdeviceLossOnFocusLoss по умолчанию false
DXVK d3d9_device.cppРанний return в NotifyWindowActivated; TestCooperativeLevel читает только m_deviceLostState
DXVK Wiki: WindowsWindows официально не поддерживается; 32-битные DLL из x32/
DXVK issue #4730KF1 + DXVK: черный экран на части AMD, закрыт как "not planned"
DXVK issue #2605UT2004 не восстанавливается после alt-tab из fullscreen
Microsoft DirectX blog: Demystifying Full Screen OptimizationsКак FSO подменяет Fullscreen Exclusive на borderless windowed
dxwrapper: ConfigurationD3d9to9Ex и оговорка автора про неработающие 3D-игры
metallicafan212/UE2SelectionFixesПрецедент бинарного патча D3DDrv.dll именно под Killing Floor 3339
PCGamingWiki: Killing FloorBorderless только через внешний инструмент; API D3D 8/9 + OpenGL 2.1, только 32-bit