From 9b5bfa546737710b3dd48343c6bbff1e9e0e918b Mon Sep 17 00:00:00 2001 From: rouges78 Date: Fri, 21 Aug 2026 12:05:45 +0200 Subject: [PATCH 1/6] Give the GameStringerTranslator pipe its missing server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The measurement in #84 settled the transport question: finish the pipe, not the shared memory. This is the Rust half — the first link of the one-button chain. translator_pipe.rs serves the wire format the shipped DLLs already embed (hook-dll/src/ipc.cpp): message-mode pipe, 12-byte header {type, requestId, dataLength}, UTF-16LE payload, TRANSLATE_REQUEST in, TRANSLATE_RESPONSE out. The format is dictated by the prebuilt binary, so the tests speak it through a fake DLL client: hit roundtrip, non-BMP text, miss handling, unknown message types, reconnection. It answers from the Translation Bridge's DictionaryEngine and pushes misses into the same mpsc queue translation_bridge_drain_misses drains — one dictionary, one AI-fallback queue, two transports. A per- connection dedup set keeps a string asked every frame from flooding the queue. On a miss the server stays silent by design: the DLL owns the timeout, and answering with the original would poison its local cache permanently (it persists whatever response arrives). What this does not yet close, recorded in the log: gs-hook's dllmain never calls IPC::Initialize(), so the client half is compiled into the shipped DLLs but never switched on. That is the next link — a C++ touch plus a rebuild via the existing build-gs-hook job — and before enabling it, Translate()'s 2-second blocking wait on miss has to become non-blocking, because today it would stall the thread that draws. Co-Authored-By: Claude Opus 5 --- docs/METODI-DI-TRADUZIONE.md | 24 +- src-tauri/src/commands/translation_bridge.rs | 3 + src-tauri/src/lib.rs | 1 + src-tauri/src/main.rs | 15 + .../translation_bridge/shared_memory_ipc.rs | 6 + src-tauri/src/translator_pipe.rs | 344 ++++++++++++++++++ 6 files changed, 388 insertions(+), 5 deletions(-) create mode 100644 src-tauri/src/translator_pipe.rs diff --git a/docs/METODI-DI-TRADUZIONE.md b/docs/METODI-DI-TRADUZIONE.md index 9051f494..a28f67a7 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,10 +234,24 @@ 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`). L'anello ancora mancante è lato C++: +il dllmain di gs-hook non chiama `IPC::Initialize()`/`StartReceiveThread()`, +quindi serve un ritocco a `gs-hook/src/dllmain.cpp` e la ricompilazione delle +DLL in `src-tauri/resources/gs-hook/` (il job CI `build-gs-hook` esiste già). +Attenzione al punto caldo: `Translate()` su miss blocca fino a 2s +(`ReceiveTranslateResponse(..., 2000)`) sul thread che disegna — prima di +accendere l'IPC in gs-hook, quel percorso va reso non bloccante. **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 diff --git a/src-tauri/src/commands/translation_bridge.rs b/src-tauri/src/commands/translation_bridge.rs index f1245e89..e44d1f8b 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, } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2f2f0f66..f2419cda 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 7fa49c43..b4b3da3e 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; @@ -1263,6 +1264,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/shared_memory_ipc.rs b/src-tauri/src/translation_bridge/shared_memory_ipc.rs index 49f03f33..74933db3 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 00000000..2021552f --- /dev/null +++ b/src-tauri/src/translator_pipe.rs @@ -0,0 +1,344 @@ +//! 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); + } + }); +} + +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(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"); + } +} From 1f78107b809af4dd8e304895753b7d711f59ecec Mon Sep 17 00:00:00 2001 From: rouges78 Date: Fri, 21 Aug 2026 12:40:29 +0200 Subject: [PATCH 2/6] Wake up gs-hook's IPC client, and fix the deadlock that surfaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second link of the chain: gs-hook's dllmain never called IPC::Initialize, so the client half compiled into the shipped DLLs was dormant. Turning it on exposed two real bugs, both found by injecting into the GDI testapp rather than by reading the code. 1. Translate() blocked up to 2s on every cache miss, on the thread that draws. Now the miss path is fire-and-forget with a dedup set: the request goes out, the draw returns the original immediately, and the response lands in the cache via a receive-thread callback. Next frame the same string is a hit. ipc.h gains SetTranslationArrivedCallback and ipc.cpp tracks requestId -> original, since the wire protocol carries only the id. 2. The pipe handle was opened without FILE_FLAG_OVERLAPPED. On a synchronous handle the kernel serializes I/O per file object, so with the receive thread parked in ReadFile, the render thread's WriteFile queued behind it forever — and that read could only complete once the request it was blocking had arrived. Circular wait: the game froze on the first miss, and the server logged a connection and zero requests. Now the handle is overlapped and a dedicated sender thread owns all writes, so the draw thread only ever touches a mutex and a condvar. Measured on the GDI testapp, injected for real: before UI responsive: False, 3 log lines, 0 requests server-side after UI responsive: True, capture intact, and server-side 3 hits translated + 2 misses queued for the AI fallback Shutdown order is now StopReceiveThread (cancels pending overlapped I/O, joins) then Shutdown (closes the handle) — closing it while a thread waits on an OVERLAPPED is a use-after-free. Co-Authored-By: Claude Opus 5 --- gs-hook/src/dllmain.cpp | 15 ++ src-tauri/examples/translator_pipe_server.rs | 90 +++++++ src-tauri/src/translator_pipe.rs | 12 +- unreal-translator/hook-dll/include/ipc.h | 7 + unreal-translator/hook-dll/src/ipc.cpp | 254 ++++++++++++++---- unreal-translator/hook-dll/src/translator.cpp | 45 ++-- 6 files changed, 352 insertions(+), 71 deletions(-) create mode 100644 src-tauri/examples/translator_pipe_server.rs diff --git a/gs-hook/src/dllmain.cpp b/gs-hook/src/dllmain.cpp index 086e83fd..7b04f8cc 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/src-tauri/examples/translator_pipe_server.rs b/src-tauri/examples/translator_pipe_server.rs new file mode 100644 index 00000000..cba977b2 --- /dev/null +++ b/src-tauri/examples/translator_pipe_server.rs @@ -0,0 +1,90 @@ +//! 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::(); + + std::thread::spawn(move || { + for text in miss_rx { + println!("MISS → \"{}\" (in coda per l'AI fallback)", text); + } + }); + + // 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/translator_pipe.rs b/src-tauri/src/translator_pipe.rs index 2021552f..8e6c8b49 100644 --- a/src-tauri/src/translator_pipe.rs +++ b/src-tauri/src/translator_pipe.rs @@ -63,7 +63,9 @@ fn start_on( }); } -async fn serve( +/// 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, @@ -142,6 +144,14 @@ async fn handle_connection( 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 diff --git a/unreal-translator/hook-dll/include/ipc.h b/unreal-translator/hook-dll/include/ipc.h index 6672e810..524b6dcb 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 e1372dcc..1f333f4d 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 7bc663ab..c412c44c 100644 --- a/unreal-translator/hook-dll/src/translator.cpp +++ b/unreal-translator/hook-dll/src/translator.cpp @@ -3,11 +3,17 @@ #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 e in attesa di risposta: evita di reinviare la +// stessa richiesta a ogni draw call mentre l'AI fallback lavora. +static std::mutex g_pendingMutex; +static std::unordered_set g_pendingRequests; static TranslatorStats g_stats; static LogCallback g_logCallback = nullptr; @@ -21,6 +27,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 +69,22 @@ 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; + std::lock_guard lock(g_pendingMutex); + if (g_pendingRequests.insert(originalText).second) { + if (IPC::SendTranslateRequest(originalText) == 0) { + g_pendingRequests.erase(originalText); + 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; } From a53ece53688ef23d03fc1d83476497622d13ca52 Mon Sep 17 00:00:00 2001 From: rouges78 Date: Fri, 21 Aug 2026 12:41:20 +0200 Subject: [PATCH 3/6] Record the overlapped-pipe trap in the translation log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The freeze looked obviously like the 2-second timeout on the miss path, and that was the wrong lead — the timeout never even fired, because the request never left. What resolved it was bisecting by experiment rather than by reading: June DLL (capture OK), HEAD rebuilt (capture OK), my changes with the server off (capture OK), my changes with the server on (freeze). The last step pins it to the connected branch in minutes. Entry carries the measurement table from both sides, the SendMessageTimeout probe that separates "slow" from "deadlocked", the shutdown ordering, and the trap itself. Co-Authored-By: Claude Opus 5 --- docs/METODI-DI-TRADUZIONE.md | 46 ++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/docs/METODI-DI-TRADUZIONE.md b/docs/METODI-DI-TRADUZIONE.md index a28f67a7..d273b443 100644 --- a/docs/METODI-DI-TRADUZIONE.md +++ b/docs/METODI-DI-TRADUZIONE.md @@ -245,13 +245,45 @@ 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`). L'anello ancora mancante è lato C++: -il dllmain di gs-hook non chiama `IPC::Initialize()`/`StartReceiveThread()`, -quindi serve un ritocco a `gs-hook/src/dllmain.cpp` e la ricompilazione delle -DLL in `src-tauri/resources/gs-hook/` (il job CI `build-gs-hook` esiste già). -Attenzione al punto caldo: `Translate()` su miss blocca fino a 2s -(`ReceiveTranslateResponse(..., 2000)`) sul thread che disegna — prima di -accendere l'IPC in gs-hook, quel percorso va reso non bloccante. +(`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 From fa186f229337c5b1be9efa8247acd3ce5f31aa45 Mon Sep 17 00:00:00 2001 From: rouges78 Date: Fri, 21 Aug 2026 12:56:56 +0200 Subject: [PATCH 4/6] Close the loop: drain misses, translate, feed the dictionary back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third link. The pipe server queues every string the DLL asks for and the dictionary does not know; this drains that queue, translates with the app's AI stack, and puts the result back — so the second sighting of a string is a hit served in microseconds instead of another provider call. lib/translation-bridge-drain.ts holds the logic with its dependencies injected, so the interesting parts are testable without React or Tauri: a per-session translation cap (a game full of new strings is an open tap on the provider), periodic persistence, and the rule that an empty translation or one identical to the original is never stored — that would turn a miss into a permanently wrong hit and the string would never be retried. 14 tests cover those edges plus the timer loop. save_to_dir/load_from_dir were dead since Injekt was archived; they are now Tauri commands, so what the loop learns survives a restart. The overlay page also feeds its own translations back, which it never did — it was re-translating the same line on every appearance. One more defect surfaced on the way, and it was mine: the DLL's dedup set was only cleared by the response callback, but on a miss the server stays silent by design, so a string stayed "pending" forever and was never asked about again — the chain learned and could not tell. The pending map now carries a timestamp and a 10s TTL, above the drain loop's ~3s round. Measured end to end, injected into the GDI testapp with a stub provider: miss "The dragon roars from the mountain." learn "[IT] The dragon roars from the mountain." hit "The dragon roars from the mountain." -> "[IT] The dragon..." That third line is the one that proves anything, and without the TTL it never appears. Co-Authored-By: Claude Opus 5 --- .../lib/translation-bridge-drain.test.ts | 260 ++++++++++++++++++ app/gs-overlay/page.tsx | 14 + docs/METODI-DI-TRADUZIONE.md | 31 +++ hooks/use-translation-bridge-drain.ts | 116 ++++++++ lib/translation-bridge-drain.ts | 204 ++++++++++++++ lib/translation-bridge.ts | 33 +++ src-tauri/examples/translator_pipe_server.rs | 10 +- src-tauri/src/commands/platform_stubs.rs | 10 + src-tauri/src/commands/translation_bridge.rs | 29 ++ src-tauri/src/main.rs | 2 + .../translation_bridge/dictionary_engine.rs | 2 - unreal-translator/hook-dll/src/translator.cpp | 31 ++- 12 files changed, 732 insertions(+), 10 deletions(-) create mode 100644 __tests__/lib/translation-bridge-drain.test.ts create mode 100644 hooks/use-translation-bridge-drain.ts create mode 100644 lib/translation-bridge-drain.ts diff --git a/__tests__/lib/translation-bridge-drain.test.ts b/__tests__/lib/translation-bridge-drain.test.ts new file mode 100644 index 00000000..18aa6f57 --- /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 d4ab12fe..dbcb241d 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/docs/METODI-DI-TRADUZIONE.md b/docs/METODI-DI-TRADUZIONE.md index d273b443..cdcad5f0 100644 --- a/docs/METODI-DI-TRADUZIONE.md +++ b/docs/METODI-DI-TRADUZIONE.md @@ -289,6 +289,37 @@ isola il colpevole al ramo "IPC connessa" in tre minuti. 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/hooks/use-translation-bridge-drain.ts b/hooks/use-translation-bridge-drain.ts new file mode 100644 index 00000000..38c2a05d --- /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/translation-bridge-drain.ts b/lib/translation-bridge-drain.ts new file mode 100644 index 00000000..bdd561c9 --- /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 0b6c22ab..9b70a556 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/src-tauri/examples/translator_pipe_server.rs b/src-tauri/examples/translator_pipe_server.rs index cba977b2..30e8e08c 100644 --- a/src-tauri/examples/translator_pipe_server.rs +++ b/src-tauri/examples/translator_pipe_server.rs @@ -45,9 +45,17 @@ fn main() { 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 { - println!("MISS → \"{}\" (in coda per l'AI fallback)", text); + let translated = format!("[IT] {}", text); + learner + .write() + .add_translation(text.clone(), translated.clone()); + println!("IMPARATA → \"{}\" = \"{}\"", text, translated); } }); diff --git a/src-tauri/src/commands/platform_stubs.rs b/src-tauri/src/commands/platform_stubs.rs index 7cd30821..c4eb7ab7 100644 --- a/src-tauri/src/commands/platform_stubs.rs +++ b/src-tauri/src/commands/platform_stubs.rs @@ -250,6 +250,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 e44d1f8b..4b95eafb 100644 --- a/src-tauri/src/commands/translation_bridge.rs +++ b/src-tauri/src/commands/translation_bridge.rs @@ -247,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/main.rs b/src-tauri/src/main.rs index b4b3da3e..815548e6 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -778,6 +778,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, diff --git a/src-tauri/src/translation_bridge/dictionary_engine.rs b/src-tauri/src/translation_bridge/dictionary_engine.rs index 41b0c1d5..c4850567 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/unreal-translator/hook-dll/src/translator.cpp b/unreal-translator/hook-dll/src/translator.cpp index c412c44c..8d7942e4 100644 --- a/unreal-translator/hook-dll/src/translator.cpp +++ b/unreal-translator/hook-dll/src/translator.cpp @@ -4,16 +4,26 @@ #include "utils.h" #include #include -#include +#include namespace GSTranslator { static TranslatorConfig g_config; static std::atomic g_initialized(false); -// Testi già richiesti via IPC e in attesa di risposta: evita di reinviare la +// 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_set g_pendingRequests; +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; @@ -74,15 +84,22 @@ std::wstring Translate(const std::wstring& originalText) { // cache (vedi il callback in InitializeTranslator). Questo gira dentro // gli hook di rendering: un'attesa qui congelerebbe il gioco. if (IPC::IsConnected()) { + const uint64_t now = Utils::GetTimestampMs(); std::lock_guard lock(g_pendingMutex); - if (g_pendingRequests.insert(originalText).second) { - if (IPC::SendTranslateRequest(originalText) == 0) { - g_pendingRequests.erase(originalText); + + 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++; } } } - + // Il primo draw mostra l'originale; dal prossimo, se la risposta è // arrivata, la cache fa hit. return originalText; From ee98b4a9989c604c74db3512109c5e50246fc499 Mon Sep 17 00:00:00 2001 From: rouges78 Date: Fri, 21 Aug 2026 13:10:02 +0200 Subject: [PATCH 5/6] Fall back to runtime translation when the files do not budge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth link. A game that resists static extraction is not untranslatable — gs-hook plus the pipe and the drain loop already work. What was missing is the decision to switch, and it lived nowhere. lib/translation/runtime-fallback.ts holds it, next to patch-outcome.ts and for the same reason: decidePatchOutcome says how it went, this says what to do now, and neither belongs inside a 4300-line component where it cannot be tested. 17 tests pin the ordering that matters — structural blockers (platform, missing DLLs, anti-cheat) are reported before the contingent one, because "launch the game" is an invitation to act and giving it when the act would be refused anyway is worse than silence. A partial static success is left alone: the game was modified, and layering runtime on top would show two translations of the same line. The plan needs to know whether the game is running before trying, so gs_hook_status reports DLL availability and process liveness. Without it the only possible answer to a closed game would be a failed injection, when it is really a prompt. Wired at both points where the static path leaves the game untouched: the "nothing extractable" branch, which until now ended at an error message, and the failure verdict. Both record a per-game report into the activity history saying what entered the game and which path tried — not how many stages went green, which is the lie patch-outcome.ts was written to close. New strings land in it and en; the other ten locales fall back to English by design (lib/i18n/index.tsx). Co-Authored-By: Claude Opus 5 --- __tests__/lib/runtime-fallback.test.ts | 171 ++++++++++++++++++++ components/game-detail-client.tsx | 113 +++++++++++++- lib/i18n/locales/en.json | 12 +- lib/i18n/locales/it.json | 12 +- lib/translation/runtime-fallback.ts | 173 +++++++++++++++++++++ src-tauri/src/commands/gs_hook_injector.rs | 31 ++++ src-tauri/src/commands/platform_stubs.rs | 13 ++ src-tauri/src/main.rs | 1 + 8 files changed, 523 insertions(+), 3 deletions(-) create mode 100644 __tests__/lib/runtime-fallback.test.ts create mode 100644 lib/translation/runtime-fallback.ts diff --git a/__tests__/lib/runtime-fallback.test.ts b/__tests__/lib/runtime-fallback.test.ts new file mode 100644 index 00000000..cccbd07f --- /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/components/game-detail-client.tsx b/components/game-detail-client.tsx index 52785530..8c50d535 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/lib/i18n/locales/en.json b/lib/i18n/locales/en.json index 18e85fee..ede99c4a 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/it.json b/lib/i18n/locales/it.json index 917fef2b..c8cefd6e 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 partira' a schermo mentre giochi.", + "runtimeFallbackBlocked": { + "not-windows": "La traduzione a runtime e' 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, ne' nei file ne' 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/translation/runtime-fallback.ts b/lib/translation/runtime-fallback.ts new file mode 100644 index 00000000..8757243d --- /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/src/commands/gs_hook_injector.rs b/src-tauri/src/commands/gs_hook_injector.rs index 968581a6..29a9d538 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 c4eb7ab7..cc4b5812 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 // ═══════════════════════════════════════════════════════════════════ diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 815548e6..8e82e11a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -713,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, From 5b151380121aaa9412a77d76e80f484e13b92834 Mon Sep 17 00:00:00 2001 From: rouges78 Date: Fri, 21 Aug 2026 13:22:03 +0200 Subject: [PATCH 6/6] Translate the runtime-fallback strings into every locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught what I did not: this repo enforces key parity across all eleven locales, and __tests__/lib/i18n-locale-integrity.test.ts fails on any key present in it.json and missing elsewhere. I had added the new keys to it and en only, reasoning that lib/i18n/index.tsx falls back to English — true at runtime, but the gate exists precisely because silent degradation is what issue #47 was about. That same test also counts values copied verbatim from it.json as regressions, so pasting Italian or English into the other nine would have traded one failure for another. They are translated. Italian accents are fixed too: the placeholders I typed to dodge shell escaping had shipped as "e'", "partira'", "ne'". Full suite now, not just the new files: 891 passed, 0 failed. Co-Authored-By: Claude Opus 5 --- lib/i18n/locales/de.json | 12 +++++++++++- lib/i18n/locales/es.json | 12 +++++++++++- lib/i18n/locales/fr.json | 12 +++++++++++- lib/i18n/locales/it.json | 6 +++--- lib/i18n/locales/ja.json | 12 +++++++++++- lib/i18n/locales/ko.json | 12 +++++++++++- lib/i18n/locales/pl.json | 12 +++++++++++- lib/i18n/locales/pt.json | 12 +++++++++++- lib/i18n/locales/ru.json | 12 +++++++++++- lib/i18n/locales/zh.json | 12 +++++++++++- 10 files changed, 102 insertions(+), 12 deletions(-) diff --git a/lib/i18n/locales/de.json b/lib/i18n/locales/de.json index cb6619e4..aa965467 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/es.json b/lib/i18n/locales/es.json index a6576424..620533a1 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 1ce1f973..41246424 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 c8cefd6e..430afa5c 100644 --- a/lib/i18n/locales/it.json +++ b/lib/i18n/locales/it.json @@ -952,11 +952,11 @@ "unityIl2cppDetail": "Unity IL2CPP — i testi sono compilati nei binari: si traducono mentre giochi, con 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 partira' a schermo mentre giochi.", + "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 e' disponibile solo su Windows.", + "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, ne' nei file ne' in memoria.", + "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.", diff --git a/lib/i18n/locales/ja.json b/lib/i18n/locales/ja.json index de6d0cb9..a54b0156 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 628958e7..a7898354 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 df8f181c..097d5ccc 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 2e5b0405..39acb11a 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 6892ce11..f72f163d 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 cc73d998..b3f4df27 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 信息时出错:",