diff --git a/__tests__/lib/runtime-fallback.test.ts b/__tests__/lib/runtime-fallback.test.ts new file mode 100644 index 000000000..cccbd07f9 --- /dev/null +++ b/__tests__/lib/runtime-fallback.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from 'vitest'; + +import { + planRuntimeFallback, + buildRunReport, + summarizeRunReport, + runtimePlanMessageKey, + type RuntimeContext, +} from '@/lib/translation/runtime-fallback'; + +/** Contesto "tutto a posto": il caso base è il runtime percorribile. */ +function ctx(over: Partial = {}): RuntimeContext { + return { + staticOutcome: 'failure', + isWindows: true, + hookAvailable: true, + processName: 'Game.exe', + processRunning: true, + ...over, + }; +} + +describe('planRuntimeFallback', () => { + it('non fa niente se la strada statica è riuscita', () => { + expect(planRuntimeFallback(ctx({ staticOutcome: 'success' }))).toEqual({ action: 'none' }); + }); + + it('non fa niente su un successo parziale', () => { + // Il gioco è stato modificato: sovrapporre il runtime mostrerebbe due + // traduzioni della stessa riga. + expect(planRuntimeFallback(ctx({ staticOutcome: 'partial' }))).toEqual({ action: 'none' }); + }); + + it('inietta quando il gioco è in esecuzione', () => { + expect(planRuntimeFallback(ctx())).toEqual({ action: 'inject', processName: 'Game.exe' }); + }); + + it('chiede di avviare il gioco se è chiuso', () => { + expect(planRuntimeFallback(ctx({ processRunning: false }))).toEqual({ + action: 'await-launch', + processName: 'Game.exe', + }); + }); + + it('fuori da Windows non propone niente', () => { + expect(planRuntimeFallback(ctx({ isWindows: false }))).toEqual({ + action: 'unavailable', + blocker: 'not-windows', + }); + }); + + it('senza le DLL gs-hook non propone niente', () => { + expect(planRuntimeFallback(ctx({ hookAvailable: false }))).toEqual({ + action: 'unavailable', + blocker: 'hook-missing', + }); + }); + + it('rispetta il gate anti-cheat', () => { + expect(planRuntimeFallback(ctx({ antiCheatBlocked: true }))).toEqual({ + action: 'unavailable', + blocker: 'anti-cheat', + }); + }); + + it('senza nome del processo non sa cosa iniettare', () => { + expect(planRuntimeFallback(ctx({ processName: null }))).toEqual({ + action: 'unavailable', + blocker: 'unknown-process', + }); + }); + + it('i motivi strutturali battono il gioco chiuso', () => { + // Dire "avvia il gioco" quando l'anti-cheat vieta comunque l'iniezione + // manda l'utente a sbattere: si dice "qui non si può" e basta. + expect( + planRuntimeFallback(ctx({ processRunning: false, antiCheatBlocked: true })), + ).toEqual({ action: 'unavailable', blocker: 'anti-cheat' }); + }); + + it('la piattaforma batte ogni altro blocco', () => { + expect( + planRuntimeFallback( + ctx({ isWindows: false, hookAvailable: false, processName: null }), + ), + ).toEqual({ action: 'unavailable', blocker: 'not-windows' }); + }); +}); + +describe('runtimePlanMessageKey', () => { + it('non dà messaggio quando non c\'è niente da dire', () => { + expect(runtimePlanMessageKey({ action: 'none' })).toBe(''); + }); + + it('distingue il blocco per motivo', () => { + expect(runtimePlanMessageKey({ action: 'unavailable', blocker: 'anti-cheat' })).toBe( + 'gameDetail.runtimeFallbackBlocked.anti-cheat', + ); + expect(runtimePlanMessageKey({ action: 'unavailable', blocker: 'hook-missing' })).toBe( + 'gameDetail.runtimeFallbackBlocked.hook-missing', + ); + }); +}); + +describe('buildRunReport', () => { + it('registra runtime fra le strade tentate solo se si inietta davvero', () => { + const injected = buildRunReport({ + gameTitle: 'Gioco', + staticOutcome: 'failure', + plan: { action: 'inject', processName: 'Game.exe' }, + }); + expect(injected.attempted).toEqual(['static', 'runtime']); + + const waiting = buildRunReport({ + gameTitle: 'Gioco', + staticOutcome: 'failure', + plan: { action: 'await-launch', processName: 'Game.exe' }, + }); + expect(waiting.attempted).toEqual(['static']); + }); + + it('tiene "non misurato" distinto da zero', () => { + const unmeasured = buildRunReport({ + gameTitle: 'Gioco', + staticOutcome: 'failure', + plan: { action: 'none' }, + }); + expect(unmeasured.stringsInjected).toBeNull(); + expect(unmeasured.stringsTotal).toBeNull(); + + const zero = buildRunReport({ + gameTitle: 'Gioco', + staticOutcome: 'failure', + stringsInjected: 0, + stringsTotal: 1200, + plan: { action: 'none' }, + }); + expect(zero.stringsInjected).toBe(0); + }); + + it('porta la chiave del passo successivo', () => { + const r = buildRunReport({ + gameTitle: 'Gioco', + staticOutcome: 'failure', + plan: { action: 'await-launch', processName: 'Game.exe' }, + }); + expect(r.nextStepKey).toBe('gameDetail.runtimeFallbackAwaitLaunch'); + }); +}); + +describe('summarizeRunReport', () => { + it('dice le stringhe scritte, non gli stadi completati', () => { + const r = buildRunReport({ + gameTitle: 'Gioco', + staticOutcome: 'failure', + stringsInjected: 0, + stringsTotal: 1679, + plan: { action: 'inject', processName: 'Game.exe' }, + }); + expect(summarizeRunReport(r)).toBe('0/1679 stringhe scritte — passato alla traduzione a runtime'); + }); + + it('non finge conteggi che non ha', () => { + const r = buildRunReport({ + gameTitle: 'Gioco', + staticOutcome: 'failure', + plan: { action: 'unavailable', blocker: 'not-windows' }, + }); + expect(summarizeRunReport(r)).toBe('conteggi non disponibili — runtime non disponibile (not-windows)'); + }); +}); diff --git a/__tests__/lib/translation-bridge-drain.test.ts b/__tests__/lib/translation-bridge-drain.test.ts new file mode 100644 index 000000000..18aa6f570 --- /dev/null +++ b/__tests__/lib/translation-bridge-drain.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { + drainOnce, + TranslationBridgeDrain, + type DrainDeps, + type DrainStats, +} from '@/lib/translation-bridge-drain'; + +vi.mock('@/lib/client-logger', () => ({ + clientLogger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() }, +})); + +function freshStats(): DrainStats { + return { learned: 0, idleRounds: 0, failed: 0, budgetExhausted: false }; +} + +const OPTS = { + intervalMs: 10, + batchSize: 50, + maxTranslationsPerSession: 2000, + saveEvery: 25, +}; + +/** Deps finte: coda predefinita, traduzione che prefissa "IT:". */ +function makeDeps(queue: string[][], overrides: Partial = {}): DrainDeps & { + added: Array<[string, string]>; + saves: number; +} { + const added: Array<[string, string]> = []; + let saves = 0; + const deps = { + drainMisses: vi.fn(async () => queue.shift() ?? []), + translate: vi.fn(async (texts: string[]) => ({ + translations: texts.map((t) => `IT:${t}`), + success: true, + })), + addTranslation: vi.fn(async (o: string, t: string) => { + added.push([o, t]); + return true; + }), + save: vi.fn(async () => { + saves++; + }), + ...overrides, + }; + return Object.defineProperties(deps as never, { + added: { get: () => added }, + saves: { get: () => saves }, + }) as DrainDeps & { added: Array<[string, string]>; saves: number }; +} + +describe('drainOnce', () => { + it('traduce i miss e li reinserisce nel dizionario', async () => { + const deps = makeDeps([['New Game', 'Continue']]); + const stats = freshStats(); + + const learned = await drainOnce(deps, stats, OPTS); + + expect(learned).toBe(2); + expect(stats.learned).toBe(2); + expect(deps.added).toEqual([ + ['New Game', 'IT:New Game'], + ['Continue', 'IT:Continue'], + ]); + }); + + it('conta un giro a vuoto quando la coda è vuota, senza chiamare il provider', async () => { + const deps = makeDeps([[]]); + const stats = freshStats(); + + await drainOnce(deps, stats, OPTS); + + expect(stats.idleRounds).toBe(1); + expect(deps.translate).not.toHaveBeenCalled(); + }); + + it('non inserisce una traduzione identica all\'originale', async () => { + // Un provider che "traduce" restituendo l'input trasformerebbe il miss in + // un hit sbagliato e permanente: la stringa non verrebbe mai più ritentata. + const deps = makeDeps([['Continue']], { + translate: async (texts: string[]) => ({ translations: [...texts], success: true }), + }); + const stats = freshStats(); + + await drainOnce(deps, stats, OPTS); + + expect(deps.added).toEqual([]); + expect(stats.learned).toBe(0); + expect(stats.failed).toBe(1); + }); + + it('non inserisce una traduzione vuota', async () => { + const deps = makeDeps([['Save']], { + translate: async () => ({ translations: [''], success: true }), + }); + const stats = freshStats(); + + await drainOnce(deps, stats, OPTS); + + expect(deps.added).toEqual([]); + expect(stats.failed).toBe(1); + }); + + it('conta come fallite tutte le stringhe se il provider fallisce', async () => { + const deps = makeDeps([['a', 'b', 'c']], { + translate: async () => ({ translations: [], success: false }), + }); + const stats = freshStats(); + + await drainOnce(deps, stats, OPTS); + + expect(stats.failed).toBe(3); + expect(stats.learned).toBe(0); + expect(deps.added).toEqual([]); + }); + + it('tronca il batch per non sforare il tetto di sessione', async () => { + const deps = makeDeps([['a', 'b', 'c', 'd', 'e']]); + const stats = freshStats(); + + await drainOnce(deps, stats, { ...OPTS, maxTranslationsPerSession: 3 }); + + expect(stats.learned).toBe(3); + expect(deps.added.map(([o]) => o)).toEqual(['a', 'b', 'c']); + expect(stats.budgetExhausted).toBe(true); + }); + + it('a budget esaurito non chiama nemmeno drainMisses', async () => { + const deps = makeDeps([['a']]); + const stats = { ...freshStats(), learned: 10 }; + + const learned = await drainOnce(deps, stats, { ...OPTS, maxTranslationsPerSession: 10 }); + + expect(learned).toBe(0); + expect(stats.budgetExhausted).toBe(true); + expect(deps.drainMisses).not.toHaveBeenCalled(); + }); + + it('salva una sola volta quando si supera la soglia di saveEvery', async () => { + const deps = makeDeps([['a', 'b', 'c']]); + const stats = freshStats(); + + await drainOnce(deps, stats, { ...OPTS, saveEvery: 2 }); + + // 0 -> 3 attraversa la soglia di 2 una volta sola: un solo salvataggio. + expect(deps.saves).toBe(1); + }); + + it('non salva se non ha imparato niente', async () => { + const deps = makeDeps([['a']], { + translate: async () => ({ translations: [''], success: true }), + }); + const stats = freshStats(); + + await drainOnce(deps, stats, { ...OPTS, saveEvery: 1 }); + + expect(deps.saves).toBe(0); + }); + + it('un salvataggio fallito non fa perdere le traduzioni già imparate', async () => { + const deps = makeDeps([['a', 'b']], { + save: async () => { + throw new Error('disco pieno'); + }, + }); + const stats = freshStats(); + + await expect(drainOnce(deps, stats, { ...OPTS, saveEvery: 1 })).resolves.toBe(2); + expect(stats.learned).toBe(2); + }); +}); + +describe('TranslationBridgeDrain', () => { + it('gira a intervalli finché non viene fermato', async () => { + vi.useFakeTimers(); + try { + const deps = makeDeps([['a'], ['b'], ['c']]); + const loop = new TranslationBridgeDrain(deps, { intervalMs: 100, saveEvery: 0 }); + + loop.start(); + expect(loop.isRunning).toBe(true); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(100); + await vi.advanceTimersByTimeAsync(100); + + expect(loop.stats.learned).toBe(3); + + loop.stop(); + expect(loop.isRunning).toBe(false); + const after = loop.stats.learned; + await vi.advanceTimersByTimeAsync(500); + expect(loop.stats.learned).toBe(after); + } finally { + vi.useRealTimers(); + } + }); + + it('si ferma da solo quando esaurisce il budget', async () => { + vi.useFakeTimers(); + try { + const deps = makeDeps([['a', 'b'], ['c']]); + const loop = new TranslationBridgeDrain(deps, { + intervalMs: 100, + maxTranslationsPerSession: 2, + saveEvery: 0, + }); + + loop.start(); + await vi.advanceTimersByTimeAsync(0); + + expect(loop.stats.budgetExhausted).toBe(true); + expect(loop.isRunning).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('un giro che esplode non ferma il loop', async () => { + vi.useFakeTimers(); + try { + let call = 0; + const deps = makeDeps([['a']], { + drainMisses: async () => { + call++; + if (call === 1) throw new Error('IPC giù'); + return call === 2 ? ['b'] : []; + }, + }); + const loop = new TranslationBridgeDrain(deps, { intervalMs: 100, saveEvery: 0 }); + + loop.start(); + await vi.advanceTimersByTimeAsync(0); + expect(loop.stats.learned).toBe(0); + + await vi.advanceTimersByTimeAsync(100); + expect(loop.stats.learned).toBe(1); + expect(loop.isRunning).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('start() due volte non raddoppia i giri', async () => { + vi.useFakeTimers(); + try { + const deps = makeDeps([['a'], ['b']]); + const loop = new TranslationBridgeDrain(deps, { intervalMs: 100, saveEvery: 0 }); + + loop.start(); + loop.start(); + await vi.advanceTimersByTimeAsync(0); + + expect(deps.drainMisses).toHaveBeenCalledTimes(1); + loop.stop(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/app/gs-overlay/page.tsx b/app/gs-overlay/page.tsx index d4ab12fe3..dbcb241d1 100644 --- a/app/gs-overlay/page.tsx +++ b/app/gs-overlay/page.tsx @@ -3,6 +3,8 @@ import { useState, useEffect, useRef } from 'react'; import { listen } from '@tauri-apps/api/event'; import { translateWithFallback } from '@/lib/ai/ai-translate-direct'; +import { translationBridge } from '@/lib/translation-bridge'; +import { useTranslationBridgeDrain } from '@/hooks/use-translation-bridge-drain'; // Riga estratta inoltrata dalla DLL via overlay_ipc (evento "gs-overlay-text"). interface OverlayText { @@ -23,6 +25,11 @@ export default function GsOverlayPage() { const hideTimer = useRef | null>(null); const reqId = useRef(0); // guardia contro risposte di traduzione stantie + // Drena i cache miss che la DLL chiede via pipe e non trova nel dizionario: + // li traduce e li reinserisce, così dal secondo avvistamento sono hit. Gira + // finché l'overlay è aperto, cioè finché la traduzione in tempo reale è attiva. + useTranslationBridgeDrain({ enabled: true, targetLanguage: 'it' }); + useEffect(() => { const unlisten = listen('gs-overlay-text', async (event) => { const msg = event.payload; @@ -55,6 +62,13 @@ export default function GsOverlayPage() { if (myReq !== reqId.current) return; // arrivata una riga più recente const t = res.success && res.translations[0] ? res.translations[0] : msg.original; setLine({ original: msg.original, translated: t, pending: false }); + + // Restituisci la traduzione al dizionario del bridge: senza questo + // ritradurremmo la stessa riga a ogni sua comparsa, e la DLL non + // imparerebbe mai. Dal prossimo avvistamento è un hit via IPC. + if (t !== msg.original) { + void translationBridge.addTranslation(msg.original, t); + } } catch { if (myReq !== reqId.current) return; setLine({ original: msg.original, translated: msg.original, pending: false }); diff --git a/components/game-detail-client.tsx b/components/game-detail-client.tsx index 527855308..8c50d535a 100644 --- a/components/game-detail-client.tsx +++ b/components/game-detail-client.tsx @@ -46,7 +46,13 @@ import { classifyCompatError, maybeOfferCompatOptIn, type CompatGameRef, } from '@/lib/compat-telemetry'; import { reportCrash } from '@/lib/crash-reporter'; -import { decidePatchOutcome } from '@/lib/translation/patch-outcome'; +import { decidePatchOutcome, type PatchOutcome } from '@/lib/translation/patch-outcome'; +import { + planRuntimeFallback, + buildRunReport, + summarizeRunReport, + type RunReport, +} from '@/lib/translation/runtime-fallback'; import { TARGET_LANGUAGES as CANONICAL_TARGET_LANGUAGES } from '@/lib/translation/target-languages'; import { LANG_TO_CODE } from '@/lib/translation/language-mappings'; import { useWarmIndex } from '@/hooks/use-warm-index'; @@ -2047,6 +2053,98 @@ export default function GameDetailPage() { } }; + // ═══ FALLBACK A RUNTIME ═══ + // Quando la strada statica non scrive nulla nel gioco, il gioco non è + // intraducibile: c'è gs-hook + la pipe GameStringerTranslator. La REGOLA su + // cosa fare sta in lib/translation/runtime-fallback.ts (con i suoi test); + // qui si raccolgono i fatti, si chiama la regola e si esegue il piano. + const tryRuntimeFallback = async ( + staticOutcome: PatchOutcome, + counts: { injected?: number | null; total?: number | null }, + ): Promise => { + if (!game?.installPath) return null; + + // Nome dell'eseguibile: stessa scala di ripieghi del percorso di patch. + let exeName: string | undefined = game.detectedFiles?.find((f: string) => f.endsWith('.exe')); + if (!exeName) { + try { + const exeList = await invoke('find_executables_in_folder', { + folderPath: game.installPath, + }); + if (exeList?.length) exeName = exeList[0]; + } catch { /* resta undefined → blocker 'unknown-process' */ } + } + + let hookAvailable = false; + let processRunning = false; + try { + const status = await invoke<{ available: boolean; process_running: boolean }>( + 'gs_hook_status', + { processName: exeName ?? null }, + ); + hookAvailable = status.available; + processRunning = status.process_running; + } catch (e: unknown) { + clientLogger.warn('[RuntimeFallback] gs_hook_status fallito:', String(e)); + } + + const plan = planRuntimeFallback({ + staticOutcome, + // `available` è già false fuori da Windows (vedi lo stub): un solo fatto + // da guardare invece di due che possono contraddirsi. + isWindows: hookAvailable || processRunning, + hookAvailable, + processName: exeName ?? null, + processRunning, + }); + + const report = buildRunReport({ + gameTitle: game.title || game.name || '', + engine: game.engine || engineInfo?.engine || null, + staticOutcome, + stringsInjected: counts.injected ?? null, + stringsTotal: counts.total ?? null, + plan, + }); + + // Il report per gioco finisce nella cronologia: dice cosa è entrato nel + // gioco e quale strada ci ha provato, non quanti stadi hanno detto verde. + void activityHistory.trackPatch( + game.name || game.title || '', + game.appid?.toString(), + summarizeRunReport(report), + ); + + if (plan.action === 'none') return report; + + if (plan.action === 'unavailable' || plan.action === 'await-launch') { + toast.info(t(report.nextStepKey)); + return report; + } + + // plan.action === 'inject' + toast.loading(t(report.nextStepKey), { id: 'gs-runtime-fallback' }); + try { + const res = await invoke<{ success: boolean; message: string }>('inject_gs_hook', { + processName: plan.processName, + }); + if (res.success) { + toast.success(t('gameDetail.runtimeFallbackReady'), { id: 'gs-runtime-fallback' }); + } else { + toast.error(t('gameDetail.runtimeFallbackFailed'), { + id: 'gs-runtime-fallback', + description: res.message.slice(0, 180), + }); + } + } catch (e: unknown) { + toast.error(t('gameDetail.runtimeFallbackFailed'), { + id: 'gs-runtime-fallback', + description: String(e).slice(0, 180), + }); + } + return report; + }; + // ═══ AUTO-TRANSLATE ONE-CLICK FLOW ═══ const autoTranslateRunningRef = useRef(false); const startAutoTranslate = async () => { @@ -3009,6 +3107,16 @@ export default function GameDetailPage() { }); const patchResult = verdict.outcome; + // Strada statica senza effetto sul gioco → prova il runtime. Il report + // per gioco lo registra `tryRuntimeFallback` stesso, così vale anche per + // il ramo «nessuna stringa estraibile» che esce prima di qui. + if (patchResult === 'failure') { + await tryRuntimeFallback(patchResult, { + injected: verdict.stringsTranslated, + total: verdict.stringsTotal, + }); + } + // Messaggio onesto: nessun deliverable e nessuna stringa estraibile = motore non // supportato (file-based) o niente da tradurre. Niente falso "successo". if (deliverables.length === 0 && totalStr === 0) { @@ -3024,6 +3132,9 @@ export default function GameDetailPage() { `Il motore "${predictionResult?.engine || game.engine || 'sconosciuto'}" non è ancora supportato per la traduzione automatica sui file, oppure non sono state trovate stringhe estraibili. Opzioni: prova l'OCR overlay (per giochi che mostrano testo a runtime) o la traduzione manuale dal patcher dedicato.` ); toast.error(t('gameDetail.errUnsupported')); + // Niente da estrarre dai file non vuol dire niente da tradurre: è + // esattamente il caso per cui esiste la strada a runtime. + void tryRuntimeFallback('failure', { injected: 0, total: totalStr }); return; // → finally resetta lo stato; nessun result di "successo" } diff --git a/docs/METODI-DI-TRADUZIONE.md b/docs/METODI-DI-TRADUZIONE.md index 9051f4944..cdcad5f0c 100644 --- a/docs/METODI-DI-TRADUZIONE.md +++ b/docs/METODI-DI-TRADUZIONE.md @@ -212,7 +212,7 @@ veloce. Vedi lo stato dei due sotto. | pipe / regione | lato Rust | lato client | |---|---|---| | pipe `GameStringerOverlay` | reale, `src-tauri/src/overlay_ipc.rs` | reale, `gs-hook/src/gs_overlay_ipc.cpp` — **sola scrittura**, fire-and-forget | -| pipe `GameStringerTranslator` | **nessun server** | reale, `unreal-translator/hook-dll/src/ipc.cpp` | +| pipe `GameStringerTranslator` | reale, `src-tauri/src/translator_pipe.rs` | compilato in gs-hook (`hook-dll/src/ipc.cpp`) ma **mai acceso**: solo il dllmain di unreal-translator chiama `IPC::Initialize()`, quello di gs-hook no | | pipe `GameStringerUETranslator` | **stub**: `start_windows_pipe_server` dorme in un loop (`ue_translator/ipc_bridge.rs:130`) | reale, `unity-translator-dll/src/ipc_client.h` | | shmem `GameStringer_TranslationBridge_v1` | reale, `translation_bridge/shared_memory_ipc.rs` | **TODO**: `QueryBackend` ritorna `null` (`plugins/GameStringer.Satellite/Plugin.cs`) | @@ -234,15 +234,92 @@ python -c "import io;b=io.open('src-tauri/resources/gs-hook/x64/gs-hook.dll','rb ``` Quindi `unity_injector.rs` e la DLL Unity si accordano correttamente su -`GameStringerUETranslator`; a gs-hook manca un server e in Rust non esiste -nemmeno una costante per `GameStringerTranslator`. **Rinominare l'una nell'altra -scollegherebbe la DLL Unity**, che è un binario precompilato nel repo: il nome -va cambiato nell'header C++ e la DLL ricompilata, non solo in Rust. +`GameStringerUETranslator`. **Rinominare l'una nell'altra scollegherebbe la DLL +Unity**, che è un binario precompilato nel repo: il nome va cambiato nell'header +C++ e la DLL ricompilata, non solo in Rust. + +Il server Rust per `GameStringerTranslator` esiste da agosto 2026 +(`src-tauri/src/translator_pipe.rs`): message mode, header di 12 byte +`{type, requestId, dataLength}` + payload UTF-16LE, hit → risposta immediata dal +dizionario del Translation Bridge, miss → nessuna risposta (la DLL ha il suo +timeout) e il testo entra nella coda drenata da `translation_bridge_drain_misses`. +Un solo dizionario per shared memory e pipe. Il wire format è dettato dal +binario C++ già spedito e verificato con un finto client nei test +(`cargo test --lib translator_pipe`). Il lato C++ è stato acceso lo stesso +giorno: `gs-hook/src/dllmain.cpp` ora chiama `IPC::Initialize()` + +`StartReceiveThread()`, e il percorso di miss in `Translate()` è +fire-and-forget (la risposta rientra in cache dal receive thread via +`SetTranslationArrivedCallback`), perché prima bloccava fino a 2s sul thread +che disegna. + +### Una pipe letta e scritta insieme richiede `FILE_FLAG_OVERLAPPED` + +Accendere l'IPC in gs-hook freezava il gioco al primo miss. Non era il +timeout di 2s: era che `ipc.cpp` apriva la pipe **senza** +`FILE_FLAG_OVERLAPPED`. Su un handle sincrono il kernel serializza le +operazioni sullo stesso file object, quindi col receive thread fermo dentro +`ReadFile` la `WriteFile` del render thread si accodava dietro la lettura — +e quella lettura poteva completarsi solo quando fosse arrivata la richiesta +che stava bloccando. Attesa circolare. + +**Come è stato misurato.** Iniezione reale nella testapp GDI +(`gs-hook/testapp`), sonda `SendMessageTimeout(WM_NULL, 2000ms)` sulla +finestra per distinguere "lento" da "bloccato", e log su entrambi i lati: + +| | UI responsiva | log DLL | lato server | +|---|---|---|---| +| handle sincrono | **False** | 3 righe (solo attivazione) | connessione, **0 richieste** | +| handle overlapped | True | cattura intatta | 3 hit tradotti + 2 miss in coda | + +La cura: handle overlapped e un **thread di invio dedicato** con coda: il +thread di rendering tocca solo un mutex e una condvar, mai l'I/O. Lo +spegnimento va in ordine `StopReceiveThread()` (cancella le overlapped in +corso e fa join) **poi** `Shutdown()` (chiude l'handle): chiudere l'handle +mentre un thread attende su un `OVERLAPPED` è use-after-free. + +**La trappola.** Il freeze sembrava ovviamente colpa del timeout di 2s nel +percorso di miss, ed era la pista sbagliata: quel timeout non scattava +nemmeno, perché la richiesta non partiva. Il metodo che ha risolto è stato +bisecare per esperimento invece che per lettura — DLL di giugno (cattura +OK), HEAD ricompilato (cattura OK), mie modifiche con server **spento** +(cattura OK), mie modifiche con server **acceso** (freeze). L'ultimo passo +isola il colpevole al ramo "IPC connessa" in tre minuti. **La trappola.** Due nomi che differiscono di due lettere sembrano un refuso da sistemare. Prima di allinearli, leggi cosa c'è dentro i binari: qui erano due canali sani, e l'unico difetto vero era il server che manca a entrambi. +### Un dedup senza scadenza impedisce alla catena di accorgersi di aver imparato + +Il drain loop imparava le stringhe e il gioco continuava a mostrarle in inglese. +Non era il loop: era il **dedup lato DLL**. `Translate()` tiene un insieme di +richieste in volo per non rispedire la stessa stringa a ogni draw call, e quel +set veniva svuotato solo dal callback di risposta. Ma sul miss il server tace di +proposito — la traduzione ancora non esiste — quindi la voce non usciva mai: +la stringa restava "in attesa" per sempre e la DLL non la richiedeva **mai più**, +nemmeno dopo che il drain loop l'aveva imparata. + +La cura è un TTL (`kPendingTtlMs`, 10s in +`unreal-translator/hook-dll/src/translator.cpp`): sopra il giro del drain loop +lato app (~3s), sotto la pazienza umana. Scaduto, la stringa si può richiedere. + +**Come è stato misurato.** Server di prova con un "provider" che prefissa `[IT]` +(`cargo run --example translator_pipe_server`), iniezione reale nella testapp +GDI, e si guarda la stessa stringa attraversare i tre stati: + +```text +[DEBUG] translator IPC miss: "The dragon roars from the mountain." +IMPARATA → "The dragon roars from the mountain." = "[IT] The dragon roars..." +[DEBUG] translator IPC hit: "The dragon roars from the mountain." -> "[IT] The dragon roars..." +``` + +Senza TTL la terza riga non compare mai, ed è l'unica che dimostra qualcosa. + +**La trappola.** Un dedup e una cache si somigliano, ma la cache ha una chiave +che prima o poi viene riempita, il dedup no: se la condizione che lo svuota può +non verificarsi mai, serve una scadenza. Qui la condizione era "il server +risponde", e sul miss il server tace per progetto. + --- ## Come si aggiunge una voce diff --git a/gs-hook/src/dllmain.cpp b/gs-hook/src/dllmain.cpp index 086e83fda..7b04f8ccc 100644 --- a/gs-hook/src/dllmain.cpp +++ b/gs-hook/src/dllmain.cpp @@ -19,6 +19,7 @@ #include "gs_log.h" #include "gs_overlay_ipc.h" #include "translator.h" // core generico riusato: namespace GSTranslator +#include "ipc.h" // client pipe GameStringerTranslator (stesso core) #include #include #include @@ -45,6 +46,15 @@ DWORD WINAPI MainThread(LPVOID) { if (MH_Initialize() != MH_OK) { LogA("[gs-hook] MinHook init FAILED\n"); return 1; } + // Connetti a GameStringer (pipe GameStringerTranslator, server Rust in + // translator_pipe.rs). Senza backend si prosegue in sola cache locale. + if (GSTranslator::IPC::Initialize()) { + GSTranslator::IPC::StartReceiveThread(); + LogA("[gs-hook] connesso a GameStringer via IPC\n"); + } else { + LogA("[gs-hook] GameStringer non raggiungibile, solo cache locale\n"); + } + // Lingue: in produzione arrivano da GameStringer via IPC/config. Default qui. GSTranslator::TranslatorConfig cfg; cfg.targetLanguage = L"it"; @@ -101,6 +111,11 @@ DWORD WINAPI CleanupThread(LPVOID) { g_active.clear(); MH_DisableHook(MH_ALL_HOOKS); MH_Uninitialize(); + // Ordine obbligato: StopReceiveThread cancella le I/O overlapped in corso + // e joina i thread; solo dopo Shutdown può chiudere l'handle (chiuderlo + // mentre un thread attende su un OVERLAPPED è use-after-free). + GSTranslator::IPC::StopReceiveThread(); + GSTranslator::IPC::Shutdown(); GSTranslator::ShutdownTranslator(); gs::overlay::Shutdown(); return 0; diff --git a/hooks/use-translation-bridge-drain.ts b/hooks/use-translation-bridge-drain.ts new file mode 100644 index 000000000..38c2a05d6 --- /dev/null +++ b/hooks/use-translation-bridge-drain.ts @@ -0,0 +1,116 @@ +'use client'; + +/** + * Aggancia il drain loop del Translation Bridge al ciclo di vita di React. + * + * Vedi `lib/translation-bridge-drain.ts` per il perché: quando la DLL iniettata + * chiede una stringa sconosciuta, il server la accoda come cache miss; questo + * loop la traduce con lo stack AI dell'app e la rimette nel dizionario, così + * dal secondo avvistamento è un hit. + */ + +import { useEffect, useRef, useState } from 'react'; +import { appDataDir, join } from '@tauri-apps/api/path'; + +import { clientLogger } from '@/lib/client-logger'; +import { translationBridge } from '@/lib/translation-bridge'; +import { translateWithFallback } from '@/lib/ai/ai-translate-direct'; +import { + TranslationBridgeDrain, + type DrainOptions, + type DrainStats, +} from '@/lib/translation-bridge-drain'; + +/** Sottocartella dei dizionari appresi, dentro la app data dir di Tauri. */ +const DICT_SUBDIR = 'bridge-dictionaries'; + +export interface UseTranslationBridgeDrainOptions extends DrainOptions { + /** Il loop parte solo quando è true (es. traduzione in tempo reale attiva). */ + enabled: boolean; + targetLanguage?: string; + sourceLanguage?: string; +} + +/** + * @returns le statistiche correnti del loop, per mostrarle nella UI. + */ +export function useTranslationBridgeDrain({ + enabled, + targetLanguage = 'it', + sourceLanguage = 'auto', + ...drainOptions +}: UseTranslationBridgeDrainOptions): DrainStats | null { + const [stats, setStats] = useState(null); + const loopRef = useRef(null); + + useEffect(() => { + if (!enabled) return; + + let cancelled = false; + let dictDir: string | null = null; + + const run = async () => { + // Ricarica quello che le sessioni precedenti hanno imparato: è ciò che + // rende il tetto di spesa sostenibile nel tempo. + try { + dictDir = await join(await appDataDir(), DICT_SUBDIR); + const loaded = await translationBridge.loadFromDir(dictDir); + if (loaded > 0) { + clientLogger.info(`[BridgeDrain] Ricaricate ${loaded} traduzioni apprese`); + } + } catch (error: unknown) { + clientLogger.warn(`[BridgeDrain] Dizionari appresi non ricaricati: ${String(error)}`); + } + if (cancelled) return; + + const loop = new TranslationBridgeDrain( + { + drainMisses: (max) => translationBridge.drainMisses(max), + translate: async (texts) => { + const res = await translateWithFallback( + { texts, targetLanguage, sourceLanguage }, + true, // preferWebApis: provider gratuiti senza chiave, come l'overlay + ); + return { translations: res.translations, success: res.success }; + }, + addTranslation: (original, translated) => + translationBridge.addTranslation(original, translated), + save: dictDir + ? async () => { + await translationBridge.saveToDir(dictDir as string); + } + : undefined, + }, + drainOptions, + (s) => setStats({ ...s }), + ); + + loopRef.current = loop; + loop.start(); + }; + + void run(); + + return () => { + cancelled = true; + loopRef.current?.stop(); + // Ultimo salvataggio: quello imparato dopo l'ultima soglia andrebbe perso. + if (dictDir && (loopRef.current?.stats.learned ?? 0) > 0) { + void translationBridge.saveToDir(dictDir); + } + loopRef.current = null; + }; + // drainOptions è uno spread di primitivi: si stabilizza sui suoi campi. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + enabled, + targetLanguage, + sourceLanguage, + drainOptions.intervalMs, + drainOptions.batchSize, + drainOptions.maxTranslationsPerSession, + drainOptions.saveEvery, + ]); + + return stats; +} diff --git a/lib/i18n/locales/de.json b/lib/i18n/locales/de.json index cb6619e47..aa9654677 100644 --- a/lib/i18n/locales/de.json +++ b/lib/i18n/locales/de.json @@ -950,7 +950,17 @@ "dr1Error": "Danganronpa: Übersetzung fehlgeschlagen", "unityMonoDetail": "Unity Mono – die Texte lassen sich in den Spieldateien übersetzen.", "unityIl2cppDetail": "Unity IL2CPP – die Texte stecken kompiliert in den Binärdateien: Sie werden beim Spielen übersetzt, über BepInEx + XUnity.", - "unityUnknownRuntime": "Unity – Runtime nicht bestimmt (BepInEx + XUnity)." + "unityUnknownRuntime": "Unity – Runtime nicht bestimmt (BepInEx + XUnity).", + "runtimeFallbackInjecting": "Keine Zeichenketten in die Dateien geschrieben: Wechsel zur Laufzeitübersetzung...", + "runtimeFallbackAwaitLaunch": "Dieses Spiel lässt sich nicht über die Dateien übersetzen. Starte es, und die Übersetzung erscheint während des Spielens auf dem Bildschirm.", + "runtimeFallbackBlocked": { + "not-windows": "Die Laufzeitübersetzung ist nur unter Windows verfügbar.", + "hook-missing": "Komponenten der Laufzeitübersetzung nicht gefunden: Installiere GameStringer neu.", + "anti-cheat": "Dieses Spiel nutzt Anti-Cheat: Wir fassen es nicht an, weder auf der Festplatte noch im Speicher.", + "unknown-process": "Spiel-Programmdatei nicht erkannt: Es gibt keinen Prozess, auf den zugegriffen werden kann." + }, + "runtimeFallbackReady": "Laufzeitübersetzung aktiv: Die Zeilen erscheinen während des Spielens übersetzt.", + "runtimeFallbackFailed": "Laufzeitübersetzung konnte nicht gestartet werden." }, "danganronpaPatcher": { "errLoadDrat": "Fehler beim Laden der DRAT-Informationen:", diff --git a/lib/i18n/locales/en.json b/lib/i18n/locales/en.json index 18e85fee8..ede99c4a6 100644 --- a/lib/i18n/locales/en.json +++ b/lib/i18n/locales/en.json @@ -950,7 +950,17 @@ "dr1Error": "Danganronpa: translation failed", "unityMonoDetail": "Unity Mono — text can be translated in the game files.", "unityIl2cppDetail": "Unity IL2CPP — text is compiled into the binaries: it gets translated while you play, via BepInEx + XUnity.", - "unityUnknownRuntime": "Unity — runtime not determined (BepInEx + XUnity)." + "unityUnknownRuntime": "Unity — runtime not determined (BepInEx + XUnity).", + "runtimeFallbackInjecting": "No strings written to the files: switching to runtime translation...", + "runtimeFallbackAwaitLaunch": "This game resists file-based translation. Launch it and the translation will appear on screen as you play.", + "runtimeFallbackBlocked": { + "not-windows": "Runtime translation is only available on Windows.", + "hook-missing": "Runtime translation components not found: reinstall GameStringer.", + "anti-cheat": "This game uses anti-cheat: we do not touch it, neither on disk nor in memory.", + "unknown-process": "Game executable not identified: no process to act on." + }, + "runtimeFallbackReady": "Runtime translation active: lines will appear translated as you play.", + "runtimeFallbackFailed": "Could not start runtime translation." }, "danganronpaPatcher": { "errLoadDrat": "Error loading DRAT info:", diff --git a/lib/i18n/locales/es.json b/lib/i18n/locales/es.json index a65764241..620533a1c 100644 --- a/lib/i18n/locales/es.json +++ b/lib/i18n/locales/es.json @@ -950,7 +950,17 @@ "dr1Error": "Danganronpa: traducción fallida", "unityMonoDetail": "Unity Mono: los textos se pueden traducir en los archivos del juego.", "unityIl2cppDetail": "Unity IL2CPP: los textos están compilados en los binarios, así que se traducen mientras juegas con BepInEx + XUnity.", - "unityUnknownRuntime": "Unity: runtime no determinado (BepInEx + XUnity)." + "unityUnknownRuntime": "Unity: runtime no determinado (BepInEx + XUnity).", + "runtimeFallbackInjecting": "No se escribió ninguna cadena en los archivos: paso a la traducción en tiempo de ejecución...", + "runtimeFallbackAwaitLaunch": "Este juego no se deja traducir en los archivos. Inícialo y la traducción aparecerá en pantalla mientras juegas.", + "runtimeFallbackBlocked": { + "not-windows": "La traducción en tiempo de ejecución solo está disponible en Windows.", + "hook-missing": "No se encontraron los componentes de traducción en tiempo de ejecución: reinstala GameStringer.", + "anti-cheat": "Este juego usa anti-cheat: no lo tocamos, ni en disco ni en memoria.", + "unknown-process": "Ejecutable del juego no identificado: no hay ningún proceso sobre el que actuar." + }, + "runtimeFallbackReady": "Traducción en tiempo de ejecución activa: las líneas aparecerán traducidas mientras juegas.", + "runtimeFallbackFailed": "No se pudo iniciar la traducción en tiempo de ejecución." }, "danganronpaPatcher": { "errLoadDrat": "Error al cargar la información de DRAT:", diff --git a/lib/i18n/locales/fr.json b/lib/i18n/locales/fr.json index 1ce1f9731..41246424a 100644 --- a/lib/i18n/locales/fr.json +++ b/lib/i18n/locales/fr.json @@ -950,7 +950,17 @@ "dr1Error": "Danganronpa : échec de la traduction", "unityMonoDetail": "Unity Mono — les textes peuvent être traduits dans les fichiers du jeu.", "unityIl2cppDetail": "Unity IL2CPP — les textes sont compilés dans les binaires : ils sont traduits pendant que vous jouez, via BepInEx + XUnity.", - "unityUnknownRuntime": "Unity — runtime non déterminé (BepInEx + XUnity)." + "unityUnknownRuntime": "Unity — runtime non déterminé (BepInEx + XUnity).", + "runtimeFallbackInjecting": "Aucune chaîne écrite dans les fichiers : passage à la traduction à l'exécution...", + "runtimeFallbackAwaitLaunch": "Ce jeu résiste à la traduction par fichiers. Lancez-le et la traduction s'affichera à l'écran pendant que vous jouez.", + "runtimeFallbackBlocked": { + "not-windows": "La traduction à l'exécution n'est disponible que sous Windows.", + "hook-missing": "Composants de traduction à l'exécution introuvables : réinstallez GameStringer.", + "anti-cheat": "Ce jeu utilise un anti-triche : nous n'y touchons pas, ni sur le disque ni en mémoire.", + "unknown-process": "Exécutable du jeu non identifié : aucun processus sur lequel agir." + }, + "runtimeFallbackReady": "Traduction à l'exécution active : les lignes apparaîtront traduites pendant que vous jouez.", + "runtimeFallbackFailed": "Impossible de démarrer la traduction à l'exécution." }, "danganronpaPatcher": { "errLoadDrat": "Erreur lors du chargement des infos DRAT :", diff --git a/lib/i18n/locales/it.json b/lib/i18n/locales/it.json index 917fef2b3..430afa5ca 100644 --- a/lib/i18n/locales/it.json +++ b/lib/i18n/locales/it.json @@ -950,7 +950,17 @@ "dr1Error": "Danganronpa: traduzione fallita", "unityMonoDetail": "Unity Mono — i testi si possono tradurre nei file del gioco.", "unityIl2cppDetail": "Unity IL2CPP — i testi sono compilati nei binari: si traducono mentre giochi, con BepInEx + XUnity.", - "unityUnknownRuntime": "Unity — runtime non determinato (BepInEx + XUnity)." + "unityUnknownRuntime": "Unity — runtime non determinato (BepInEx + XUnity).", + "runtimeFallbackInjecting": "Nessuna stringa scritta nei file: passo alla traduzione a runtime...", + "runtimeFallbackAwaitLaunch": "Questo gioco non si lascia tradurre nei file. Avvialo e la traduzione partirà a schermo mentre giochi.", + "runtimeFallbackBlocked": { + "not-windows": "La traduzione a runtime è disponibile solo su Windows.", + "hook-missing": "Componenti della traduzione a runtime non trovati: reinstalla GameStringer.", + "anti-cheat": "Il gioco usa un anti-cheat: non lo tocchiamo, né nei file né in memoria.", + "unknown-process": "Eseguibile del gioco non identificato: non so in quale processo agire." + }, + "runtimeFallbackReady": "Traduzione a runtime attiva: le righe appariranno tradotte mentre giochi.", + "runtimeFallbackFailed": "Attivazione della traduzione a runtime fallita." }, "danganronpaPatcher": { "errLoadDrat": "Errore caricamento info DRAT:", diff --git a/lib/i18n/locales/ja.json b/lib/i18n/locales/ja.json index de6d0cb9b..a54b01565 100644 --- a/lib/i18n/locales/ja.json +++ b/lib/i18n/locales/ja.json @@ -950,7 +950,17 @@ "dr1Error": "Danganronpa: 翻訳に失敗しました", "unityMonoDetail": "Unity Mono — テキストはゲームのファイル内で翻訳できます。", "unityIl2cppDetail": "Unity IL2CPP — テキストはバイナリに埋め込まれているため、BepInEx + XUnity でプレイ中に翻訳します。", - "unityUnknownRuntime": "Unity — ランタイム不明(BepInEx + XUnity)。" + "unityUnknownRuntime": "Unity — ランタイム不明(BepInEx + XUnity)。", + "runtimeFallbackInjecting": "ファイルに書き込まれた文字列はありません。実行時翻訳に切り替えます...", + "runtimeFallbackAwaitLaunch": "このゲームはファイル書き換えでの翻訳ができません。起動すると、プレイ中に画面上で翻訳が表示されます。", + "runtimeFallbackBlocked": { + "not-windows": "実行時翻訳は Windows でのみ利用できます。", + "hook-missing": "実行時翻訳のコンポーネントが見つかりません。GameStringer を再インストールしてください。", + "anti-cheat": "このゲームはアンチチートを使用しています。ディスク上もメモリ上も変更しません。", + "unknown-process": "ゲームの実行ファイルを特定できません。操作対象のプロセスがありません。" + }, + "runtimeFallbackReady": "実行時翻訳が有効です。プレイ中に各行が翻訳されて表示されます。", + "runtimeFallbackFailed": "実行時翻訳を開始できませんでした。" }, "danganronpaPatcher": { "errLoadDrat": "DRAT情報の読み込みエラー:", diff --git a/lib/i18n/locales/ko.json b/lib/i18n/locales/ko.json index 628958e71..a78983547 100644 --- a/lib/i18n/locales/ko.json +++ b/lib/i18n/locales/ko.json @@ -950,7 +950,17 @@ "dr1Error": "Danganronpa: 번역 실패", "unityMonoDetail": "Unity Mono — 텍스트를 게임 파일에서 번역할 수 있습니다.", "unityIl2cppDetail": "Unity IL2CPP — 텍스트가 바이너리에 컴파일되어 있어 BepInEx + XUnity로 플레이 중에 번역됩니다.", - "unityUnknownRuntime": "Unity — 런타임을 확인하지 못했습니다 (BepInEx + XUnity)." + "unityUnknownRuntime": "Unity — 런타임을 확인하지 못했습니다 (BepInEx + XUnity).", + "runtimeFallbackInjecting": "파일에 기록된 문자열이 없습니다. 런타임 번역으로 전환합니다...", + "runtimeFallbackAwaitLaunch": "이 게임은 파일 수정 방식으로는 번역할 수 없습니다. 게임을 실행하면 플레이 중 화면에 번역이 표시됩니다.", + "runtimeFallbackBlocked": { + "not-windows": "런타임 번역은 Windows에서만 사용할 수 있습니다.", + "hook-missing": "런타임 번역 구성 요소를 찾을 수 없습니다. GameStringer를 다시 설치하세요.", + "anti-cheat": "이 게임은 안티치트를 사용합니다. 디스크에서도 메모리에서도 건드리지 않습니다.", + "unknown-process": "게임 실행 파일을 식별하지 못했습니다. 작업할 프로세스가 없습니다." + }, + "runtimeFallbackReady": "런타임 번역이 활성화되었습니다. 플레이 중 각 줄이 번역되어 표시됩니다.", + "runtimeFallbackFailed": "런타임 번역을 시작하지 못했습니다." }, "danganronpaPatcher": { "errLoadDrat": "DRAT 정보 로드 오류:", diff --git a/lib/i18n/locales/pl.json b/lib/i18n/locales/pl.json index df8f181cc..097d5ccc1 100644 --- a/lib/i18n/locales/pl.json +++ b/lib/i18n/locales/pl.json @@ -950,7 +950,17 @@ "dr1Error": "Danganronpa: tłumaczenie nie powiodło się", "unityMonoDetail": "Unity Mono — teksty można tłumaczyć w plikach gry.", "unityIl2cppDetail": "Unity IL2CPP — teksty są wkompilowane w pliki binarne: tłumaczą się w trakcie gry, przez BepInEx + XUnity.", - "unityUnknownRuntime": "Unity — nie ustalono środowiska (BepInEx + XUnity)." + "unityUnknownRuntime": "Unity — nie ustalono środowiska (BepInEx + XUnity).", + "runtimeFallbackInjecting": "Nie zapisano żadnych ciągów w plikach: przechodzę na tłumaczenie w czasie działania...", + "runtimeFallbackAwaitLaunch": "Ta gra nie poddaje się tłumaczeniu przez pliki. Uruchom ją, a tłumaczenie pojawi się na ekranie podczas gry.", + "runtimeFallbackBlocked": { + "not-windows": "Tłumaczenie w czasie działania jest dostępne tylko w systemie Windows.", + "hook-missing": "Nie znaleziono składników tłumaczenia w czasie działania: zainstaluj ponownie GameStringer.", + "anti-cheat": "Ta gra używa zabezpieczenia anti-cheat: nie ruszamy jej ani na dysku, ani w pamięci.", + "unknown-process": "Nie rozpoznano pliku wykonywalnego gry: brak procesu, na którym można działać." + }, + "runtimeFallbackReady": "Tłumaczenie w czasie działania aktywne: wiersze pojawią się przetłumaczone podczas gry.", + "runtimeFallbackFailed": "Nie udało się uruchomić tłumaczenia w czasie działania." }, "danganronpaPatcher": { "errLoadDrat": "Błąd wczytywania informacji DRAT:", diff --git a/lib/i18n/locales/pt.json b/lib/i18n/locales/pt.json index 2e5b04052..39acb11a2 100644 --- a/lib/i18n/locales/pt.json +++ b/lib/i18n/locales/pt.json @@ -950,7 +950,17 @@ "dr1Error": "Danganronpa: falha na tradução", "unityMonoDetail": "Unity Mono — os textos podem ser traduzidos nos ficheiros do jogo.", "unityIl2cppDetail": "Unity IL2CPP — os textos estão compilados nos binários: são traduzidos enquanto jogas, com BepInEx + XUnity.", - "unityUnknownRuntime": "Unity — runtime não determinado (BepInEx + XUnity)." + "unityUnknownRuntime": "Unity — runtime não determinado (BepInEx + XUnity).", + "runtimeFallbackInjecting": "Nenhuma cadeia escrita nos ficheiros: a mudar para a tradução em tempo de execução...", + "runtimeFallbackAwaitLaunch": "Este jogo resiste à tradução pelos ficheiros. Inicie-o e a tradução aparecerá no ecrã enquanto joga.", + "runtimeFallbackBlocked": { + "not-windows": "A tradução em tempo de execução só está disponível no Windows.", + "hook-missing": "Componentes da tradução em tempo de execução não encontrados: reinstale o GameStringer.", + "anti-cheat": "Este jogo usa anti-cheat: não lhe tocamos, nem no disco nem na memória.", + "unknown-process": "Executável do jogo não identificado: não há processo sobre o qual agir." + }, + "runtimeFallbackReady": "Tradução em tempo de execução ativa: as linhas aparecerão traduzidas enquanto joga.", + "runtimeFallbackFailed": "Não foi possível iniciar a tradução em tempo de execução." }, "danganronpaPatcher": { "errLoadDrat": "Erro ao carregar informações do DRAT:", diff --git a/lib/i18n/locales/ru.json b/lib/i18n/locales/ru.json index 6892ce113..f72f163d8 100644 --- a/lib/i18n/locales/ru.json +++ b/lib/i18n/locales/ru.json @@ -950,7 +950,17 @@ "dr1Error": "Danganronpa: перевод не удался", "unityMonoDetail": "Unity Mono — тексты можно переводить прямо в файлах игры.", "unityIl2cppDetail": "Unity IL2CPP — тексты вкомпилированы в бинарники: они переводятся во время игры через BepInEx + XUnity.", - "unityUnknownRuntime": "Unity — среда выполнения не определена (BepInEx + XUnity)." + "unityUnknownRuntime": "Unity — среда выполнения не определена (BepInEx + XUnity).", + "runtimeFallbackInjecting": "В файлы не записано ни одной строки: перехожу к переводу во время игры...", + "runtimeFallbackAwaitLaunch": "Эта игра не поддаётся переводу через файлы. Запустите её, и перевод появится на экране во время игры.", + "runtimeFallbackBlocked": { + "not-windows": "Перевод во время игры доступен только в Windows.", + "hook-missing": "Компоненты перевода во время игры не найдены: переустановите GameStringer.", + "anti-cheat": "В игре используется защита от читов: мы её не трогаем — ни на диске, ни в памяти.", + "unknown-process": "Исполняемый файл игры не определён: нет процесса, с которым можно работать." + }, + "runtimeFallbackReady": "Перевод во время игры включён: строки будут появляться переведёнными по ходу игры.", + "runtimeFallbackFailed": "Не удалось запустить перевод во время игры." }, "danganronpaPatcher": { "errLoadDrat": "Ошибка загрузки информации DRAT:", diff --git a/lib/i18n/locales/zh.json b/lib/i18n/locales/zh.json index cc73d9983..b3f4df277 100644 --- a/lib/i18n/locales/zh.json +++ b/lib/i18n/locales/zh.json @@ -950,7 +950,17 @@ "dr1Error": "Danganronpa: 翻译失败", "unityMonoDetail": "Unity Mono — 文本可以直接在游戏文件中翻译。", "unityIl2cppDetail": "Unity IL2CPP — 文本已编译进二进制文件:需通过 BepInEx + XUnity 在游玩时实时翻译。", - "unityUnknownRuntime": "Unity — 未能确定运行时(BepInEx + XUnity)。" + "unityUnknownRuntime": "Unity — 未能确定运行时(BepInEx + XUnity)。", + "runtimeFallbackInjecting": "未向文件写入任何字符串:切换到运行时翻译…", + "runtimeFallbackAwaitLaunch": "该游戏无法通过修改文件进行翻译。启动游戏后,翻译将在游玩过程中显示在屏幕上。", + "runtimeFallbackBlocked": { + "not-windows": "运行时翻译仅在 Windows 上可用。", + "hook-missing": "未找到运行时翻译组件:请重新安装 GameStringer。", + "anti-cheat": "该游戏使用了反作弊保护:我们不会改动它,无论是磁盘还是内存。", + "unknown-process": "未能识别游戏可执行文件:没有可操作的进程。" + }, + "runtimeFallbackReady": "运行时翻译已启用:游玩过程中各行文本将显示为译文。", + "runtimeFallbackFailed": "无法启动运行时翻译。" }, "danganronpaPatcher": { "errLoadDrat": "加载 DRAT 信息时出错:", diff --git a/lib/translation-bridge-drain.ts b/lib/translation-bridge-drain.ts new file mode 100644 index 000000000..bdd561c9c --- /dev/null +++ b/lib/translation-bridge-drain.ts @@ -0,0 +1,204 @@ +/** + * Drain loop del Translation Bridge — l'anello che fa "imparare" la catena. + * + * Quando la DLL iniettata chiede una stringa che il dizionario non conosce, il + * server (`src-tauri/src/translator_pipe.rs`) non risponde e la accoda come + * cache miss. Questo loop drena quella coda, traduce con lo stack AI dell'app e + * reinserisce il risultato nel dizionario: dal secondo avvistamento in poi la + * stessa stringa è un hit, servito in microsecondi senza ripagare il provider. + * + * La logica è pura e a dipendenze iniettate (`DrainDeps`) — niente React, niente + * Tauri — così i test possono farla girare a tempo simulato. + */ + +import { clientLogger } from '@/lib/client-logger'; + +/** Porte verso il mondo esterno: il test le sostituisce con delle finte. */ +export interface DrainDeps { + /** Preleva fino a `max` testi non tradotti dalla coda dei miss. */ + drainMisses: (max: number) => Promise; + /** Traduce un blocco di testi. Deve restituire un array parallelo a `texts`. */ + translate: (texts: string[]) => Promise<{ translations: string[]; success: boolean }>; + /** Inserisce una traduzione nel dizionario del bridge. */ + addTranslation: (original: string, translated: string) => Promise; + /** Persiste i dizionari su disco (memoria durevole tra sessioni). */ + save?: () => Promise; +} + +export interface DrainOptions { + /** Attesa tra un giro e l'altro, in ms. */ + intervalMs?: number; + /** Massimo di testi drenati per giro. */ + batchSize?: number; + /** + * Tetto di spesa: massimo di stringhe tradotte per sessione. Serve a evitare + * che un gioco con migliaia di stringhe nuove apra un rubinetto sul provider. + * `0` = nessun limite (sconsigliato). + */ + maxTranslationsPerSession?: number; + /** Salva su disco ogni N traduzioni imparate. */ + saveEvery?: number; +} + +export interface DrainStats { + /** Stringhe tradotte e reinserite nel dizionario in questa sessione. */ + learned: number; + /** Giri in cui la coda era vuota. */ + idleRounds: number; + /** Testi che il provider non è riuscito a tradurre. */ + failed: number; + /** True quando il tetto di sessione è stato raggiunto. */ + budgetExhausted: boolean; +} + +const DEFAULTS: Required> = { + intervalMs: 3000, + batchSize: 50, + maxTranslationsPerSession: 2000, + saveEvery: 25, +}; + +/** + * Esegue UN giro di drain. Esportata a parte perché è l'unità che ha senso + * testare: il loop attorno è solo un timer. + * + * @returns quante stringhe sono state imparate in questo giro. + */ +export async function drainOnce( + deps: DrainDeps, + stats: DrainStats, + opts: Required, +): Promise { + if (opts.maxTranslationsPerSession > 0 && stats.learned >= opts.maxTranslationsPerSession) { + stats.budgetExhausted = true; + return 0; + } + + const texts = await deps.drainMisses(opts.batchSize); + if (texts.length === 0) { + stats.idleRounds++; + return 0; + } + + // Non superare il tetto: tronca il batch a quanto resta di budget. + const remaining = + opts.maxTranslationsPerSession > 0 + ? opts.maxTranslationsPerSession - stats.learned + : texts.length; + const batch = texts.slice(0, remaining); + + const result = await deps.translate(batch); + if (!result.success) { + stats.failed += batch.length; + return 0; + } + + let learnedNow = 0; + for (let i = 0; i < batch.length; i++) { + const original = batch[i]; + const translated = result.translations[i]; + // Una traduzione vuota o identica all'originale non è una traduzione: + // inserirla trasformerebbe un miss in un hit permanente e sbagliato, + // e la stringa non verrebbe mai più ritentata. + if (!translated || translated === original) { + stats.failed++; + continue; + } + if (await deps.addTranslation(original, translated)) { + stats.learned++; + learnedNow++; + } else { + stats.failed++; + } + } + + if ( + deps.save && + opts.saveEvery > 0 && + learnedNow > 0 && + Math.floor(stats.learned / opts.saveEvery) > + Math.floor((stats.learned - learnedNow) / opts.saveEvery) + ) { + try { + await deps.save(); + } catch (error: unknown) { + clientLogger.warn(`[BridgeDrain] Salvataggio fallito: ${String(error)}`); + } + } + + if (opts.maxTranslationsPerSession > 0 && stats.learned >= opts.maxTranslationsPerSession) { + stats.budgetExhausted = true; + clientLogger.warn( + `[BridgeDrain] Tetto di sessione raggiunto (${opts.maxTranslationsPerSession} stringhe), loop fermo`, + ); + } + + return learnedNow; +} + +/** + * Loop che chiama `drainOnce` a intervalli finché non lo si ferma o finché il + * tetto di sessione non si esaurisce. + */ +export class TranslationBridgeDrain { + private timer: ReturnType | null = null; + private running = false; + private readonly opts: Required; + readonly stats: DrainStats = { + learned: 0, + idleRounds: 0, + failed: 0, + budgetExhausted: false, + }; + + constructor( + private readonly deps: DrainDeps, + options: DrainOptions = {}, + private readonly onTick?: (stats: DrainStats) => void, + ) { + this.opts = { ...DEFAULTS, ...options }; + } + + get isRunning(): boolean { + return this.running; + } + + start(): void { + if (this.running) return; + this.running = true; + void this.tick(); + } + + stop(): void { + this.running = false; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + } + + /** Esegue un giro subito, senza aspettare l'intervallo (per i test e la UI). */ + async runOnce(): Promise { + return drainOnce(this.deps, this.stats, this.opts); + } + + private async tick(): Promise { + if (!this.running) return; + + try { + await drainOnce(this.deps, this.stats, this.opts); + } catch (error: unknown) { + clientLogger.error(`[BridgeDrain] Giro fallito: ${String(error)}`); + } + + this.onTick?.(this.stats); + + if (this.stats.budgetExhausted) { + this.stop(); + return; + } + if (!this.running) return; + + this.timer = setTimeout(() => void this.tick(), this.opts.intervalMs); + } +} diff --git a/lib/translation-bridge.ts b/lib/translation-bridge.ts index 0b6c22abe..9b70a5561 100644 --- a/lib/translation-bridge.ts +++ b/lib/translation-bridge.ts @@ -269,6 +269,39 @@ export class TranslationBridgeClient { } } + /** + * Persist every dictionary to `dir` (one `_.json` per language + * pair). This is the durable memory of what the drain loop learns: without + * it, each session pays the provider again for the same strings. + */ + async saveToDir(dir: string): Promise { + try { + const response = await invoke>('translation_bridge_save_dir', { + dir, + }); + return response.data ?? 0; + } catch (error: unknown) { + clientLogger.error(`[TranslationBridge] Failed to save dictionaries: ${String(error)}`); + return 0; + } + } + + /** + * Reload dictionaries written by `saveToDir`. A missing directory yields 0, + * not an error — that is simply the first session. + */ + async loadFromDir(dir: string): Promise { + try { + const response = await invoke>('translation_bridge_load_dir', { + dir, + }); + return response.data ?? 0; + } catch (error: unknown) { + clientLogger.error(`[TranslationBridge] Failed to load dictionaries: ${String(error)}`); + return 0; + } + } + /** * Clear all dictionaries */ diff --git a/lib/translation/runtime-fallback.ts b/lib/translation/runtime-fallback.ts new file mode 100644 index 000000000..8757243d2 --- /dev/null +++ b/lib/translation/runtime-fallback.ts @@ -0,0 +1,173 @@ +/** + * Cosa fare quando la strada statica non ha tradotto il gioco. + * + * PERCHÉ ESISTE + * `decidePatchOutcome` dice *com'è andata*; questo modulo dice *cosa fare + * adesso*. Sono due domande diverse e vivevano entrambe dentro un componente + * da 4300 righe, dove non si possono provare. Un gioco che resiste + * all'estrazione statica non è un gioco intraducibile: c'è la strada a runtime + * (gs-hook + pipe `GameStringerTranslator` + drain loop). Ma quella strada ha + * precondizioni vere — Windows, DLL presenti, e soprattutto **il gioco in + * esecuzione**, perché si inietta in un processo, non in una cartella. + * + * La regola sta qui, con i suoi test, e il componente la chiama. + */ + +import type { PatchOutcome } from './patch-outcome'; + +/** Perché la strada a runtime non è percorribile. */ +export type RuntimeBlocker = + /** L'iniezione è Windows-only. */ + | 'not-windows' + /** Le DLL gs-hook non sono nel bundle. */ + | 'hook-missing' + /** Il gate anti-cheat vieta di toccare questo processo. */ + | 'anti-cheat' + /** Non sappiamo quale eseguibile cercare. */ + | 'unknown-process'; + +export type RuntimePlan = + /** La strada statica ha funzionato: non serve altro. */ + | { action: 'none' } + /** Gioco in esecuzione: si può iniettare adesso. */ + | { action: 'inject'; processName: string } + /** Tutto pronto ma il gioco è chiuso: va avviato prima. */ + | { action: 'await-launch'; processName: string } + /** Strada a runtime preclusa. */ + | { action: 'unavailable'; blocker: RuntimeBlocker }; + +export interface RuntimeContext { + /** Esito della strada statica, da `decidePatchOutcome`. */ + staticOutcome: PatchOutcome; + /** L'iniezione esiste solo su Windows. */ + isWindows: boolean; + /** `gs-hook.dll` presente nelle resources per l'arch giusta. */ + hookAvailable: boolean; + /** Nome dell'eseguibile del gioco (es. `Game.exe`), se noto. */ + processName?: string | null; + /** Il processo è vivo adesso. */ + processRunning: boolean; + /** Il gate anti-cheat ha già detto no per questo gioco. */ + antiCheatBlocked?: boolean; +} + +/** + * Decide il passo successivo dopo la strada statica. + * + * L'ordine conta: i motivi *strutturali* per cui il runtime non si può fare + * (piattaforma, DLL, anti-cheat) vengono prima di quelli *contingenti* (gioco + * chiuso), perché a un utente si dice «qui non si può» una volta sola, mentre + * «avvia il gioco» è un invito ad agire, e darlo quando l'azione non porterebbe + * a niente è peggio che tacere. + */ +export function planRuntimeFallback(ctx: RuntimeContext): RuntimePlan { + // Un successo parziale è comunque un gioco modificato: il runtime + // sovrapposto a una patch statica mostrerebbe due traduzioni della stessa + // riga. Si interviene solo quando la strada statica non ha inciso. + if (ctx.staticOutcome !== 'failure') { + return { action: 'none' }; + } + + if (!ctx.isWindows) { + return { action: 'unavailable', blocker: 'not-windows' }; + } + if (!ctx.hookAvailable) { + return { action: 'unavailable', blocker: 'hook-missing' }; + } + if (ctx.antiCheatBlocked) { + return { action: 'unavailable', blocker: 'anti-cheat' }; + } + if (!ctx.processName) { + return { action: 'unavailable', blocker: 'unknown-process' }; + } + + return ctx.processRunning + ? { action: 'inject', processName: ctx.processName } + : { action: 'await-launch', processName: ctx.processName }; +} + +/** Chiave i18n del messaggio da mostrare per un piano. */ +export function runtimePlanMessageKey(plan: RuntimePlan): string { + switch (plan.action) { + case 'none': + return ''; + case 'inject': + return 'gameDetail.runtimeFallbackInjecting'; + case 'await-launch': + return 'gameDetail.runtimeFallbackAwaitLaunch'; + case 'unavailable': + return `gameDetail.runtimeFallbackBlocked.${plan.blocker}`; + } +} + +// ─── Report per gioco ───────────────────────────────────────────── + +export type AttemptedPath = 'static' | 'runtime'; + +export interface RunReport { + gameTitle: string; + engine: string | null; + /** Strade tentate, in ordine. */ + attempted: AttemptedPath[]; + /** Stringhe scritte nei file di gioco (`null` = non misurato). */ + stringsInjected: number | null; + /** Stringhe trovate (`null` = non misurato). */ + stringsTotal: number | null; + /** Esito della strada statica. */ + staticOutcome: PatchOutcome; + /** Piano scelto dopo la strada statica. */ + plan: RuntimePlan; + /** Chiave i18n del passo successivo suggerito. */ + nextStepKey: string; +} + +export function buildRunReport(args: { + gameTitle: string; + engine?: string | null; + staticOutcome: PatchOutcome; + stringsInjected?: number | null; + stringsTotal?: number | null; + plan: RuntimePlan; +}): RunReport { + const attempted: AttemptedPath[] = ['static']; + if (args.plan.action === 'inject') { + attempted.push('runtime'); + } + + return { + gameTitle: args.gameTitle, + engine: args.engine ?? null, + attempted, + stringsInjected: args.stringsInjected ?? null, + stringsTotal: args.stringsTotal ?? null, + staticOutcome: args.staticOutcome, + plan: args.plan, + nextStepKey: runtimePlanMessageKey(args.plan), + }; +} + +/** + * Riassunto di una riga per la cronologia attività. + * + * Dice cosa è ENTRATO nel gioco, non quanti stadi sono finiti — è la stessa + * regola per cui esiste `patch-outcome.ts`: «100% completato» con zero righe + * scritte è la bugia che quel modulo è nato per chiudere. `null` resta + * distinto da `0`: «non misurato» non è «misurato, ed è zero». + */ +export function summarizeRunReport(report: RunReport): string { + const counts = + report.stringsInjected === null || report.stringsTotal === null + ? 'conteggi non disponibili' + : `${report.stringsInjected}/${report.stringsTotal} stringhe scritte`; + + switch (report.plan.action) { + case 'inject': + return `${counts} — passato alla traduzione a runtime`; + case 'await-launch': + return `${counts} — traduzione a runtime pronta, avvia il gioco`; + case 'unavailable': + return `${counts} — runtime non disponibile (${report.plan.blocker})`; + case 'none': + return counts; + } +} diff --git a/src-tauri/examples/translator_pipe_server.rs b/src-tauri/examples/translator_pipe_server.rs new file mode 100644 index 000000000..30e8e08c0 --- /dev/null +++ b/src-tauri/examples/translator_pipe_server.rs @@ -0,0 +1,98 @@ +//! Host di prova per il server della pipe `GameStringerTranslator`. +//! +//! Fa girare `translator_pipe::serve` da solo, senza l'app Tauri: dizionario +//! preseedato con le righe della testapp GDI di gs-hook, miss stampati a video. +//! Serve per il test end-to-end manuale: +//! +//! ```text +//! cargo run --example translator_pipe_server # terminale 1 +//! gs-hook\testapp\build-x64\bin\Release\gdi-texttest.exe # terminale 2 +//! gs-hook\build-x64\bin\Release\gs-injector.exe +//! ``` +//! +//! Atteso: la DLL logga "connesso a GameStringer via IPC", qui compaiono le +//! richieste (hit per le righe preseedate, miss per il resto). + +#[cfg(windows)] +fn main() { + use gamestringer::translation_bridge::dictionary_engine::DictionaryEngine; + use parking_lot::RwLock; + use std::sync::{mpsc, Arc}; + + // Le 4 righe di gdi_text_test.cpp — tre tradotte, una lasciata fuori + // apposta per vedere il percorso miss. + let mut engine = DictionaryEngine::new(); + engine.set_active_languages("en", "it"); + engine.load_translations( + "en", + "it", + vec![ + ( + "You found a mysterious key!".to_string(), + "Hai trovato una chiave misteriosa!".to_string(), + ), + ( + "A wild slime appears before you.".to_string(), + "Uno slime selvatico ti appare davanti.".to_string(), + ), + ( + "The old merchant smiles and says: welcome back, traveler.".to_string(), + "Il vecchio mercante sorride e dice: bentornato, viaggiatore.".to_string(), + ), + // "The dragon roars from the mountain." → miss voluto + ], + ); + let dictionary = Arc::new(RwLock::new(engine)); + let (miss_tx, miss_rx) = mpsc::channel::(); + + // Drain loop finto: sta al posto di `lib/translation-bridge-drain.ts`, con + // un "provider" che prefissa [IT]. Serve a mostrare che la catena IMPARA: + // primo avvistamento di una stringa = miss, dal secondo = hit. + let learner = Arc::clone(&dictionary); + std::thread::spawn(move || { + for text in miss_rx { + let translated = format!("[IT] {}", text); + learner + .write() + .add_translation(text.clone(), translated.clone()); + println!("IMPARATA → \"{}\" = \"{}\"", text, translated); + } + }); + + // Logger minimo a stdout così i log::debug! del server (hit/miss/connessioni) + // diventano visibili in questo host di prova. + struct StdoutLogger; + impl log::Log for StdoutLogger { + fn enabled(&self, _: &log::Metadata) -> bool { + true + } + fn log(&self, record: &log::Record) { + println!("[{}] {}", record.level(), record.args()); + } + fn flush(&self) {} + } + static LOGGER: StdoutLogger = StdoutLogger; + let _ = log::set_logger(&LOGGER).map(|_| log::set_max_level(log::LevelFilter::Debug)); + + println!( + "Server di prova su {} — 3 righe preseedate, Ctrl+C per uscire", + gamestringer::translator_pipe::PIPE_NAME + ); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + if let Err(e) = rt.block_on(gamestringer::translator_pipe::serve( + gamestringer::translator_pipe::PIPE_NAME, + dictionary, + miss_tx, + )) { + eprintln!("server terminato: {e}"); + } +} + +#[cfg(not(windows))] +fn main() { + eprintln!("Named pipe: solo Windows."); +} diff --git a/src-tauri/src/commands/gs_hook_injector.rs b/src-tauri/src/commands/gs_hook_injector.rs index 968581a67..29a9d5383 100644 --- a/src-tauri/src/commands/gs_hook_injector.rs +++ b/src-tauri/src/commands/gs_hook_injector.rs @@ -27,6 +27,37 @@ pub struct InjectionResult { pub message: String, } +/// Se la traduzione a runtime è percorribile, e se il gioco è già avviato. +/// +/// Serve a decidere PRIMA di provare: senza questa sonda l'unica risposta +/// possibile a «il gioco è chiuso» sarebbe un'injection fallita, mentre è +/// un invito ad agire («avvia il gioco»). La regola che ci ragiona sopra sta +/// in `lib/translation/runtime-fallback.ts` con i suoi test. +#[derive(Debug, serde::Serialize)] +pub struct GsHookStatus { + /// DLL e injector presenti per almeno un'architettura. + pub available: bool, + /// `process_name` è vivo adesso. + pub process_running: bool, +} + +#[command] +pub async fn gs_hook_status(process_name: Option) -> Result { + // Basta una delle due arch: quale serve davvero si sa solo col PID in mano. + let available = ["x64", "x86"].iter().any(|arch| { + gs_hook_paths(arch) + .map(|(dll, injector)| dll.exists() && injector.exists()) + .unwrap_or(false) + }); + + let process_running = process_name + .as_deref() + .and_then(find_process_by_name) + .is_some(); + + Ok(GsHookStatus { available, process_running }) +} + /// Inietta `gs-hook.dll` (arch corretta) nel processo `process_name`. #[command] pub async fn inject_gs_hook( diff --git a/src-tauri/src/commands/platform_stubs.rs b/src-tauri/src/commands/platform_stubs.rs index 7cd308215..cc4b58129 100644 --- a/src-tauri/src/commands/platform_stubs.rs +++ b/src-tauri/src/commands/platform_stubs.rs @@ -80,6 +80,19 @@ pub async fn inject_gs_hook(_process_name: String) -> Result) -> Result { + // Fuori da Windows la strada a runtime non esiste: non e' un errore da + // mostrare, e' un fatto che il pianificatore usa per non proporla. + Ok(GsHookStatus { available: false, process_running: false }) +} + // ═══════════════════════════════════════════════════════════════════ // commands::ue_translator stubs // ═══════════════════════════════════════════════════════════════════ @@ -250,6 +263,16 @@ pub mod translation_bridge_stubs { pub async fn translation_bridge_drain_misses(_max: Option) -> Result>, String> { Ok(BridgeResponse::err(PLATFORM_ERR)) } + + #[tauri::command] + pub async fn translation_bridge_save_dir(_dir: String) -> Result, String> { + Ok(BridgeResponse::err(PLATFORM_ERR)) + } + + #[tauri::command] + pub async fn translation_bridge_load_dir(_dir: String) -> Result, String> { + Ok(BridgeResponse::err(PLATFORM_ERR)) + } } // ═══════════════════════════════════════════════════════════════════ diff --git a/src-tauri/src/commands/translation_bridge.rs b/src-tauri/src/commands/translation_bridge.rs index f1245e89b..4b95eafb8 100644 --- a/src-tauri/src/commands/translation_bridge.rs +++ b/src-tauri/src/commands/translation_bridge.rs @@ -20,6 +20,7 @@ pub struct TranslationBridgeState { pub bridge: Arc>, pub dictionary: Arc>, pub miss_receiver: Arc>>, + pub miss_sender: std::sync::mpsc::Sender, } impl TranslationBridgeState { @@ -27,10 +28,12 @@ impl TranslationBridgeState { let bridge = TranslationBridge::new(); let dictionary = Arc::clone(bridge.dictionary()); let miss_receiver = Arc::clone(bridge.miss_receiver()); + let miss_sender = bridge.miss_sender(); Self { bridge: Arc::new(Mutex::new(bridge)), dictionary, miss_receiver, + miss_sender, } } } @@ -244,3 +247,32 @@ pub async fn translation_bridge_drain_misses( } Ok(BridgeResponse::ok(texts)) } + +/// Salva tutti i dizionari in una directory (un file `_.json` per +/// coppia di lingue). È la memoria durevole di quello che il drain loop +/// impara: senza questa, ogni sessione ripaga l'AI per le stesse stringhe. +#[tauri::command] +pub async fn translation_bridge_save_dir( + state: State<'_, TranslationBridgeState>, + dir: String, +) -> Result, String> { + let dict = state.dictionary.read(); + match dict.save_to_dir(&dir) { + Ok(count) => Ok(BridgeResponse::ok(count)), + Err(e) => Ok(BridgeResponse::err(e)), + } +} + +/// Ricarica i dizionari salvati da `translation_bridge_save_dir`. +/// Directory inesistente = 0, non un errore (prima sessione). +#[tauri::command] +pub async fn translation_bridge_load_dir( + state: State<'_, TranslationBridgeState>, + dir: String, +) -> Result, String> { + let mut dict = state.dictionary.write(); + match dict.load_from_dir(&dir) { + Ok(count) => Ok(BridgeResponse::ok(count)), + Err(e) => Ok(BridgeResponse::err(e)), + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2f2f0f66d..f2419cdab 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ pub mod activity_history; pub mod ue_translator; pub mod ocr_translator; pub mod overlay_ipc; +pub mod translator_pipe; #[cfg(windows)] pub mod process_utils; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 7fa49c43e..8e82e11a6 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -17,6 +17,7 @@ mod activity_history; mod ue_translator; mod ocr_translator; mod overlay_ipc; +mod translator_pipe; pub mod profiles; pub mod notifications; @@ -712,6 +713,7 @@ fn main() { // gs-hook Direct Injection (dual-arch: GDI/Unity/Unreal universale) commands::gs_hook_injector::inject_gs_hook, + commands::gs_hook_injector::gs_hook_status, // Universal Injector (auto-detect engine + setup traduzione file-based) commands::universal_injector::detect_game_engine, @@ -777,6 +779,8 @@ fn main() { commands::translation_bridge::translation_bridge_export_json, commands::translation_bridge::translation_bridge_clear, commands::translation_bridge::translation_bridge_drain_misses, + commands::translation_bridge::translation_bridge_save_dir, + commands::translation_bridge::translation_bridge_load_dir, // Translation API (DeepL, Google, LibreTranslate) commands::translation_api::translate_deepl, @@ -1263,6 +1267,20 @@ fn main() { // gs-hook le righe estratte e le inoltra al frontend via evento. overlay_ipc::start(app.handle().clone()); + // Server IPC translator (pipe GameStringerTranslator): risponde + // alle richieste di traduzione delle DLL dal dizionario del + // Translation Bridge; i miss vanno nella coda dell'AI fallback. + #[cfg(windows)] + { + use tauri::Manager; + let bridge_state = + app.state::(); + translator_pipe::start( + std::sync::Arc::clone(&bridge_state.dictionary), + bridge_state.miss_sender.clone(), + ); + } + // ═══════════════════════════════════════════════════ // SYSTEM TRAY — Pacchetto Completo // ═══════════════════════════════════════════════════ diff --git a/src-tauri/src/translation_bridge/dictionary_engine.rs b/src-tauri/src/translation_bridge/dictionary_engine.rs index 41b0c1d50..c48505677 100644 --- a/src-tauri/src/translation_bridge/dictionary_engine.rs +++ b/src-tauri/src/translation_bridge/dictionary_engine.rs @@ -392,7 +392,6 @@ impl DictionaryEngine { } /// Salva tutti i dizionari su disco in formato JSON - #[allow(dead_code)] pub fn save_to_dir(&self, dir: &str) -> Result { let dir_path = Path::new(dir); fs::create_dir_all(dir_path) @@ -416,7 +415,6 @@ impl DictionaryEngine { } /// Carica tutti i dizionari da una directory - #[allow(dead_code)] pub fn load_from_dir(&mut self, dir: &str) -> Result { let dir_path = Path::new(dir); if !dir_path.exists() { return Ok(0); } diff --git a/src-tauri/src/translation_bridge/shared_memory_ipc.rs b/src-tauri/src/translation_bridge/shared_memory_ipc.rs index 49f03f330..74933db37 100644 --- a/src-tauri/src/translation_bridge/shared_memory_ipc.rs +++ b/src-tauri/src/translation_bridge/shared_memory_ipc.rs @@ -314,6 +314,12 @@ impl TranslationBridge { &self.miss_receiver } + /// Clona il sender dei cache miss (per il server translator_pipe: i miss + /// della pipe confluiscono nella stessa coda drenata dall'AI fallback) + pub fn miss_sender(&self) -> mpsc::Sender { + self.miss_sender.clone() + } + // ─── Internals ──────────────────────────────────────────────── /// Crea la shared memory nominata via OS diff --git a/src-tauri/src/translator_pipe.rs b/src-tauri/src/translator_pipe.rs new file mode 100644 index 000000000..8e6c8b492 --- /dev/null +++ b/src-tauri/src/translator_pipe.rs @@ -0,0 +1,354 @@ +//! translator_pipe — server Named Pipe per le richieste di traduzione delle DLL. +//! +//! È il lato Rust del canale `GameStringerTranslator`, il cui client vive in +//! `unreal-translator/hook-dll/src/ipc.cpp` (riusato da gs-hook). Fino a oggi +//! quel client non aveva nessun server: vedi la tabella dei trasporti in +//! `docs/METODI-DI-TRADUZIONE.md`. +//! +//! Wire format (dettato dal binario C++ già spedito, non modificabile da qui): +//! pipe in **message mode**; ogni messaggio è +//! `IPCMessage { type: u32 LE, request_id: u32 LE, data_length: u32 LE }` +//! seguito da `data_length` byte di payload **UTF-16LE** senza terminatore. +//! La DLL invia `TRANSLATE_REQUEST` (1) e si aspetta `TRANSLATE_RESPONSE` (101) +//! con lo stesso `request_id`. +//! +//! Semantica: hit nel dizionario → risposta immediata; miss → NESSUNA risposta +//! (la DLL ha il suo timeout) e il testo finisce nella coda dei cache miss del +//! Translation Bridge, la stessa drenata da `translation_bridge_drain_misses` +//! per l'AI fallback. Un solo dizionario, due trasporti. + +#![cfg(windows)] + +use std::collections::HashSet; +use std::sync::mpsc; +use std::sync::Arc; + +use parking_lot::RwLock; + +use crate::translation_bridge::dictionary_engine::DictionaryEngine; +use crate::translation_bridge::protocol::TranslationRequest; + +/// Nome della pipe (deve combaciare con `PIPE_NAME` in hook-dll/include/ipc.h). +pub const PIPE_NAME: &str = r"\\.\pipe\GameStringerTranslator"; + +/// DLL → GameStringer: richiesta di traduzione. +const MSG_TRANSLATE_REQUEST: u32 = 1; +/// GameStringer → DLL: risposta con il testo tradotto. +const MSG_TRANSLATE_RESPONSE: u32 = 101; + +/// Dimensione dell'header `IPCMessage` C++ (tre u32, packing naturale). +const HEADER_SIZE: usize = 12; +/// Payload massimo accettato (allineato al buffer di lettura della DLL: 64KB). +const MAX_PAYLOAD: usize = 65536 - HEADER_SIZE; + +/// Avvia il server in background sulla pipe di produzione. +/// +/// `dictionary` e `miss_sender` sono gli stessi del Translation Bridge, così i +/// dizionari caricati dal frontend rispondono anche qui e i miss confluiscono +/// nell'unica coda dell'AI fallback. +pub fn start(dictionary: Arc>, miss_sender: mpsc::Sender) { + start_on(PIPE_NAME.to_string(), dictionary, miss_sender); +} + +/// Come `start`, ma su un nome pipe arbitrario (per i test). +fn start_on( + pipe_name: String, + dictionary: Arc>, + miss_sender: mpsc::Sender, +) { + tauri::async_runtime::spawn(async move { + if let Err(e) = serve(&pipe_name, dictionary, miss_sender).await { + log::warn!("📡 translator IPC server terminato: {}", e); + } + }); +} + +/// Loop del server. Pubblico per gli host di prova (examples/) e i test: +/// la produzione passa da `start`. +pub async fn serve( + pipe_name: &str, + dictionary: Arc>, + miss_sender: mpsc::Sender, +) -> std::io::Result<()> { + use tokio::net::windows::named_pipe::{PipeMode, ServerOptions}; + + log::info!("📡 Translator IPC server in ascolto su {}", pipe_name); + loop { + // Una nuova istanza per ogni connessione (un gioco alla volta), come + // overlay_ipc. Message mode: la DLL fa un ReadFile per messaggio. + let mut server = ServerOptions::new() + .pipe_mode(PipeMode::Message) + .create(pipe_name)?; + server.connect().await?; + log::debug!("📡 translator IPC: DLL connessa"); + + if let Err(e) = handle_connection(&mut server, &dictionary, &miss_sender).await { + log::debug!("📡 translator IPC: connessione chiusa ({})", e); + } + } +} + +async fn handle_connection( + server: &mut tokio::net::windows::named_pipe::NamedPipeServer, + dictionary: &Arc>, + miss_sender: &mpsc::Sender, +) -> std::io::Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + // Dedup dei miss per connessione: ogni testo sconosciuto entra in coda una + // volta sola, anche se la DLL lo richiede a ogni frame. + let mut queued: HashSet = HashSet::new(); + let mut buf = vec![0u8; HEADER_SIZE + MAX_PAYLOAD]; + + loop { + // In message mode ogni read restituisce un messaggio intero. + let n = server.read(&mut buf).await?; + if n == 0 { + return Ok(()); // pipe chiusa + } + if n < HEADER_SIZE { + continue; + } + + let msg_type = u32::from_le_bytes(buf[0..4].try_into().unwrap()); + let request_id = u32::from_le_bytes(buf[4..8].try_into().unwrap()); + let data_length = u32::from_le_bytes(buf[8..12].try_into().unwrap()) as usize; + + if msg_type != MSG_TRANSLATE_REQUEST { + // CACHE_SYNC / LOG_MESSAGE / STATS_UPDATE: oggi la DLL non li invia + // (sono TODO lato C++); li ignoriamo senza chiudere la connessione. + continue; + } + // Il payload deve essere UTF-16 intero e coerente con l'header. + if !data_length.is_multiple_of(2) || HEADER_SIZE + data_length != n { + log::warn!( + "translator IPC: frame malformato (len dichiarata {}, ricevuti {})", + data_length, + n - HEADER_SIZE + ); + continue; + } + + let units: Vec = buf[HEADER_SIZE..HEADER_SIZE + data_length] + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + let original = match String::from_utf16(&units) { + Ok(s) => s, + Err(_) => continue, // UTF-16 invalido: ignora il frame + }; + + let translation = { + let hash = TranslationRequest::compute_hash(&original); + let dict = dictionary.read(); + dict.get_translation(hash, &original) + }; + + match translation { + Some(ref translated) => { + log::debug!("translator IPC hit: \"{}\" -> \"{}\"", original, translated); + } + None => { + log::debug!("translator IPC miss: \"{}\"", original); + } + } + match translation { + Some(translated) => { + let payload: Vec = translated + .encode_utf16() + .flat_map(|u| u.to_le_bytes()) + .collect(); + if payload.len() > MAX_PAYLOAD { + continue; // non entrerebbe nel buffer di lettura della DLL + } + let mut frame = Vec::with_capacity(HEADER_SIZE + payload.len()); + frame.extend_from_slice(&MSG_TRANSLATE_RESPONSE.to_le_bytes()); + frame.extend_from_slice(&request_id.to_le_bytes()); + frame.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + frame.extend_from_slice(&payload); + // Un write = un messaggio (PIPE_TYPE_MESSAGE). + server.write_all(&frame).await?; + } + None => { + // Nessuna risposta: la DLL gestisce il timeout. Il testo va in + // coda per l'AI fallback, una volta sola. + if queued.insert(original.clone()) { + let _ = miss_sender.send(original); + } + } + } + } +} + +// ─── Tests ──────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::time::Duration; + + /// Client di test che parla il wire format della DLL (ipc.cpp). + struct FakeDll { + pipe: std::fs::File, + } + + impl FakeDll { + fn connect(pipe_name: &str) -> Self { + let pipe = loop { + match std::fs::OpenOptions::new().read(true).write(true).open(pipe_name) { + Ok(f) => break f, + Err(_) => std::thread::sleep(Duration::from_millis(5)), + } + }; + Self { pipe } + } + + fn send_request(&mut self, request_id: u32, text: &str) { + let payload: Vec = text.encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); + let mut frame = Vec::with_capacity(HEADER_SIZE + payload.len()); + frame.extend_from_slice(&MSG_TRANSLATE_REQUEST.to_le_bytes()); + frame.extend_from_slice(&request_id.to_le_bytes()); + frame.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + frame.extend_from_slice(&payload); + self.pipe.write_all(&frame).unwrap(); + } + + fn read_response(&mut self) -> (u32, String) { + let mut header = [0u8; HEADER_SIZE]; + self.pipe.read_exact(&mut header).unwrap(); + let msg_type = u32::from_le_bytes(header[0..4].try_into().unwrap()); + let request_id = u32::from_le_bytes(header[4..8].try_into().unwrap()); + let len = u32::from_le_bytes(header[8..12].try_into().unwrap()) as usize; + assert_eq!(msg_type, MSG_TRANSLATE_RESPONSE); + let mut payload = vec![0u8; len]; + self.pipe.read_exact(&mut payload).unwrap(); + let units: Vec = payload + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + (request_id, String::from_utf16(&units).unwrap()) + } + } + + fn test_setup( + translations: Vec<(&str, &str)>, + ) -> (String, mpsc::Receiver, std::thread::JoinHandle<()>) { + let pipe_name = format!( + r"\\.\pipe\gs_test_translator_{}_{:?}", + std::process::id(), + std::thread::current().id() + ); + let mut engine = DictionaryEngine::new(); + engine.set_active_languages("en", "it"); + engine.load_translations( + "en", + "it", + translations + .into_iter() + .map(|(a, b)| (a.to_string(), b.to_string())) + .collect(), + ); + let dictionary = Arc::new(RwLock::new(engine)); + let (miss_tx, miss_rx) = mpsc::channel(); + + // Il server gira su un runtime dedicato al thread di test (i test non + // hanno il runtime di tauri::async_runtime). + let name = pipe_name.clone(); + let handle = std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async move { + let _ = serve(&name, dictionary, miss_tx).await; + }); + }); + + (pipe_name, miss_rx, handle) + } + + #[test] + fn test_hit_roundtrip() { + let (pipe, _miss_rx, _srv) = test_setup(vec![("New Game", "Nuova partita")]); + let mut dll = FakeDll::connect(&pipe); + + dll.send_request(7, "New Game"); + let (id, translated) = dll.read_response(); + assert_eq!(id, 7); + assert_eq!(translated, "Nuova partita"); + } + + #[test] + fn test_utf16_non_ascii() { + let (pipe, _miss_rx, _srv) = + test_setup(vec![("Café — привет 日本語", "Caffè — ciao giapponese")]); + let mut dll = FakeDll::connect(&pipe); + + dll.send_request(1, "Café — привет 日本語"); + let (_, translated) = dll.read_response(); + assert_eq!(translated, "Caffè — ciao giapponese"); + } + + #[test] + fn test_miss_queues_once_and_server_survives() { + let (pipe, miss_rx, _srv) = test_setup(vec![("Continue", "Continua")]); + let mut dll = FakeDll::connect(&pipe); + + // Miss ripetuto: nessuna risposta, un solo enqueue. + dll.send_request(1, "Unknown line"); + dll.send_request(2, "Unknown line"); + // Un hit subito dopo: se il server avesse risposto ai miss, qui + // leggeremmo la risposta sbagliata. + dll.send_request(3, "Continue"); + let (id, translated) = dll.read_response(); + assert_eq!(id, 3); + assert_eq!(translated, "Continua"); + + assert_eq!( + miss_rx.recv_timeout(Duration::from_secs(1)).unwrap(), + "Unknown line" + ); + assert!( + miss_rx.try_recv().is_err(), + "il miss duplicato non deve essere ri-accodato" + ); + } + + #[test] + fn test_unknown_message_type_ignored() { + let (pipe, _miss_rx, _srv) = test_setup(vec![("Save", "Salva")]); + let mut dll = FakeDll::connect(&pipe); + + // STATS_UPDATE (4): il server deve ignorarlo e restare vivo. + let mut frame = Vec::new(); + frame.extend_from_slice(&4u32.to_le_bytes()); + frame.extend_from_slice(&99u32.to_le_bytes()); + frame.extend_from_slice(&0u32.to_le_bytes()); + dll.pipe.write_all(&frame).unwrap(); + + dll.send_request(5, "Save"); + let (id, translated) = dll.read_response(); + assert_eq!(id, 5); + assert_eq!(translated, "Salva"); + } + + #[test] + fn test_reconnect_after_disconnect() { + let (pipe, _miss_rx, _srv) = test_setup(vec![("Load", "Carica")]); + + { + let mut dll = FakeDll::connect(&pipe); + dll.send_request(1, "Load"); + let (_, t) = dll.read_response(); + assert_eq!(t, "Carica"); + } // disconnessione + + // Il server deve accettare un nuovo client. + let mut dll2 = FakeDll::connect(&pipe); + dll2.send_request(2, "Load"); + let (id, t) = dll2.read_response(); + assert_eq!(id, 2); + assert_eq!(t, "Carica"); + } +} diff --git a/unreal-translator/hook-dll/include/ipc.h b/unreal-translator/hook-dll/include/ipc.h index 6672e810e..524b6dcbe 100644 --- a/unreal-translator/hook-dll/include/ipc.h +++ b/unreal-translator/hook-dll/include/ipc.h @@ -47,6 +47,13 @@ uint32_t SendTranslateRequest(const std::wstring& text); // Riceve risposta traduzione (blocking con timeout) bool ReceiveTranslateResponse(uint32_t requestId, std::wstring& translatedText, uint32_t timeoutMs = 5000); +// Callback invocata dal receive thread quando arriva una TRANSLATE_RESPONSE. +// Se impostata, sostituisce il percorso blocking di ReceiveTranslateResponse: +// la risposta viene consegnata con il testo ORIGINALE corrispondente (mappato +// via requestId), così il chiamante può metterla in cache senza bloccare mai. +using TranslationArrivedCallback = std::function; +void SetTranslationArrivedCallback(TranslationArrivedCallback callback); + // Invia log a GameStringer void SendLog(const char* level, const std::string& message); diff --git a/unreal-translator/hook-dll/src/ipc.cpp b/unreal-translator/hook-dll/src/ipc.cpp index e1372dcc9..1f333f4d1 100644 --- a/unreal-translator/hook-dll/src/ipc.cpp +++ b/unreal-translator/hook-dll/src/ipc.cpp @@ -3,24 +3,56 @@ #include #include #include -#include +#include #include #include +#include +#include namespace GSTranslator { namespace IPC { +// ─── Perché I/O OVERLAPPED (2026-08-21) ────────────────────────────────────── +// Un handle di pipe aperto SENZA FILE_FLAG_OVERLAPPED è sincrono: il kernel +// serializza ogni operazione su quel file object. Con il receive thread fermo +// dentro ReadFile, una WriteFile dal thread di rendering si accoda dietro la +// lettura e non parte MAI — e la lettura si sblocca solo quando il server +// risponde, cosa che non può fare perché la richiesta non è mai partita. +// Attesa circolare: il gioco si freeza all'istante (misurato con la testapp +// GDI, UI non responsiva e zero richieste lato server). +// +// Quindi: handle overlapped + un thread di invio dedicato. Il thread di +// rendering si limita ad accodare (mutex + condvar, zero I/O), così il +// percorso di disegno non blocca mai — vedi Translate() in translator.cpp. + static HANDLE g_hPipe = INVALID_HANDLE_VALUE; static std::atomic g_connected(false); static std::atomic g_running(false); static std::thread g_receiveThread; +static std::thread g_sendThread; static MessageCallback g_messageCallback = nullptr; static std::mutex g_responseMutex; static std::condition_variable g_responseCV; static std::unordered_map g_pendingResponses; +// requestId -> testo originale della richiesta in volo (per il callback async) +static std::unordered_map g_inflightOriginals; +static TranslationArrivedCallback g_translationArrived = nullptr; static std::atomic g_nextRequestId(1); +// Coda di invio: il render thread accoda, g_sendThread scrive. +static std::mutex g_sendMutex; +static std::condition_variable g_sendCV; +static std::deque> g_sendQueue; + +// Tetto della coda: se il backend non drena, si scartano le richieste più +// vecchie invece di gonfiare all'infinito la memoria del gioco. +static constexpr size_t kMaxQueuedFrames = 256; + +// Attesa massima per una singola operazione overlapped prima di ricontrollare +// g_running (permette l'uscita pulita dei thread). +static constexpr DWORD kIoPollMs = 200; + bool Initialize() { // Prova a connettersi alla pipe di GameStringer for (int attempt = 0; attempt < 5; attempt++) { @@ -30,14 +62,14 @@ bool Initialize() { 0, nullptr, OPEN_EXISTING, - 0, + FILE_FLAG_OVERLAPPED, // obbligatorio: vedi nota in testa al file nullptr ); - + if (g_hPipe != INVALID_HANDLE_VALUE) { break; } - + if (GetLastError() == ERROR_PIPE_BUSY) { if (!WaitNamedPipeW(PIPE_NAME, 2000)) { continue; @@ -46,29 +78,38 @@ bool Initialize() { Sleep(500); } } - + if (g_hPipe == INVALID_HANDLE_VALUE) { Utils::LogWarning("Impossibile connettersi a GameStringer pipe"); return false; } - + // Imposta modalità message DWORD mode = PIPE_READMODE_MESSAGE; SetNamedPipeHandleState(g_hPipe, &mode, nullptr, nullptr); - + g_connected = true; Utils::LogInfo("Connesso a GameStringer via IPC"); - + return true; } void Shutdown() { g_connected = false; - + if (g_hPipe != INVALID_HANDLE_VALUE) { CloseHandle(g_hPipe); g_hPipe = INVALID_HANDLE_VALUE; } + + { + std::lock_guard lock(g_responseMutex); + g_inflightOriginals.clear(); + } + { + std::lock_guard lock(g_sendMutex); + g_sendQueue.clear(); + } } bool IsConnected() { @@ -79,57 +120,67 @@ uint32_t SendTranslateRequest(const std::wstring& text) { if (!g_connected || g_hPipe == INVALID_HANDLE_VALUE) { return 0; } - + uint32_t requestId = g_nextRequestId++; - + + // Registra l'originale in volo: serve al receive thread per consegnare + // la risposta col testo di partenza (il protocollo porta solo requestId). + { + std::lock_guard lock(g_responseMutex); + g_inflightOriginals[requestId] = text; + } + // Prepara messaggio size_t textBytes = text.length() * sizeof(wchar_t); size_t totalSize = sizeof(IPCMessage) + textBytes; - + std::vector buffer(totalSize); IPCMessage* msg = reinterpret_cast(buffer.data()); msg->type = MessageType::TRANSLATE_REQUEST; msg->requestId = requestId; msg->dataLength = (uint32_t)textBytes; memcpy(buffer.data() + sizeof(IPCMessage), text.c_str(), textBytes); - - // Invia - DWORD bytesWritten; - if (!WriteFile(g_hPipe, buffer.data(), (DWORD)totalSize, &bytesWritten, nullptr)) { - Utils::LogError("Errore invio richiesta traduzione: %d", GetLastError()); - return 0; + + // Accoda e torna subito: questo gira sul thread di rendering. + { + std::lock_guard lock(g_sendMutex); + if (g_sendQueue.size() >= kMaxQueuedFrames) { + g_sendQueue.pop_front(); // scarta la più vecchia + } + g_sendQueue.push_back(std::move(buffer)); } - + g_sendCV.notify_one(); + return requestId; } bool ReceiveTranslateResponse(uint32_t requestId, std::wstring& translatedText, uint32_t timeoutMs) { std::unique_lock lock(g_responseMutex); - + // Aspetta risposta con timeout auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); - + while (g_pendingResponses.find(requestId) == g_pendingResponses.end()) { if (g_responseCV.wait_until(lock, deadline) == std::cv_status::timeout) { return false; } } - + translatedText = g_pendingResponses[requestId]; g_pendingResponses.erase(requestId); - + return true; } void SendLog(const char* level, const std::string& message) { if (!g_connected) return; - + // TODO: Implementare invio log } void SendStats(uint64_t requests, uint64_t cacheHits, uint64_t errors) { if (!g_connected) return; - + // TODO: Implementare invio statistiche } @@ -137,35 +188,102 @@ void SetMessageCallback(MessageCallback callback) { g_messageCallback = callback; } +void SetTranslationArrivedCallback(TranslationArrivedCallback callback) { + std::lock_guard lock(g_responseMutex); + g_translationArrived = callback; +} + +// Attende il completamento di un'operazione overlapped, ricontrollando +// g_running a intervalli così lo stop non resta appeso. +static bool WaitOverlapped(OVERLAPPED& ov, DWORD& bytes) { + for (;;) { + const DWORD w = WaitForSingleObject(ov.hEvent, kIoPollMs); + if (w == WAIT_OBJECT_0) { + return GetOverlappedResult(g_hPipe, &ov, &bytes, FALSE) != FALSE; + } + if (w != WAIT_TIMEOUT || !g_running || !g_connected) { + CancelIoEx(g_hPipe, &ov); + // Raccogli l'esito della cancellazione per non lasciare I/O appesa. + GetOverlappedResult(g_hPipe, &ov, &bytes, TRUE); + return false; + } + } +} + +// Thread di invio: unico a scrivere sulla pipe, così il render thread non +// tocca mai l'I/O. +static void SendThreadFunc() { + HANDLE hEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!hEvent) return; + + while (g_running) { + std::vector frame; + { + std::unique_lock lock(g_sendMutex); + g_sendCV.wait(lock, [] { return !g_sendQueue.empty() || !g_running; }); + if (!g_running) break; + frame = std::move(g_sendQueue.front()); + g_sendQueue.pop_front(); + } + + if (!g_connected || g_hPipe == INVALID_HANDLE_VALUE) continue; + + OVERLAPPED ov = {}; + ov.hEvent = hEvent; + ResetEvent(hEvent); + + DWORD written = 0; + if (!WriteFile(g_hPipe, frame.data(), (DWORD)frame.size(), &written, &ov)) { + const DWORD err = GetLastError(); + if (err != ERROR_IO_PENDING) { + Utils::LogError("Errore invio richiesta traduzione: %d", err); + if (err == ERROR_BROKEN_PIPE || err == ERROR_NO_DATA) { + g_connected = false; + } + continue; + } + if (!WaitOverlapped(ov, written)) { + continue; + } + } + } + + CloseHandle(hEvent); +} + static void ReceiveThreadFunc() { std::vector buffer(65536); - + HANDLE hEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!hEvent) return; + while (g_running && g_connected) { - DWORD bytesRead; - BOOL success = ReadFile( - g_hPipe, - buffer.data(), - (DWORD)buffer.size(), - &bytesRead, - nullptr - ); - - if (!success) { - DWORD error = GetLastError(); - if (error == ERROR_BROKEN_PIPE || error == ERROR_PIPE_NOT_CONNECTED) { + OVERLAPPED ov = {}; + ov.hEvent = hEvent; + ResetEvent(hEvent); + + DWORD bytesRead = 0; + if (!ReadFile(g_hPipe, buffer.data(), (DWORD)buffer.size(), &bytesRead, &ov)) { + const DWORD error = GetLastError(); + if (error == ERROR_IO_PENDING) { + if (!WaitOverlapped(ov, bytesRead)) { + if (!g_running || !g_connected) break; + continue; + } + } else if (error == ERROR_BROKEN_PIPE || error == ERROR_PIPE_NOT_CONNECTED) { Utils::LogWarning("Connessione IPC persa"); g_connected = false; break; + } else { + continue; } - continue; } - + if (bytesRead < sizeof(IPCMessage)) { continue; } - + IPCMessage* msg = reinterpret_cast(buffer.data()); - + switch (msg->type) { case MessageType::TRANSLATE_RESPONSE: { // Estrai testo tradotto @@ -173,49 +291,79 @@ static void ReceiveThreadFunc() { reinterpret_cast(buffer.data() + sizeof(IPCMessage)), msg->dataLength / sizeof(wchar_t) ); - - // Notifica thread in attesa + + std::wstring original; + TranslationArrivedCallback callback; { std::lock_guard lock(g_responseMutex); - g_pendingResponses[msg->requestId] = translated; + auto it = g_inflightOriginals.find(msg->requestId); + if (it != g_inflightOriginals.end()) { + original = it->second; + g_inflightOriginals.erase(it); + } + callback = g_translationArrived; + if (!callback) { + // Percorso blocking storico: notifica il thread in attesa + g_pendingResponses[msg->requestId] = translated; + } + } + + if (callback && !original.empty()) { + callback(original, translated); // fuori dal lock + } else if (!callback) { + g_responseCV.notify_all(); } - g_responseCV.notify_all(); break; } - + case MessageType::CONFIG_UPDATE: // TODO: Aggiorna configurazione break; - + case MessageType::SHUTDOWN: Utils::LogInfo("Ricevuto comando shutdown da GameStringer"); g_running = false; break; - + default: if (g_messageCallback) { - g_messageCallback(msg->type, - buffer.data() + sizeof(IPCMessage), + g_messageCallback(msg->type, + buffer.data() + sizeof(IPCMessage), msg->dataLength); } break; } } + + CloseHandle(hEvent); } +// Avvia i thread di I/O (ricezione + invio). Il nome resta per compatibilità +// col chiamante storico in unreal-translator. void StartReceiveThread() { if (g_running) return; - + g_running = true; g_receiveThread = std::thread(ReceiveThreadFunc); + g_sendThread = std::thread(SendThreadFunc); } void StopReceiveThread() { g_running = false; - + g_sendCV.notify_all(); + + // Sblocca le operazioni overlapped ancora in corso PRIMA del join + // (l'handle viene chiuso dopo, in Shutdown). + if (g_hPipe != INVALID_HANDLE_VALUE) { + CancelIoEx(g_hPipe, nullptr); + } + if (g_receiveThread.joinable()) { g_receiveThread.join(); } + if (g_sendThread.joinable()) { + g_sendThread.join(); + } } } // namespace IPC diff --git a/unreal-translator/hook-dll/src/translator.cpp b/unreal-translator/hook-dll/src/translator.cpp index 7bc663abb..8d7942e44 100644 --- a/unreal-translator/hook-dll/src/translator.cpp +++ b/unreal-translator/hook-dll/src/translator.cpp @@ -3,11 +3,27 @@ #include "ipc.h" #include "utils.h" #include +#include +#include namespace GSTranslator { static TranslatorConfig g_config; static std::atomic g_initialized(false); +// Testi già richiesti via IPC, col timestamp dell'invio: evita di reinviare la +// stessa richiesta a ogni draw call mentre l'AI fallback lavora. +// +// Il timestamp NON è un dettaglio. Sul miss il server resta volutamente in +// silenzio (la traduzione ancora non esiste), quindi la voce non verrà mai +// tolta dal callback di risposta: senza scadenza la stringa resterebbe +// "in attesa" per sempre e la DLL non la richiederebbe MAI più, nemmeno dopo +// che il drain loop l'ha imparata. La catena imparava e non se ne accorgeva. +static std::mutex g_pendingMutex; +static std::unordered_map g_pendingRequests; + +// Dopo quanto una richiesta senza risposta può essere rifatta. Va tenuto sopra +// il giro del drain loop lato app (~3s) e sotto la soglia della pazienza umana. +static constexpr uint64_t kPendingTtlMs = 10000; static TranslatorStats g_stats; static LogCallback g_logCallback = nullptr; @@ -21,6 +37,17 @@ bool InitializeTranslator(const TranslatorConfig& config) { } } + // Le risposte IPC arrivano dal receive thread: dritte in cache, mai un + // blocco sul thread che disegna. La cache ha il suo mutex interno. + IPC::SetTranslationArrivedCallback([](const std::wstring& original, + const std::wstring& translated) { + if (!translated.empty()) { + GetGlobalCache().Put(original, translated); + } + std::lock_guard lock(g_pendingMutex); + g_pendingRequests.erase(original); + }); + g_initialized = true; return true; } @@ -52,28 +79,29 @@ std::wstring Translate(const std::wstring& originalText) { g_stats.cacheMisses++; - // Se connesso a GameStringer, chiedi traduzione + // Se connesso a GameStringer, chiedi la traduzione SENZA bloccare: + // fire-and-forget, la risposta arriva dal receive thread e finisce in + // cache (vedi il callback in InitializeTranslator). Questo gira dentro + // gli hook di rendering: un'attesa qui congelerebbe il gioco. if (IPC::IsConnected()) { - uint64_t startTime = Utils::GetTimestampMs(); - - uint32_t requestId = IPC::SendTranslateRequest(originalText); - if (requestId > 0) { - if (IPC::ReceiveTranslateResponse(requestId, translated, 2000)) { - // Aggiorna latenza media - uint64_t latency = Utils::GetTimestampMs() - startTime; - g_stats.averageLatencyMs = (g_stats.averageLatencyMs + latency) / 2; - - // Salva in cache - GetGlobalCache().Put(originalText, translated); - - return translated; + const uint64_t now = Utils::GetTimestampMs(); + std::lock_guard lock(g_pendingMutex); + + auto it = g_pendingRequests.find(originalText); + const bool ask = (it == g_pendingRequests.end()) || (now - it->second >= kPendingTtlMs); + + if (ask) { + if (IPC::SendTranslateRequest(originalText) != 0) { + g_pendingRequests[originalText] = now; // riparte il TTL + } else { + g_pendingRequests.erase(originalText); // riprova al prossimo draw + g_stats.translationErrors++; } } - - g_stats.translationErrors++; } - - // Fallback: ritorna originale + + // Il primo draw mostra l'originale; dal prossimo, se la risposta è + // arrivata, la cache fa hit. return originalText; }