Перейти к содержанию

Фоновый агент и журнал фаз

Режим Rockstar держит модифицированные файлы игры вне папки GTA (в «образе») и подставляет их обратно только на время игровой сессии - Rockstar Games Launcher сканирует свою папку и чинит всё, что не сошлось с манифестом. Значит, кто-то должен заметить старт игры за миллисекунды до того, как она откроет update.rpf, и вернуть чистые файлы, когда игра закрылась. Этим занимается фоновый агент.

Агент - не отдельная программа, а режим самого лаунчера: Miami Graphics.exe --hotswap-agent. Он живёт вне окна лаунчера, переживает его закрытие и перезагрузку машины, и потому обязан быть устойчив к собственной внезапной смерти: подмена гигабайтных файлов, прерванная на середине, оставляет игрока без игры. Отсюда журнал фаз, который пишется до каждой файловой операции, и восстановление, которое читает журнал на каждом старте.

Здесь - устройство цикла агента: чем он будится, что и куда пишет, как чинит прерванную операцию, как ловит ремонт со стороны Rockstar Games Launcher и что остаётся в логе после всего этого.

Агент - это режим лаунчера, а не отдельный exe

Задача Планировщика создаётся только при включении режима и снимается при выключении: выключенный режим не оставляет в системе ничего работающего.

MiamiGraphics.Shell/Bridge/AppBridge.cs
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: задача при входе в систему и немедленный запуск из моста легко дают два процесса, второй обязан молча выйти.

MiamiGraphics.Shell/App.xaml.cs
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» и «жива ли игра»:

MiamiGraphics.Core/HotSwap/HotSwapAgentLoop.cs
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: событие приходит за миллисекунды и не стоит ничего, пока ничего не происходит.

MiamiGraphics.Core/HotSwap/ProcessStartNotifier.cs
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.

MiamiGraphics.Core/HotSwap/HotSwapAgentLoop.cs
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 проворачивается за полчаса игры и уносит с собой ровно те строки, ради которых он заведён.

MiamiGraphics.Core/HotSwap/HotSwapAgentLoop.cs
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).

MiamiGraphics.Core/HotSwap/HotSwapAgentLoop.cs
if ((DateTime.UtcNow - lastBeat).TotalSeconds > 10)
{
    HotSwapRecovery.EnsureConsistent(gta, out _);
    Heartbeat(gta, plan.Trigger == HotSwapTrigger.Manual ? "manual"
                   : wasGame ? "armed" : "watching");
    lastBeat = DateTime.UtcNow;
}
MiamiGraphics.Core/HotSwap/HotSwapAgentLoop.cs
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 в корне образа) пишется до каждой файловой операции. После краха или выключения питания по нему однозначно видно, что где лежит.

MiamiGraphics.Core/HotSwap/HotSwapJournal.cs
public enum HotSwapPhase
{
    Idle = 0,        // игровой файл чистый, моды в образе - штатное состояние
    Arming = 1,      // идёт подмена
    Armed = 2,       // моды подставлены в игру, игра запущена
    Disarming = 3,   // идёт возврат
    Freezing = 4,    // идёт первичная заморозка
}

Запись атомарна тем же приёмом, что heartbeat, и каждая смена уходит в лог парой «была → стала» - по логу восстанавливается вся хронология свапа:

MiamiGraphics.Core/HotSwap/HotSwapJournal.cs
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 счёл бы их немодифицированными - моды потерялись бы молча.

MiamiGraphics.Core/HotSwap/HotSwapRecovery.cs
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 против копирования).

MiamiGraphics.Core/HotSwap/GameFileSwapper.cs
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 с и только пока моды подставлены:

MiamiGraphics.Core/HotSwap/HotSwapAgentLoop.cs
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

Второй способ пострадать от лаунчера - подставить свой файл под руку тому, кто его прямо сейчас переписывает. Итог - файл наполовину наш, наполовину его, то есть полная перекачка игры. Поэтому перед подменой снимается картина окружения: живые процессы лаунчера плюс свежие временные файлы в каталогах файлов набора.

MiamiGraphics.Core/HotSwap/RockstarRepairWatch.cs
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 у тестера не сработал». В файл пишут оба процесса - лаунчер (мост, ручные кнопки, включение режима) и агент, поэтому каждая строка подписана источником:

yyyy-MM-dd HH:mm:ss.fff [источник:область] сообщение

Источник - лаунчер или агент (HotSwapLog.Origin), область - агент, journal, recovery, arm, disarm, freeze, unfreeze, rockstar, baseline, watcher, мост.

Файл лежит в %LocalAppData%\MiamiGraphics\logs\hotswap.log (см. AppData layout).

MiamiGraphics.Core/HotSwap/HotSwapLog.cs
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 переписал файл под нами.