Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions docs/METODI-DI-TRADUZIONE.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,86 @@ nulla — quel gioco non la spedisce — ma non è il caso generale.

---

## Traduzione in tempo reale (IPC)

### La Named Pipe costa ~19us a stringa, e non è sul percorso caldo

Il round trip su Named Pipe è ~20x più lento della shared memory, e non
importa: la DLL chiama l'IPC solo quando **non** ha già la stringa in cache,
cioè la prima volta che la vede. In regime la traduzione in-game non fa IPC.

**Come è stato misurato.** Stesso carico sui due trasporti — lookup in
dizionario di 16 stringhe di gioco realistiche (da `New Game` a una riga di
dialogo da 120 caratteri), una richiesta alla volta, 3000 iterazioni dopo 300 di
warmup, latenza end-to-end lato chiamante:

```text
cargo test --release --lib ipc_bench -- --nocapture --test-threads=1
```

| trasporto | p50 | p95 | p99 | max |
|---|---|---|---|---|
| Named Pipe | 9.2us | 18.9us | 30.4us | 68.2us |
| shared memory | 0.4us | 0.5us | 0.8us | 22.2us |

Due esecuzioni indipendenti sono rientrate entro il 5% su ogni percentile.

Spendendo il 10% di un frame a 60fps (1667us): ~88 stringhe/frame sulla pipe,
~3333 sulla shared memory. In debug il divario è ancora più netto (37us contro
1.0us di p50) perché la shmem beneficia dell'ottimizzazione, la pipe è
dominata dalle syscall.

**Perché non conta.** `GSTranslator::Translate()` in
`unreal-translator/hook-dll/src/translator.cpp:46` cerca prima nella cache locale
del processo, e solo su miss chiama `IPC::SendTranslateRequest`. La cache è
persistita su disco tra le sessioni (`LoadCache`/`SaveCache`), e `source_gdi.cpp`
fa dedup per riga. Quindi 88 stringhe/frame non è il budget di rendering: è il
budget di stringhe **mai viste prima**, che dopo i primi secondi di gioco tende a
zero.

**La trappola.** Sembra una scelta di architettura da fare col cronometro — pipe
o shared memory. Non lo è: con una cache davanti, il trasporto è irrilevante
per la frequenza di frame, e vince quello che è finito, non quello che è
veloce. Vedi lo stato dei due sotto.

**Stato reale dei trasporti** (misurato il 21 agosto 2026):

| 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 `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`) |

Nessun percorso richiesta/risposta è completo su entrambi i lati. L'unica cosa
che funziona end-to-end è l'overlay, che è unidirezionale e non ha bisogno di
round trip.

**I due nomi di pipe non sono un disallineamento**, per quanto si somiglino: sono
due canali distinti, ciascuno col suo client. Verificato leggendo le stringhe
UTF-16 dentro le DLL precompilate che il repo spedisce:

| DLL | nome incorporato | iniettata da |
|---|---|---|
| `resources/gs-hook/{x64,x86}/gs-hook.dll` | `GameStringerOverlay` + `GameStringerTranslator` | `gs_hook_injector.rs` |
| `resources/unity-translator/unity_auto_translator.dll` | `GameStringerUETranslator` | `unity_injector.rs` |

```bash
python -c "import io;b=io.open('src-tauri/resources/gs-hook/x64/gs-hook.dll','rb').read();print([n for n in ['GameStringerTranslator','GameStringerUETranslator'] if n.encode('utf-16-le') in b])"
```

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.

**La trappola.** Due nomi che differiscono di due lettere sembrano un refuso da
sistemare. Prima di allinearli, leggi cosa c'è dentro i binari: qui erano due
canali sani, e l'unico difetto vero era il server che manca a entrambi.

---

## Come si aggiunge una voce

Quando trovi un modo nuovo di estrarre o reiniettare testo, o capisci perché un
Expand Down
66 changes: 61 additions & 5 deletions lib/translation-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,40 @@ export interface TranslationPair {
*/
export class TranslationBridgeClient {
private isConnected: boolean = false;
private maxRetries: number = 3;
private retryDelayMs: number = 500;

/**
* Retry wrapper: retries a Tauri invoke call only on transient failures.
* Non-transient errors (validation, not found, etc.) are thrown immediately.
*/
private async withRetry<T>(fn: () => Promise<T>, retries = this.maxRetries): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
// Only retry on transient errors (timeouts, IPC failures, network issues).
// Check structured error properties first, fall back to string matching.
const err = error as Record<string, unknown>;
const isTransient =
err?.code === 'ETIMEDOUT' || err?.code === 'ECONNRESET'
|| err?.code === 'ECONNREFUSED' || err?.code === 'ERR_IPC_CHANNEL_CLOSED'
|| (() => {
const msg = String(error).toLowerCase();
return msg.includes('timeout') || msg.includes('ipc')
|| msg.includes('connection') || msg.includes('unavailable')
|| msg.includes('channel closed');
})();
if (!isTransient || attempt >= retries) {
throw error;
}
await new Promise(r => setTimeout(r, this.retryDelayMs * (attempt + 1)));
}
}
throw lastError;
}

/**
* Start the Translation Bridge server
Expand Down Expand Up @@ -92,7 +126,9 @@ export class TranslationBridgeClient {
*/
async getStats(): Promise<BridgeStats | null> {
try {
const response = await invoke<BridgeResponse<BridgeStats>>('translation_bridge_stats');
const response = await this.withRetry(() =>
invoke<BridgeResponse<BridgeStats>>('translation_bridge_stats')
);
return response.data;
} catch (error: unknown) {
clientLogger.error(`[TranslationBridge] Failed to get stats: ${String(error)}`);
Expand All @@ -105,7 +141,9 @@ export class TranslationBridgeClient {
*/
async getDictionaryStats(): Promise<DictionaryStats | null> {
try {
const response = await invoke<BridgeResponse<DictionaryStats>>('translation_bridge_dictionary_stats');
const response = await this.withRetry(() =>
invoke<BridgeResponse<DictionaryStats>>('translation_bridge_dictionary_stats')
);
return response.data;
} catch (error: unknown) {
clientLogger.error(`[TranslationBridge] Failed to get dictionary stats: ${String(error)}`);
Expand Down Expand Up @@ -188,9 +226,11 @@ export class TranslationBridgeClient {
*/
async getTranslation(text: string): Promise<string | null> {
try {
const response = await invoke<BridgeResponse<string | null>>('translation_bridge_get_translation', {
text,
});
const response = await this.withRetry(() =>
invoke<BridgeResponse<string | null>>('translation_bridge_get_translation', {
text,
})
);
return response.data;
} catch (error: unknown) {
clientLogger.error(`[TranslationBridge] Failed to get translation: ${String(error)}`);
Expand All @@ -213,6 +253,22 @@ export class TranslationBridgeClient {
}
}

/**
* Drain cache misses (untranslated texts) for AI fallback.
* Returns up to `max` unique texts that were not found in the dictionary.
*/
async drainMisses(max: number = 100): Promise<string[]> {
try {
const response = await invoke<BridgeResponse<string[]>>('translation_bridge_drain_misses', {
max,
});
return response.data ?? [];
} catch (error: unknown) {
clientLogger.error(`[TranslationBridge] Failed to drain misses: ${String(error)}`);
return [];
}
}

/**
* Clear all dictionaries
*/
Expand Down
76 changes: 51 additions & 25 deletions src-tauri/src/commands/translation_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,30 @@ use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use tauri::State;

use parking_lot::RwLock;
use crate::translation_bridge::TranslationBridge;
use crate::translation_bridge::shared_memory_ipc::BridgeStats;
use crate::translation_bridge::dictionary_engine::DictionaryStats;
use crate::translation_bridge::dictionary_engine::{DictionaryEngine, DictionaryStats};

/// Stato globale del Translation Bridge
/// Stato globale del Translation Bridge.
/// `dictionary` e `miss_receiver` sono esposti direttamente per evitare double-locking:
/// le operazioni sul dizionario usano `RwLock` e il drain dei miss usa il proprio Mutex,
/// entrambi senza passare dal `Mutex<TranslationBridge>`.
pub struct TranslationBridgeState {
pub bridge: Arc<Mutex<TranslationBridge>>,
pub dictionary: Arc<RwLock<DictionaryEngine>>,
pub miss_receiver: Arc<parking_lot::Mutex<std::sync::mpsc::Receiver<String>>>,
}

impl TranslationBridgeState {
pub fn new() -> Self {
let bridge = TranslationBridge::new();
let dictionary = Arc::clone(bridge.dictionary());
let miss_receiver = Arc::clone(bridge.miss_receiver());
Self {
bridge: Arc::new(Mutex::new(TranslationBridge::new())),
bridge: Arc::new(Mutex::new(bridge)),
dictionary,
miss_receiver,
}
}
}
Expand Down Expand Up @@ -102,8 +113,7 @@ pub async fn translation_bridge_stats(
pub async fn translation_bridge_dictionary_stats(
state: State<'_, TranslationBridgeState>,
) -> Result<BridgeResponse<DictionaryStats>, String> {
let bridge = state.bridge.lock();
let dict = bridge.dictionary().read();
let dict = state.dictionary.read();
Ok(BridgeResponse::ok(dict.get_stats()))
}

Expand All @@ -127,14 +137,13 @@ pub async fn translation_bridge_load_translations(
state: State<'_, TranslationBridgeState>,
params: LoadTranslationsParams,
) -> Result<BridgeResponse<usize>, String> {
let bridge = state.bridge.lock();

let translations: Vec<(String, String)> = params.translations
.into_iter()
.map(|p| (p.original, p.translated))
.collect();

let count = bridge.load_dictionary(&params.source_lang, &params.target_lang, translations);

let mut dict = state.dictionary.write();
let count = dict.load_translations(&params.source_lang, &params.target_lang, translations);
Ok(BridgeResponse::ok(count))
}

Expand All @@ -144,9 +153,8 @@ pub async fn translation_bridge_load_json(
state: State<'_, TranslationBridgeState>,
path: String,
) -> Result<BridgeResponse<usize>, String> {
let bridge = state.bridge.lock();

match bridge.load_dictionary_from_json(&path) {
let mut dict = state.dictionary.write();
match dict.load_from_json(&path) {
Ok(count) => Ok(BridgeResponse::ok(count)),
Err(e) => Ok(BridgeResponse::err(e)),
}
Expand All @@ -159,8 +167,7 @@ pub async fn translation_bridge_set_languages(
source: String,
target: String,
) -> Result<BridgeResponse<String>, String> {
let bridge = state.bridge.lock();
let mut dict = bridge.dictionary().write();
let mut dict = state.dictionary.write();
dict.set_active_languages(&source, &target);
Ok(BridgeResponse::ok(format!("Lingue attive: {} -> {}", source, target)))
}
Expand All @@ -172,8 +179,7 @@ pub async fn translation_bridge_add_translation(
original: String,
translated: String,
) -> Result<BridgeResponse<String>, String> {
let bridge = state.bridge.lock();
let mut dict = bridge.dictionary().write();
let mut dict = state.dictionary.write();
dict.add_translation(original.clone(), translated);
Ok(BridgeResponse::ok(format!("Aggiunta traduzione: {}", original)))
}
Expand All @@ -184,12 +190,9 @@ pub async fn translation_bridge_get_translation(
state: State<'_, TranslationBridgeState>,
text: String,
) -> Result<BridgeResponse<Option<String>>, String> {
let bridge = state.bridge.lock();
let dict = bridge.dictionary().read();

let dict = state.dictionary.read();
let hash = crate::translation_bridge::protocol::TranslationRequest::compute_hash(&text);
let result = dict.get_translation(hash, &text);

Ok(BridgeResponse::ok(result))
}

Expand All @@ -199,9 +202,7 @@ pub async fn translation_bridge_export_json(
state: State<'_, TranslationBridgeState>,
path: String,
) -> Result<BridgeResponse<String>, String> {
let bridge = state.bridge.lock();
let dict = bridge.dictionary().read();

let dict = state.dictionary.read();
match dict.export_to_json(&path) {
Ok(_) => Ok(BridgeResponse::ok(format!("Esportato in {}", path))),
Err(e) => Ok(BridgeResponse::err(e)),
Expand All @@ -213,8 +214,33 @@ pub async fn translation_bridge_export_json(
pub async fn translation_bridge_clear(
state: State<'_, TranslationBridgeState>,
) -> Result<BridgeResponse<String>, String> {
let bridge = state.bridge.lock();
let mut dict = bridge.dictionary().write();
let mut dict = state.dictionary.write();
dict.clear_all();
Ok(BridgeResponse::ok("Dizionari puliti".to_string()))
}

/// Drena i cache miss (testi non tradotti) per AI fallback.
/// Il frontend può usare questi testi per chiamare l'API di traduzione e
/// poi reinserirli nel dizionario con `translation_bridge_add_translation`.
/// Usa il receiver diretto (non passa dal Mutex<TranslationBridge>).
#[tauri::command]
pub async fn translation_bridge_drain_misses(
state: State<'_, TranslationBridgeState>,
max: Option<usize>,
) -> Result<BridgeResponse<Vec<String>>, String> {
let max = max.unwrap_or(100);
let receiver = state.miss_receiver.lock();
let mut texts = Vec::new();
let mut seen = std::collections::HashSet::new();
while texts.len() < max {
match receiver.try_recv() {
Ok(text) => {
if seen.insert(text.clone()) {
texts.push(text);
}
}
Err(_) => break,
}
}
Ok(BridgeResponse::ok(texts))
}
Loading