Redux - каталог модов¶
Это главная секция админ-панели. Тут заводятся все redux-моды (полные графические сборки), которые юзеры видят в Browse → Redux каталоге.

Что такое redux¶
Redux в нашей терминологии - это полная графическая сборка GTA V: timecycle, ENB, shaders, weather, lighting. Юзер ставит redux одной кнопкой, получает целостный визуальный пакет. В отличие от gunpack'ов (только оружие) или armor pack'ов (только броня), redux трогает много файлов в update.rpf и dlcpacks.
Каждый redux в БД - это redux_items row + одна или несколько redux_versions. Версионирование нужно потому что мод эволюционирует: автор выпускает v1, потом v2 с фиксами, потом v3 с новой ENB. Юзер может выбрать какую ставить.
Структура таблиц¶
erDiagram
redux_items ||--o{ redux_versions : "has many"
redux_items ||--o{ redux_featured_picks : "may be featured"
redux_items {
uuid id PK
text name
text author
text description
text preview_url
text glb_url
int popularity
bool is_deleted
timestamptz created_at
}
redux_versions {
uuid id PK
uuid redux_id FK
text version_label
text patch_url
text sha256
int size_bytes
jsonb component_urls
timestamptz created_at
}
component_urls - JSON map типа {"minimap": "https://cdn.../minimap.bin", "tracers": "https://cdn.../tracers.bin"}. Используется при customize-импорте - юзер берёт отдельные компоненты с разных redux'ов.
Workflow «загрузить новый redux»¶
Самый частый сценарий для админа. Состоит из 4 шагов:
Шаг 1. Analyze¶
Админ кидает в UI 2 файла: чистый update.rpf (baseline) и модифицированный update.rpf (мод). UI вызывает adminReduxAnalyze:
public async Task<ReduxAnalysisDto> AdminReduxAnalyzeAsync(string cleanRpfPath, string modRpfPath, string modName)
{
var pipeline = new ReduxParserPipeline(_logger);
var analysis = await pipeline.AnalyzeAsync(cleanRpfPath, modRpfPath, modName);
return analysis;
}
ReduxParserPipeline.AnalyzeAsync:
- Открывает оба
update.rpfчерез RageLib.GTA5. - Рекурсивно обходит файлы, считает SHA-256 каждого.
- Diff:
actions = []- что Replace, что Import, что Delete. - Для каждого actionа определяет «компонент» через
ComponentScanner(minimap, tracers, hud_reticle, и так далее). - Опционально рендерит GLB-превью если в моде есть armor
.ydr- через headless renderer. - Возвращает
ReduxAnalysisDto:
public sealed record ReduxAnalysisDto(
List<PatchAction> Actions, // что менять
List<DetectedComponent> Components, // что extractable
string? GlbDataUri, // 3D preview если есть
long EstimatedPatchSize);
UI показывает превью: «27 файлов изменено, 3 удалено, 2 новых. Извлекаем: minimap, hud_reticle. Размер patch.zip - 218 МБ».
Шаг 2. Заполнение метаданных¶

Админ вводит:
- Name - например «Hunter Reborn 4.0».
- Author - например «Hunter».
- Description - markdown в textarea.
- Preview image - drop PNG, она зальётся в R2.
- Tags - visual style, time of day, season.
const form = useForm<ReduxItemDraft>({
defaultValues: {
name: '', author: '', description: '',
previewFile: null, tags: [],
},
});
Шаг 3. Upload в R2 + insert в Supabase¶

adminQueueAdd:
public async Task AdminQueueAddAsync(ReduxUploadItemDto item)
{
// 1. Собираем patch.zip из analysis result
var patchZipPath = await _patchZipBuilder.BuildAsync(item.Analysis);
// 2. Считаем SHA patch'а
var sha = await Sha256OfFileAsync(patchZipPath);
// 3. Upload в R2 - patch, preview, glb, per-component
await _r2.UploadAsync($"redux/{item.Id}/v1/patch.zip", patchZipPath);
await _r2.UploadAsync($"redux/{item.Id}/preview.png", item.PreviewBytes);
if (item.GlbBytes is not null)
await _r2.UploadAsync($"redux/{item.Id}/preview.glb", item.GlbBytes);
foreach (var comp in item.Analysis.Components)
await _r2.UploadAsync(
$"redux/{item.Id}/v1/components/{comp.Type}.bin",
comp.ExtractedBytes);
// 4. Insert в Supabase
await _supa.InsertReduxItemAsync(item);
await _supa.InsertReduxVersionAsync(...);
}
R2-пути жестко детерминированы: redux/<reduxId>/v<n>/patch.zip, redux/<reduxId>/v<n>/components/<type>.bin. Это позволяет donor-cache знать заранее URL для конкретной версии без лишних DB-запросов.
Шаг 4. Заявка в очереди¶
Если у админа много модов на залив (распространённый сценарий - раз в месяц приходит 5-10 новых), он добавляет их в очередь:
adminQueueList- get all pending uploads.adminQueueRun- start sequential upload.adminQueueCancel- abort current.
Очередь хранится в памяти (не persisted между запусками лаунчера). Если админ закрыл лаунчер - pending заявки потеряны. Это intentionally - потому что upload-state включает MemoryStream'ы с тяжёлыми payload'ами, persist на диск дорог.
Добавить версию к существующему редуксу¶
Открой редактирование редукса в Database, найди карточку «Версии редукса (X/5)», справа кнопка +. Клик ведёт на форму «Добавление версии VN», где:
- Выбираешь новый
update.rpf. - Парсер читает его в фоне.
- Вводишь короткий label (по умолчанию
VN, можно «Grey», «Blue», «Beta»). - «В очередь».
Очередь обрабатывает запись как multi-version upload, но с флагом appendToReduxId. В этом режиме:
- Не purge'им префикс
redux/{id}/на R2 целиком, только конкретный слотredux/{id}/v{slot}/. - Не делаем
_catalog.AddAsync(snapshot)(row уже существует). - Вместо этого читаем существующие
redux_versionsиз БД, мерджимcomponentsнового слота с union'ом старых, делаем_catalog.UpdateAsync(reduxId, r => r.Components = mergedComponents). _versions.UpsertAsyncпишет новыйredux_versionsrow.
Этот путь снимает необходимость каждый раз перезаливать весь редукс ради добавления одной версии под новый патч GTA.
Лимит 5 версий жёсткий - кнопка + дизейблится когда versions.length === 5. Если нужна 6-я, удали ненужную в той же карточке.
Сменить проект без перезаливки¶
В Editor редукса есть pill-селектор «Проект» с тремя вариантами: Majestic, 5RP, Both. Меняет supported_servers в redux_items. Save шлёт adminCatalogUpdate, тот вызывает _catalog.UpdateAsync который делает upsert в Supabase. Не трогает R2, не пересчитывает patch.
Автозеркалирование картинок в R2¶
При сохранении редукса (кнопка «Сохранить» в Editor) UI проходит по полям previewUrl, galleryUrls[], componentScreenshots{}. Если URL не на нашем R2 (*.miamigraphicsstorage.uk, *.r2.dev, *.r2.cloudflarestorage.com), вызывается bridge.adminMirrorImageToR2(reduxId, url, slot):
public async Task<string> AdminMirrorImageToR2Async(string reduxId, string externalUrl, string slot)
{
var uri = new Uri(externalUrl);
if (uri.Host.EndsWith("miamigraphicsstorage.uk") || ...) return externalUrl;
using var http = Services.HttpClientFactory.CreateFragmenting(TimeSpan.FromMinutes(2));
using var resp = await http.GetAsync(uri, ...);
var bytes = await resp.Content.ReadAsByteArrayAsync();
var ext = ResolveExt(resp, uri);
var temp = Path.Combine(Path.GetTempPath(), $"mg_mirror_{Guid.NewGuid():N}{ext}");
await File.WriteAllBytesAsync(temp, bytes);
var url = await _remoteStorage.UploadHashedAsync(temp, $"redux/{reduxId}/mirror/{slot}{ext}", null, ct);
File.Delete(temp);
return url;
}
UploadHashedAsync пишет в R2 как redux/{id}/mirror/{slot}-{sha1}.{ext} с Cache-Control: immutable. После того как URL обновился в redux_items, юзеры качают картинку через WebView2 bypass interceptor, который перехватывает запросы к нашему R2 и шлёт их через FragmentingHttpHandler. В РФ работает.
Если зеркалирование упало (хост недоступен, 404, таймаут), оригинальный URL остаётся как был. Save проходит, в БД пишется внешний URL, картинка просто не загрузится у РФ юзеров с DPI.
Версионирование¶
«Hunter Reborn» может выйти в v1, v1.5, v2. Все три версии хранятся в redux_versions и доступны юзеру через dropdown в UI. По дефолту показываем последнюю.
public async Task<JsonElement> AdminVersionUpsertAsync(JsonElement payload)
{
var reduxId = payload.GetProperty("reduxId").GetString()!;
var versionLabel = payload.GetProperty("versionLabel").GetString()!;
var patchPath = payload.GetProperty("patchPath").GetString()!;
// upload в R2 в новый под-путь
var url = await _r2.UploadAsync($"redux/{reduxId}/v{versionLabel}/patch.zip", patchPath);
// insert новую version row
return await _supa.InsertReduxVersionAsync(reduxId, versionLabel, url);
}
adminVersionDelete - softdelete. Сама row помечается is_deleted = true, но R2-файлы не удаляются (на случай юзеров которые уже скачали и ссылаются на эту версию).
Featured picks¶
В каталоге сверху есть «Топ редаксы» секция - redux_featured_picks table. Админ через adminFeaturedPickSet ставит до 6 модов в spotlight (с display_order). adminFeaturedPickDelete убирает из топа.
Recalculate patch sizes¶
Иногда после миграции R2 или ручного fixup размеры в БД отстают от реальных. Кнопка «Пересчитать размеры» вызывает adminRecalculateReduxPatchSizes - HEAD-request на каждый patch_url, обновляет size_bytes.
public async Task<int> AdminRecalculateReduxPatchSizesAsync()
{
var versions = await _supa.GetAllReduxVersionsAsync();
int updated = 0;
foreach (var v in versions)
{
var resp = await _http.SendAsync(new HttpRequestMessage(HttpMethod.Head, v.PatchUrl));
var size = resp.Content.Headers.ContentLength ?? 0;
if (size != v.SizeBytes)
{
await _supa.UpdateReduxVersionSizeAsync(v.Id, size);
updated++;
}
}
return updated;
}
Rebuild components¶
adminRebuildReduxComponents - re-parse всех existing redux'ов и пересоздать component_urls map'ы. Используется когда мы добавили новый тип компонента (например начали извлекать armor отдельно) и хотим заставить старые redux'ы тоже его иметь.
Pipeline:
- Скачиваем
patch.zipиз R2. - Запускаем
ReduxParserPipeline.AnalyzeFromPatchZipAsync. - Re-upload компонентов под новой схемой.
- Update Supabase row.
Это тяжёлая операция (1-2 часа на полный каталог, ~200 модов). Запускается редко, только при schema migration.