Фоновый агент и журнал фаз¶
Режим Rockstar держит модифицированные файлы игры вне папки GTA (в «образе») и подставляет их обратно только на время игровой сессии - Rockstar Games Launcher сканирует свою папку и чинит всё, что не сошлось с манифестом. Значит, кто-то должен заметить старт игры за миллисекунды до того, как она откроет update.rpf, и вернуть чистые файлы, когда игра закрылась. Этим занимается фоновый агент.
Агент - не отдельная программа, а режим самого лаунчера: Miami Graphics.exe --hotswap-agent. Он живёт вне окна лаунчера, переживает его закрытие и перезагрузку машины, и потому обязан быть устойчив к собственной внезапной смерти: подмена гигабайтных файлов, прерванная на середине, оставляет игрока без игры. Отсюда журнал фаз, который пишется до каждой файловой операции, и восстановление, которое читает журнал на каждом старте.
Здесь - устройство цикла агента: чем он будится, что и куда пишет, как чинит прерванную операцию, как ловит ремонт со стороны Rockstar Games Launcher и что остаётся в логе после всего этого.
Агент - это режим лаунчера, а не отдельный exe¶
Задача Планировщика создаётся только при включении режима и снимается при выключении: выключенный режим не оставляет в системе ничего работающего.
private const string HotSwapTaskName = "MiamiGraphicsAgent";
private static string? ResolveAgentExe() => Environment.ProcessPath;
RunSchtasks($"/Delete /TN \"{HotSwapTaskName}\" /F", out _);
if (!RunSchtasks($"/Create /TN \"{HotSwapTaskName}\" /TR \"\\\"{agentExe}\\\" --hotswap-agent\" /SC ONLOGON /RL HIGHEST /F", out var so))
{
// не удалось зарегистрировать - откатываем заморозку целиком
Core.HotSwap.GameFileSwapper.Unfreeze(gtaRoot, swapMethod, out _);
return new InjectResultDto(false, "Не удалось зарегистрировать фоновый агент: " + so.Trim(), null);
}
Три решения, которые тут неочевидны:
ONLOGON, а не служба. Агент работает с файлами игры и должен видеть тот же профиль, что и игрок./RL HIGHESTнужен дляFile.ReplaceвProgram Files-подобных путях и для подписки WMI.Environment.ProcessPath, а не отдельный бинарник. Установленная копия лаунчера собрана self-contained; отдельный агент был бы framework-dependent и не стартовал бы на машине без .NET. Поэтому агент - режим того же exe: без окна, без общего single-instance мьютекса, только цикл.- Отказ
schtasksоткатывает заморозку. Иначе моды уже уехали бы в образ, а подставлять их обратно стало бы некому - игрок остался бы с чистой игрой и включённым режимом.
Свой мьютекс у агента отдельный, Global\MiamiGraphicsAgent_SingleInstance: задача при входе в систему и немедленный запуск из моста легко дают два процесса, второй обязан молча выйти.
if (Array.Exists(e.Args, a => string.Equals(a, "--hotswap-agent", StringComparison.OrdinalIgnoreCase)))
{
MiamiGraphics.Core.HotSwap.HotSwapLog.Origin = "агент";
using var agentMutex = new System.Threading.Mutex(true, @"Global\MiamiGraphicsAgent_SingleInstance", out bool agentNew);
if (!agentNew) { /* ... Shutdown(0) ... */ }
try { MiamiGraphics.Core.HotSwap.HotSwapAgentLoop.Run(); }
catch (Exception agentEx) { HotSwapLog.Write("агент", "цикл умер", agentEx); }
}
Имя процесса у агента то же, что у окна лаунчера, поэтому гасить его по имени нельзя - убили бы само окно. Мост берёт pid из heartbeat-файла (см. ниже).
Одна итерация цикла¶
Шаг цикла задаётся планом способа. План - единственное место, где живёт знание «способ N ведёт себя так»; агент спрашивает у него свойства, а не сравнивает номера.
| Способ | Триггер | Шаг опроса | Тишина перед возвратом | Гасим процессы перед возвратом | Список процессов |
|---|---|---|---|---|---|
| 1 - агент, образ на диске игры | агент | 250 мс | 3000 мс | нет | боевой |
| 2 - агент, образ в своей папке | агент | 250 мс | 3000 мс | нет | боевой |
| 3 - ручной, образ на диске игры | человек | 1000 мс | 0 | нет | - |
| 4 - ручной, образ в своей папке | человек | 1000 мс | 0 | нет | - |
| 5 - клон ReplaceX | агент | 500 мс | 0 | да | расширенный |
При ручном триггере (способы 3 и 4) цикл не следит ни за чем: подмену инициирует лаунчер кнопкой, а агент лишь чинит незавершённые операции и отдаёт heartbeat. Держать это внутри цикла, а не «просто не запускать агента», приходится потому, что задача Планировщика могла остаться от прошлого способа, и подменять файлы за спиной у человека агент права не имеет.
За итерацию делается один снимок таблицы процессов, и по нему отвечаются оба вопроса - «жив ли лаунчер Rockstar» и «жива ли игра»:
using var procs = GameProcessWatcher.ProcSnapshot.Take();
sleepMs = !starts.Active && GameProcessWatcher.RockstarLauncherRunning(procs)
? Math.Min(plan.PollMs, 30)
: plan.PollMs;
Раньше здесь было четыре полных обхода за итерацию: три GetProcessesByName на имена лаунчера плюс GetProcesses на поиск игры. При шаге 250 мс это шестнадцать полных снимков таблицы процессов в секунду, круглые сутки, в фоновом процессе.
Когда режим выключен или GtaRoot не задан/не существует, цикл спит по 3000 мс и пишет об этом один раз - при переходе, а не каждую итерацию.
Пробуждение по событию вместо опроса¶
Агент обязан подставить моды до того, как игра откроет файлы, и пока единственным способом узнать о старте был опрос, за точность платили частотой: 250 мс в покое и 30 мс, пока на экране лаунчер Rockstar.
Замер на машине владельца (435 процессов): один обход таблицы - 7,6 мс, итерация агента до правок - 25 мс. Это около 10 % ядра в покое и 85 % ядра при открытом лаунчере Rockstar, круглые сутки. Игрок слышал это как «с Miami Graphics компьютер шумит в 2-4 раза громче».
Windows умеет сказать о старте процесса сама - Win32_ProcessStartTrace поверх ETW: событие приходит за миллисекунды и не стоит ничего, пока ничего не происходит.
public bool TryStart(out string? error)
{
error = null;
if (!OperatingSystem.IsWindows()) { error = "не Windows"; return false; }
try
{
_watcher = new ManagementEventWatcher(new WqlEventQuery("SELECT * FROM Win32_ProcessStartTrace"));
_watcher.EventArrived += OnStart;
_watcher.Start();
Active = true;
return true;
}
catch (Exception ex) { error = ex.Message; Active = false; return false; }
}
Подписка ничего не гарантирует и никого не заменяет. Опрос остался как был, просто с редким шагом; событие лишь будит цикл раньше таймаута. Не поднялась подписка (сломанный репозиторий WMI, урезанные права) - Active остаётся false, и агент возвращается к прежней частоте, включая шаг 30 мс рядом с лаунчером Rockstar.
private static void Sleep(ProcessStartNotifier starts, int ms)
{
if (starts.Active) starts.Wait(ms);
else Thread.Sleep(ms);
}
Детали, которые стоили отладки:
- Подписка ставится на объединение трёх списков: боевые процессы, список способа 5 и
Launcher,RockstarService,PlayGTAV. Реагировать надо и на лаунчер: игра стартует именно оттуда. - В событии
ProcessNameприходит с расширением (GTA5.exe), а сравниваем мы сProcessNameиз .NET - без него. Хвост.exeсрезается. - Обработчик приходит на чужом потоке WMI и может застать нас уже закрытыми. Необработанное исключение там убило бы процесс агента, а вместе с ним и возврат чистых файлов игре, - поэтому
Wake()глушит всё; разобрать событие не вышло - будим зря. - Нативный
CreateToolhelp32SnapshotвместоProcess.GetProcessesпробовали: вышло хуже - 13,2 мс против 7,6 мс на тех же 435 процессах. Оставлено как есть.
Пауза 10 секунд после неудачного arm¶
Отказ подмены при живой игре повторяется каждый шаг опроса, а причина у него обычно вечная на всю сессию - протухший образ или идущая докачка лаунчера. Без паузы это четыре записи в лог на каждые 250 мс: пятимегабайтный hotswap.log проворачивается за полчаса игры и уносит с собой ровно те строки, ради которых он заведён.
const int ArmRetryPauseSec = 10;
...
if (pid is null && lastArmError is not null) // игра закрылась, так и не получив моды
{
lastArmError = null;
armRetryAt = DateTime.MinValue; // следующая сессия начинается с чистого листа
}
Первая попытка сессии объявляется в логе, повторы после отказа идут молча, а текст ошибки пишется только когда он изменился.
Heartbeat раз в 10 секунд¶
Раз в 10 с агент вызывает восстановление и переписывает agent.json в корне образа. Запись атомарная - tmp + File.Move(overwrite: true).
if ((DateTime.UtcNow - lastBeat).TotalSeconds > 10)
{
HotSwapRecovery.EnsureConsistent(gta, out _);
Heartbeat(gta, plan.Trigger == HotSwapTrigger.Manual ? "manual"
: wasGame ? "armed" : "watching");
lastBeat = DateTime.UtcNow;
}
File.WriteAllText(tmp, JsonSerializer.Serialize(new
{
alive = true,
status,
note,
pid = Environment.ProcessId,
atUtc = DateTime.UtcNow.ToString("O"),
}));
File.Move(tmp, p, overwrite: true);
| Поле | Значения | Кому нужно |
|---|---|---|
status |
watching, armed, manual |
панель режима в настройках |
note |
текст последней проблемы (arm: ..., disarm: ..., ремонт Rockstar) |
там же, вместо здорового «включено» |
pid |
pid процесса агента | мосту, чтобы погасить агента (по имени нельзя - оно совпадает с окном лаунчера) |
atUtc |
момент записи | «агент не отвечает», если файл старше |
Ошибка записи heartbeat дедуплицируется по тексту: мёртвый heartbeat - это «агент не отвечает» в интерфейсе, причину обязаны оставить в логе, но одну, а не по строке каждые 10 с.
Журнал: пять фаз¶
Журнал (journal.json в корне образа) пишется до каждой файловой операции. После краха или выключения питания по нему однозначно видно, что где лежит.
public enum HotSwapPhase
{
Idle = 0, // игровой файл чистый, моды в образе - штатное состояние
Arming = 1, // идёт подмена
Armed = 2, // моды подставлены в игру, игра запущена
Disarming = 3, // идёт возврат
Freezing = 4, // идёт первичная заморозка
}
Запись атомарна тем же приёмом, что heartbeat, и каждая смена уходит в лог парой «была → стала» - по логу восстанавливается вся хронология свапа:
var old = Read(gtaRoot).Phase;
...
File.Move(tmp, p, overwrite: true);
HotSwapLog.Write("journal", $"фаза {old} -> {phase}" +
(gamePid is int pid ? $" (pid игры {pid})" : ""));
Freezing стоит особняком: заморозка - это перенос модифицированных файлов игры в образ и укладка чистых копий на их место, то есть минуты работы с двумя гигабайтами. Журналируем до первого move, иначе крах посреди копии оставил бы игру без update.rpf («files corrupted»), и восстановление не поняло бы, что чинить.
Фазы свопа заодно глушат ложные вердикты: проверка «игра обновилась под замороженным режимом» при фазе Arming, Disarming или Freezing возвращает false, потому что файлы игры в этот момент в движении. Без этого опрос статуса попадал в щель между «моды возвращены в игру» и «baseline удалён» на разморозке и писал в лог ложное «игра обновилась» через две миллисекунды после успешной разморозки.
Как по журналу чинится прерванная операция¶
HotSwapRecovery.EnsureConsistent зовут агент (раз в 10 с) и лаунчер (на старте при включённом режиме). Правило одно: игре - чистый файл, если игра не запущена.
| Фаза на входе | Что мог оставить крах | Что делает восстановление |
|---|---|---|
Freezing |
файл уехал в образ, чистая копия не успела встать - игра без update.rpf |
возвращает в игру все уехавшие файлы, удаляет swapset.json, снимает привязку образа, фаза → Idle |
Arming / Armed |
моды в игре, игры нет | Disarm; если возвращать нечего - фаза → Idle |
Arming / Armed, игра запущена |
- | не трогает ничего |
Disarming |
возврат оборван на середине | довершает Disarm тем же способом |
Idle |
недописанный .mgswap.tmp после выключения питания |
у копирующих способов убирает хвосты, больше ничего |
Три места, где сценарий неочевиден:
Откат заморозки идёт по всем файлам, а не по пропавшим. Заморозка обрабатывает файлы последовательно, и уже завершённые (в игре чистый, мод в образе) иначе осиротели бы: при следующем включении Freeze счёл бы их немодифицированными - моды потерялись бы молча.
foreach (var rel in HotSwapPaths.RelPaths)
{
if (!eng.HasImage(gtaRoot, rel)) continue;
eng.RollbackFreezeOne(gtaRoot, rel);
HotSwapLog.Write("recovery", $"{rel}: мод возвращён в игру (откат заморозки)");
}
Ручные способы восстановление не разоружает. Там решение «игра кончилась» принимает человек кнопкой, а не наличие процесса. Иначе моды снимались бы сразу после нажатия «Захожу в игру», человек шёл бы в игру на чистых файлах и считал режим нерабочим.
Вопрос «игра запущена?» задаётся глазами конкретного способа. У способов 1-4 список процессов узкий (GTA5, EACLauncher, RageMP, altv), у способа 5 он намеренно шире и включает Launcher Rockstar: подмена делается ещё до старта самой игры. Спроси восстановление узким списком - оно считало бы игру закрытой, пока человек сидит в меню лаунчера, и снимало бы моды через десять секунд после подстановки.
Решение каждого прохода уходит в лог, но с дедупом по тексту: в штатном состоянии оно не меняется, а зовут EnsureConsistent каждые 10 с.
Ремонт Rockstar: сверка размера и метки времени раз в 5 секунд¶
Rockstar Games Launcher живёт рядом с игрой всю сессию и, увидев несовпадение файла с манифестом, заказывает ремонт - качает свой update.rpf поверх наших модов. Ловить это постфактум бесполезно: после ремонта файл в игре новый, образ собран под старую версию, и обе стороны выглядят «здоровыми», хотя модов в игре нет.
При подстановке мы запоминаем отпечаток того, что фактически легло в игру (armed.json, rel → длина-тики). Отпечаток снимаем с файла игры, а не с копии: движки кладут файл по-разному (rename против копирования).
public static bool DetectRepairWhileArmed(string gtaRoot, out string? rel)
{
rel = null;
var armed = ReadArmedStamps(gtaRoot);
if (armed.Count == 0) return false;
foreach (var kv in armed)
{
var now = HotSwapFileOps.Stamp(HotSwapPaths.GamePath(gtaRoot, kv.Key));
if (string.IsNullOrEmpty(now) || string.IsNullOrEmpty(kv.Value)) continue;
if (string.Equals(now, kv.Value, StringComparison.Ordinal)) continue;
rel = kv.Key;
RockstarRepairWatch.Mark(gtaRoot, kv.Key, kv.Value, now,
RockstarRepairWatch.Probe(gtaRoot).Describe());
return true;
}
return false;
}
Агент дёргает эту проверку не чаще раза в 5 с и только пока моды подставлены:
if (wasGame && (DateTime.UtcNow - lastRepairCheck).TotalSeconds > 5)
{
lastRepairCheck = DateTime.UtcNow;
if (GameFileSwapper.DetectRepairWhileArmed(gta, out var brokenRel) && !repairShouted)
{
repairShouted = true;
HotSwapLog.Write("агент", $"ремонт Rockstar прямо во время сессии: {brokenRel} больше не наш. ...");
Heartbeat(gta, "armed", "Rockstar перекачал файлы игры - моды слетели, нужна пересборка образа");
}
}
Почему именно так:
- Отпечаток, а не хеш.
Stamp- это$"{fi.Length}-{fi.LastWriteTimeUtc.Ticks}", дваFileInfoна весь набор. Хешировать двухгигабайтныйupdate.rpfчетыре раза в минуту подряд с игрой нельзя. Подпись содержимого (SHA-256 по трём кускам по 1 МБ - начало, середина, конец) читается только в вопросе «игра обновилась», и только когда дешёвый отпечаток уже разошёлся. - Шаг 5 с, а не шаг цикла. При 250 мс это была бы пара
FileInfoпо гигабайтному файлу четыре раза в секунду, подряд с игрой. - Кричим один раз за сессию (
repairShouted), но статус в heartbeat остаётся с заметкой, а факт ремонта переживает перезагрузку:repair.jsonв корне образа. Первый маркер не перезаписывается - важен момент, когда игру подменили под нами, а не последняя проверка. Снимается маркер только вместе с образом (пересборка или выключение режима).
Пока маркер жив, восстановление ничего не чинит и Disarm ничего не возвращает: файлы игры новее образа, любое «восстановление» откатило бы игру на прошлую версию, лаунчер тут же начал бы ремонт заново, и так по кругу.
Блокировка на свежих .part / .partial / .download / .rgl_tmp¶
Второй способ пострадать от лаунчера - подставить свой файл под руку тому, кто его прямо сейчас переписывает. Итог - файл наполовину наш, наполовину его, то есть полная перекачка игры. Поэтому перед подменой снимается картина окружения: живые процессы лаунчера плюс свежие временные файлы в каталогах файлов набора.
private static readonly string[] PartialSuffixes =
{
".part", ".partial", ".download", ".rgl_tmp",
};
private const string OwnTempSuffix = ".mgswap.tmp";
private const int PartialFreshMinutes = 10;
| Решение | Значение | Почему |
|---|---|---|
| Окно свежести | 10 минут | без окна брошенный год назад .part запрещал бы подмену навсегда |
Голого .tmp в списке нет |
- | это расширение пишет кто угодно (антивирус, распаковщики, наши же инжекторы рядом с update.rpf); один такой файл на 10 минут выдавал бы человеку «идёт ремонт игры» там, где ремонта нет |
Свой хвост .mgswap.tmp отсеивается |
- | иначе агент принимал бы за ремонт собственную копию |
| Обход только верхнего уровня каталогов набора | SearchOption.TopDirectoryOnly |
рекурсия по папке игры - десятки тысяч файлов на каждый arm |
LauncherAlive считает только Launcher и LauncherPatcher |
- | RockstarService висит в системе всегда, признак по нему был бы вечно взведён и потому бесполезен |
Куда это подключено:
ArmприRepairInProgressотказывается и пишет причину. Плюс отдельная строка-предупреждение, если жив самLauncher.exe, а способ не гасит процессы перед возвратом: без неё связь «жив Launcher.exe → через час перекачка 2 ГБ» по логам не восстанавливается.Disarmтоже отказывается - и не трогает журнал. Фаза остаётсяArmed/Disarming, и восстановление доведёт возврат на следующем тике, когда докачка закончится. Моды в игре в это время - штатное, хоть и временное состояние: игра рабочая. Раньше отказ был только уArm, хотя возврат опаснее: он трогает файл после сессии, когда лаунчер как раз просыпается на проверку.Unfreezeотказывается с текстом в интерфейс: выключение режима - действие человека, ему важнее узнать причину, чем получить отложенную операцию.
Процессы лаунчера мы при этом не гасим. Это делает только способ 5 и только перед возвратом файлов - осознанное поведение клона ReplaceX: пока SocialClubHelper/RockstarService живы, они держат update.rpf открытым, и возврат упирается в «файл занят». После гашения ждём 1000 мс на закрытие хендлов.
hotswap.log и ротация на 5 МБ¶
Единственный инструмент разбора «способ 1 у тестера не сработал». В файл пишут оба процесса - лаунчер (мост, ручные кнопки, включение режима) и агент, поэтому каждая строка подписана источником:
Источник - лаунчер или агент (HotSwapLog.Origin), область - агент, journal, recovery, arm, disarm, freeze, unfreeze, rockstar, baseline, watcher, мост.
Файл лежит в %LocalAppData%\MiamiGraphics\logs\hotswap.log (см. AppData layout).
private const long MaxBytes = 5L * 1024 * 1024;
private static void RotateIfNeeded()
{
try
{
var fi = new FileInfo(LogPath);
if (!fi.Exists || fi.Length <= MaxBytes) return;
try { if (File.Exists(PrevLogPath)) File.Delete(PrevLogPath); } catch { }
File.Move(LogPath, PrevLogPath);
}
catch { }
}
Механика записи:
- Межпроцессная синхронизация - именованный мьютекс
Local\MiamiGraphicsHotSwapLog(per-session, поэтому повышенный агент и обычный лаунчер одной сессии видят один замок).AbandonedMutexExceptionтрактуется как «замок наш»: прошлый владелец умер, писать можно. Не дали создать именованный - падаем на локальный, потокобезопасность внутри процесса остаётся. - Файл открывается на каждую запись,
FileMode.Append+FileShare.ReadWrite: строк мало (события, не трейс), зато ни один процесс не держит хендл постоянно и ротация не блокируется. - Ротация под мьютексом: при превышении 5 МБ текущий файл уезжает в
hotswap.prev.log, старыйprevудаляется. История одного «до и после» всегда под рукой. Не удалось переименовать - пишем дальше в тот же файл, попробуем на следующей записи. - UTF-8 без BOM: append дописывает в конец существующего файла, и маркер кодировки оказался бы внутри текста.
- Логгер никогда не бросает. Любая его ошибка глотается: лог не имеет права уронить подмену файлов игры.
Панель в настройках читает хвост - по умолчанию 64 КБ, под живыми писателями (FileShare.ReadWrite | FileShare.Delete), с отбрасыванием оборванной первой строки.
Отдельная тема - дедупы. Цикл крутится до четырёх раз в секунду, значит писать можно только переходы. Что дедуплицируется:
| Что | Условие повторной записи |
|---|---|
| «режим выключен», «режим включён» | только смена состояния |
| строка плана способа | только смена способа |
| решение восстановления | только смена текста решения |
| ошибка цикла | смена текста или раз в 60 с |
| ошибка heartbeat | смена текста |
отказ arm |
смена текста (плюс пауза 10 с между попытками) |
отказ disarm при зафиксированном ремонте |
смена файла-виновника |
вердикт baseline (touch / locked) |
смена сигнатуры вердикта |
Без этого настоящая диагностика тонет: пять мегабайт набираются одинаковыми строками за минуты, а нужны там ровно те несколько строк, где arm не удался или Rockstar переписал файл под нами.