diff --git a/TZ.md b/TZ.md new file mode 100644 index 0000000..0f1b4a9 --- /dev/null +++ b/TZ.md @@ -0,0 +1,361 @@ +# Техническое задание: autoPriority + +## Назначение + +Программа для Windows, которая автоматически управляет приоритетами процессов на основе их потребления оперативной памяти. Работает в фоне, периодически сканируя все процессы и корректируя их приоритеты CPU. + +## Параметры командной строки + +| Параметр | По умолчанию | Описание | +|---|---|---| +| `-mem` | `512M` | Порог памяти для повышения приоритета. Процессы с RSS ≥ этого значения повышаются до HIGH | +| `-game-mem` | `4G` | Порог памяти для активации игрового режима. Процессы с RSS ≥ этого значения считаются играми. `0` — игровой режим отключён. Должен быть больше `-mem` | +| `-interval` | `1m` | Интервал сканирования. Минимум 10 секунд | +| `-dry-run` | `false` | Только логирование, без изменения приоритетов | + +Формат памяти: число с суффиксом `K`/`M`/`G` (например `512M`, `4G`, `2048M`). Без суффикра — байты. + +## Структуры данных + +Программа поддерживает четыре основные карты (map), хранящиеся в памяти между сканами: + +### `promoted: map[pid → name]` +Процессы, повышенные до HIGH в нормальном режиме. Если RSS процесса падает ниже порога `-mem`, он понижается до NORMAL и удаляется из карты. При выходе из программы все процессы из карты восстанавливаются до NORMAL. + +### `blocked: map[pid → name]` +Процессы, для которых не удалось открыть или изменить приоритет (системные процессы, античит). Полностью исключаются из попыток смены приоритета, но их RSS **всё равно измеряется** каждый скан (для определения игрового режима). Удаляются из карты, когда PID больше не существует в списке процессов. + +### `gameProcs: map[pid → name]` +Процессы, превысившие `-game-mem` порог. Определяют, активен ли игровой режим. Удаляются, когда процесс умирает (PID исчезает из списка или имя не совпадает). + +### `gameSaved: map[pid → {name, originalPriority}]` +Оригинальные приоритеты процессов, пониженных до IDLE в игровом режиме. Используются для восстановления при выключении игрового режима. Заполняется только при успешном понижении приоритета. Каждый PID сохраняется один раз (при первом понижении), чтобы запомнить приоритет, который был **до** игрового режима. + +## Запуск программы + +1. Разбор флагов командной строки +2. Проверка: если `game-mem > 0` и `game-mem <= mem` — выход с ошибкой +3. Если `interval < 10s` — принудительно установить 10s +4. **Уничтожение других экземпляров:** получить список всех процессов, найти процессы с тем же именем файла, что и у текущего процесса, но с другим PID, и завершить их через `TerminateProcess` +5. Подождать 100мс (ОС освобождает файловые дескрипторы) +6. Удалить старый лог-файл, создать новый (в `%TEMP%\autopriority.log`) +7. Установить собственный приоритет в IDLE +8. Записать в лог параметры запуска +9. Инициализировать пустые карты: `promoted`, `blocked`, `gameProcs`, `gameSaved` +10. Зарегистрировать deferred-функцию восстановления (см. раздел «Завершение») +11. Выполнить первый скан немедленно +12. Войти в цикл: ждать сигнала завершения ИЛИ тикера интервала, при тике — выполнять скан + +## Алгоритм сканирования (двухпроходный) + +Каждый скан состоит из двух проходов. **Первый проход** собирает данные о памяти и определяет игровые процессы. **Второй проход** применяет приоритеты, уже зная, активен ли игровой режим. Это необходимо, чтобы игровой режим срабатывал на первом же скане, а не на следующем. + +### Предварительный этап: очистка карт + +Перед проходами программа получает список всех процессов и строит `pidMap` (pid → name). Затем очищает четыре карты: + +- **`promoted`**: если PID исчез или имя не совпадает — удалить (процесс умер или PID перешёл другому процессу) +- **`blocked`**: если PID исчез — удалить (процесс умер, PID может быть переиспользован) +- **`gameProcs`**: если PID исчез или имя не совпадает — удалить +- **`gameSaved`**: если PID исчез или имя не совпадается — удалить + +Проверка имени защищает от PID reuse: если процесс умер и его PID получил другой процесс, старая запись удаляется. + +### Проход 1: Сбор RSS и определение игровых процессов + +Для каждого процесса (кроме себя и PID 0): + +1. Открыть процесс с правом `PROCESS_QUERY_LIMITED_INFORMATION` (0x1000). Это узкое право, которое дают почти все процессы, включая защищённые. Использование `PROCESS_QUERY_INFORMATION` (0x0400) приводит к Access Denied для многих пользовательских процессов. +2. Если `OpenProcess` упал: + - Добавить PID в `blocked` (если ещё не там) + - Записать в лог `BLOCK ... (added to exclusion list)` + - **Перейти к следующему процессу** (не добавлять в `procList`) + - Примечание: blocked процессы не попадают в `procList`, но если они были добавлены в `blocked` на предыдущем скане и всё ещё живы, они НЕ измеряются повторно. Это допустимо, потому что blocked процессы не могут менять приоритет. + + **Важное исключение:** если процесс УЖЕ в `blocked` (добавлен ранее), то не логировать повторно — просто пропустить. + +3. Вызвать `GetProcessMemoryInfo` для получения `WorkingSetSize` (RSS) +4. Если упало — пропустить процесс +5. Закрыть handle +6. Записать `{PID, Name, RSS}` в `procList` +7. Если `game-mem > 0` и `RSS >= game-mem`: + - Добавить PID в `gameProcs` (если ещё не там) + +После прохода 1 вычислить: +``` +hasGame = (game-mem > 0) AND (len(gameProcs) > 0) +``` + +### Проход 2: Применение приоритетов + +Для каждого процесса из `procList` (в порядке обхода): + +Определить `isBlocked`: процесс находится в `blocked`. + +#### Вариант A: Процесс — игровой (есть в `gameProcs`) + +Если `hasGame` (игровой режим активен): +- Если `isBlocked`: записать в лог `GAME ... — tracked as game (blocked)`, перейти к следующему +- Открыть с `PROCESS_QUERY_LIMITED_INFORMATION` +- Получить текущий приоритет +- Если уже HIGH — пропустить +- Если `dry-run`: записать `would set HIGH (game)`, пропустить +- Иначе: попытаться установить HIGH через `trySetPrio` (см. ниже) + - Успех → `GAME ... -> HIGH` + - Провал → `GAME ... — tracked as game (priority change failed)` + +Перейти к следующему процессу. + +#### Вариант B: Игровой режим активен, процесс НЕ игровой + +Если `isBlocked`: пропустить (не пытаться понизить) +Открыть с `PROCESS_QUERY_LIMITED_INFORMATION` +Получить текущий приоритет +Если уже IDLE — пропустить +Если `dry-run`: записать `would set IDLE (game)`, пропустить +Иначе: попытаться установить IDLE через `trySetPrio` + - Успех → сохранить оригинальный приоритет в `gameSaved` (если ещё не сохранён), удалить из `promoted` (если там был), записать `GAME ... -> IDLE` + - Провал → процесс добавлен в `blocked`, больше не пытаться + +Перейти к следующему процессу. + +#### Вариант C: Нормальный режим (игровой режим не активен) + +Если `isBlocked`: пропустить + +Открыть с `PROCESS_QUERY_LIMITED_INFORMATION` + +**Если RSS < `-mem`:** +- Получить текущий приоритет +- Если приоритет ABOVE_NORMAL / HIGH / REALTIME → понизить до NORMAL (DEMOTE), удалить из `promoted` (если там был) +- Иначе пропустить + +**Если RSS >= `-mem`:** +- Получить текущий приоритет +- Если уже HIGH — пропустить (процесс уже повышен, возможно из `promoted`) +- Если `dry-run`: записать `would set HIGH`, пропустить +- Иначе: попытаться установить HIGH через `trySetPrio` + - Успех → добавить в `promoted`, записать `PROMOTE ... -> HIGH` + - Провал → процесс добавлен в `blocked` + +### Финал скана: управление игровым режимом + +После прохода 2, если `game-mem > 0`: + +Вычислить `newHasGame = len(gameProcs) > 0` + +**Если `newHasGame == true` и `gameMode == false`:** +- `gameMode = true` +- Записать `GAME MODE ON` + +**Если `newHasGame == false` и `gameMode == true`:** +- `gameMode = false` +- Записать `GAME MODE OFF` +- **Восстановить приоритеты:** для каждого PID в `gameSaved`: + - Проверить, что PID всё ещё существует и имя совпадает (защита от PID reuse) + - Если `dry-run`: записать `[DRY-RUN] RESTORE ... -> originalPriority` + - Иначе: установить оригинальный приоритет через `setPrio` (отдельный `OpenProcess` с `PROCESS_SET_INFORMATION`) + - Успех → `RESTORE ... -> originalPriority` + - Провал → `RESTORE ... error` + - Удалить запись из `gameSaved` +- Очистить `gameSaved` (все записи обработаны) + +## Функция trySetPrio + +``` +trySetPrio(queryHandle, pid, name, targetClass) → bool +``` + +1. Закрыть `queryHandle` (handle от `PROCESS_QUERY_LIMITED_INFORMATION`) +2. Открыть процесс заново с правом `PROCESS_SET_INFORMATION` (0x0200) + - Разделение прав: многие процессы дают `QUERY` но не дают `SET` в комбинации +3. Если `OpenProcess` упал: + - Добавить PID в `blocked` + - Записать `BLOCK ... open SetInformation failed` + - Вернуть `false` +4. Вызвать `SetPriorityClass(handle, targetClass)` +5. Закрыть handle (через defer) +6. Если `SetPriorityClass` упал: + - Добавить PID в `blocked` + - Записать `BLOCK ... SetPriorityClass failed` + - Вернуть `false` +7. Подождать 50мс (плавная смена приоритетов, предотвращает статтеры) +8. Вернуть `true` + +## Функция setPrio (для восстановления) + +``` +setPrio(pid, targetClass) → error +``` + +1. Открыть с `PROCESS_SET_INFORMATION` +2. Вызвать `SetPriorityClass` +3. Закрыть handle +4. Вернуть ошибку или nil + +## Завершение программы (deferred) + +При получении сигнала SIGINT/SIGTERM: + +1. Получить свежий список всех процессов +2. Построить `restoreMap` (pid → name) +3. **Восстановить `gameSaved`:** для каждого PID — проверить имя, установить оригинальный приоритет, записать `RESTORE ... -> originalPriority` +4. **Восстановить `promoted`:** для каждого PID — проверить имя, установить NORMAL, записать `RESTORE ... -> NORMAL` +5. **Восстановить `gameProcs`:** для каждого PID — проверить имя, установить NORMAL, записать `RESTORE ... -> NORMAL` +6. Записать `autoPriority stopped` + +Порядок важен: `gameSaved` восстанавливается первым, потому что `promoted` мог быть удалён из `promoted` во время игрового режима, но его оригинальный приоритет сохранён в `gameSaved`. + +## Классы приоритетов Windows + +| Константа | Значение | Имя в логе | +|---|---|---| +| IDLE | 0x0040 | IDLE | +| BELOW_NORMAL | 0x4000 | BELOW_NORMAL | +| NORMAL | 0x0020 | NORMAL | +| ABOVE_NORMAL | 0x8000 | ABOVE_NORMAL | +| HIGH | 0x0080 | HIGH | +| REALTIME | 0x0100 | REALTIME | + +### Логика isAboveNormal + +Возвращает `true` для `ABOVE_NORMAL`, `HIGH`, `REALTIME`. НЕ возвращает `true` для `BELOW_NORMAL`, потому что числовое значение `BELOW_NORMAL` (0x4000) больше `NORMAL` (0x0020), хотя семантически ниже. Поэтому используется явная проверка, а не числовое сравнение. + +## Логирование + +Лог пишется в `%TEMP%\autopriority.log`. При запуске старый лог удаляется, создаётся новый. Каждая запись синхронизируется на диск (`Sync()`). Формат: `[DD.MM.YYYY HH:MM:SS] сообщение`. + +### Префиксы логов + +| Префикс | Значение | +|---|---| +| `PROMOTE` | Процесс повышен до HIGH (нормальный режим) | +| `DEMOTE` | Процесс понижен до NORMAL (нормальный режим) | +| `GAME` | Процесс изменён вgame режиме (→ HIGH или → IDLE) | +| `GAME MODE ON`| Игровой режим активирован | +| `GAME MODE OFF`| Игровой режим деактивирован, процессы восстановлены | +| `BLOCK` | OpenProcess или SetPriorityClass упал; процесс в исключениях | +| `RESTORE` | Процесс восстановлен в оригинальный приоритет | +| `[DRY-RUN]` | Было бы изменение приоритета (dry-run режим) | + +## Windows API + +Программа использует только `kernel32.dll` и `psapi.dll` через raw syscall. Без внешних зависимостей. + +### Используемые функции + +- `CreateToolhelp32Snapshot` — снимок списка процессов +- `Process32FirstW` / `Process32NextW` — обход снимка +- `OpenProcess` — открытие handle процесса +- `GetProcessMemoryInfo` — получение WorkingSetSize (RSS) +- `GetPriorityClass` — чтение текущего приоритета +- `SetPriorityClass` — установка приоритета +- `CloseHandle` — закрытие handle +- `TerminateProcess` — завершение других экземпляров + +### Права доступа + +- `PROCESS_QUERY_LIMITED_INFORMATION` (0x1000) — для чтения памяти и приоритета. Это право дают почти все процессы. +- `PROCESS_SET_INFORMATION` (0x0200) — для изменения приоритета. Запрашивается отдельно, только когда нужно менять приоритет. +- `PROCESS_TERMINATE` (0x0001) — для уничтожения других экземпляров. + +Разделение прав критично: запрос `QUERY|SET` комбинированно приводит к Access Denied для многих пользовательских процессов (KeePassXC, HD-Player), хотя каждое право по отдельности они дают. + +## Структура processEntry32 + +``` +Size uint32 +CntUsage uint32 +PID uint32 +DefaultHeapID uintptr // НЕ uint32 — intptr на x64 +ModuleID uint32 +CntThreads uint32 +ParentPID uint32 +PrioClass int32 +Flags uint32 +ExeFile [260]uint16 // UTF-16 +``` + +`DefaultHeapID` должен быть `uintptr`, не `uint32` — иначе на x64 структура неправильно выровнена и `Process32Next` падает. + +## Сценарии работы + +### Сценарий 1: Обычная работа + +1. Запуск программы +2. Chrome потребляет 600MB (≥ 512MB) → `PROMOTE Chrome.exe -> HIGH`, добавляется в `promoted` +3. Chrome сбрасывает память до 300MB (< 512MB) → `DEMOTE Chrome.exe -> NORMAL`, удаляется из `promoted` +4. Discord потребляет 700MB → `PROMOTE Discord.exe -> HIGH` +5. Discord сбрасывает до 200MB → `DEMOTE Discord.exe -> NORMAL` + +### Сценарий 2: Понижение высокого приоритета + +1. `wininit.exe` запущен с HIGH, потребляет 6MB (< 512MB порога) +2. Программа видит: RSS < mem, приоритет ABOVE_NORMAL → `DEMOTE wininit.exe -> NORMAL` +3. Это применяется ко всем процессам (включая ранее повышенные, если их RSS упал ниже порога) + +### Сценарий 3: Игровой режим + +1. Запуск RustClient, потребление растёт с 1GB до 8GB +2. На скане, где RSS ≥ 4GB: RustClient добавляется в `gameProcs` +3. `hasGame = true` +4. RustClient → `trySetPrio` HIGH → если античит блокирует → `BLOCK RustClient.exe` + `GAME RustClient.exe — tracked as game (priority change failed)` +5. RustClient остаётся в `gameProcs` (важно: блокировка SetPriorityClass не удаляет из gameProcs) +6. Все остальные процессы → IDLE (`GAME Chrome.exe -> IDLE`, etc.) +7. `GAME MODE ON` +8. При следующем скане RustClient всё ещё в `gameProcs`, остальные процессы уже IDLE — пропускаются + +### Сценарий 4: Выход из игрового режима + +1. RustClient закрыт +2. `gameProcs` очищен (PID исчез) +3. `newHasGame = false`, `gameMode = true` → переход +4. `gameMode = false`, `GAME MODE OFF` +5. Для каждого процесса в `gameSaved`: восстановить оригинальный приоритет + - `RESTORE Chrome.exe -> NORMAL` (был NORMAL до игрового режима) + - `RESTORE Discord.exe -> HIGH` (был HIGH, потому что был в `promoted`) +6. `gameSaved` очищен +7. Нормальный режим возобновляется: процессы с RSS ≥ mem повышаются, с RSS < mem и выше NORMAL — понижаются + +### Сценарий 5: Заблокированный процесс становится игрой + +1. RustClient стартует, потребляет 3.9GB +2. RSS ≥ 512MB (mem), не в `promoted` → попытка PROMOTE → античит блокирует → `BLOCK RustClient.exe` (добавлен в `blocked`) +3. RustClient дорастает до 8GB +4. На следующем скане: RustClient в `blocked`, но **проход 1 всё равно пытается его открыть** +5. `OpenProcess(QUERY_LIMITED)` для blocked процесса — если упадёт, пропустит. Если пройдёт — RSS измеряется +6. Если RustClient в `blocked` из-за `SetPriorityClass` (не `OpenProcess`), то `OpenProcess(QUERY_LIMITED)` пройдёт, RSS измерится, и он попадёт в `gameProcs` +7. `hasGame = true`, `GAME MODE ON` +8. В проходе 2: RustClient в `gameProcs`, но `isBlocked = true` → `GAME RustClient.exe — tracked as game (blocked)`, приоритет не меняется +9. Остальные процессы понижаются до IDLE + +### Сценарий 6: PID reuse + +1. Процесс A (PID 1234, "chrome.exe") повышен, в `promoted` +2. Процесс A умирает +3. Новый процесс B (PID 1234, "notepad.exe") запускается +4. На следующем скане: `pidMap[1234] = "notepad.exe"`, но `promoted[1234] = "chrome.exe"` +5. Имя не совпадает → `delete(promoted, 1234)` +6. Процесс B обрабатывается как новый процесс + +### Сценарий 7: Системные процессы + +1. `svchost.exe` (PID 932) — `OpenProcess(QUERY_LIMITED)` возвращает Access Denied +2. Добавляется в `blocked`, логируется `BLOCK` +3. На следующем скане: PID 932 всё ещё в `blocked`, `OpenProcess` снова падает, но лог НЕ пишется (процесс уже в `blocked`) +4. Когда процесс умирает: PID исчезает из `pidMap` → удаляется из `blocked` + +## Сборка + +``` +go build -ldflags="-H windowsgui -s -w" -o autopriority.exe . +``` + +- `-H windowsgui` — без консольного окна +- `-s -w` — strip debug info, уменьшение размера + +Требуется Go 1.26+. Только Windows (`//go:build windows`). + +## Автозапуск + +Поместить ярлык на `autopriority.exe` в папку `shell:startup` (Win+R → `shell:startup`). diff --git a/main.go b/main.go index 9c906a5..0c5b22e 100644 --- a/main.go +++ b/main.go @@ -538,10 +538,6 @@ func main() { continue } - if _, ok := promoted[p.PID]; ok { - continue - } - h, err := openProc(p.PID, ProcessQueryLimitedInformation) if err != nil { continue @@ -556,6 +552,7 @@ func main() { p.Name, p.PID, formatMemSize(rssBytes), prioName(origPrio)) } else { if trySetPrio(h, p.PID, p.Name, PriorityClassNormal) { + delete(promoted, p.PID) logf("DEMOTE %s (PID %d) RSS=%s, %s -> NORMAL", p.Name, p.PID, formatMemSize(rssBytes), prioName(origPrio)) }