diff --git a/docs/METODI-DI-TRADUZIONE.md b/docs/METODI-DI-TRADUZIONE.md index a27387ac..24815973 100644 --- a/docs/METODI-DI-TRADUZIONE.md +++ b/docs/METODI-DI-TRADUZIONE.md @@ -178,6 +178,102 @@ nulla — quel gioco non la spedisce — ma non è il caso generale. --- +## RPG Maker + +### RPG Maker 2000/2003 non veniva riconosciuto affatto + +**Il fatto.** GameStringer aveva un ramo dedicato che si presentava come +«RPG Maker classico (RPG_RT 2000/2003)» — con tanto di messaggio all'utente e, +da agosto 2026, l'aggancio al fallback a runtime. Quel ramo era +**irraggiungibile proprio per i giochi RPG_RT**: nessuno dei tre strati di +rilevamento sapeva riconoscerli. + +**Come è emerso.** Installando Yume Nikki (RPG Maker 2003, gratuito su Steam, +app 650700) per provare il flusso completo. Interrogando i comandi direttamente +con `cargo run --example probe_rpgmaker -- ""`: + +```text +detect_rpgmaker_game -> ERRORE: Non sembra essere un gioco RPG Maker +``` + +…su una cartella che contiene `RPG_RT.exe`, `RPG_RT.ldb`, `RPG_RT.lmt`, +`RPG_RT.ini` e 60+ `Map####.lmu`. + +**Perché.** Tre strati, tutti ciechi allo stesso modo: + +1. `engine_detector::is_rpg_maker()` cercava `www/data/System.json` (MV/MZ), + `Game.rpgproject` e `rgss*.dll` (XP/VX/VXAce). Nessun controllo su `RPG_RT.*`. +2. `RpgMakerVersion` non aveva una variante per 2000/2003: si ricadeva in + `Unknown`, e `detect_rpgmaker_game` restituiva errore. +3. Nel frontend quell'errore finiva in + `} catch { /* detect fallito → prosegui col workflow file-based normale */ }`, + quindi il ramo «classico» veniva saltato **in silenzio**. + +**La cura.** Variante `RpgMakerVersion::RT`, rilevamento sui file **dati** e +ricerca in profondità. Per RT `find_data_files` ritorna `Ok(vec![])`: zero file +dati **non è un errore**, è il fatto che manda questi giochi al runtime — se +tornasse `Err`, il rilevamento fallirebbe e il ramo resterebbe irraggiungibile +come prima. + +**Due accortezze, entrambe già costate altrove.** + +- **Mai cercare l'eseguibile.** Moltissimi giochi RPG_RT rinominano `RPG_RT.exe` + col titolo. `RPG_RT.ldb` e `RPG_RT.lmt` non si toccano mai: sono quelli il + marcatore. Una scansione della libreria che cerca `RPG_RT.exe` conclude «non + ne hai» anche quando ne hai. +- **La profondità va cercata.** Steam installa Yume Nikki in + `common/Yume Nikki/**yumenikki**/`: cercare solo nella radice non lo trova. È + identica alla trappola dei `Paks` di Unreal, in un altro punto del codice. + `find_rpg_rt_dir` cerca fino a 3 livelli e `detect_rpgmaker_game` **riporta la + cartella trovata**, non la radice, così chi legge il risultato non deve + rifare la ricerca. + +**Dopo.** Dalla radice d'installazione, cioè il percorso che passa l'app: + +```text +version: RT +path: …\Yume Nikki\yumenikki (risolto, non la radice) +title: Yume Nikki +data_files: [] estrazione: 0 stringhe +``` + +Che sono esattamente le condizioni del ramo classico (`isMvMz` falso, stringhe +`<= 0`), quindi ora scatta — e con esso il fallback a runtime della PR #87. + +**Anche l'eseguibile va cercato in profondità.** `find_executables_in_folder` +leggeva solo la radice: su `common/Yume Nikki` non trovava nulla, perché +`RPG_RT.exe` sta in `yumenikki/`. L'esito era peggiore di «non trovato»: chi +chiama ripiega sul nome del gioco (`YumeNikki.exe`), che non esiste, e poi cerca +un processo che non esisterà mai — il fallback direbbe «avvia il gioco» a gioco +già avviato. Ora, **se e solo se** la radice è vuota, guarda un livello sotto. +Con questa, la stessa lezione è comparsa in quattro punti indipendenti del +codice: i `Paks` di Unreal, il rilevatore RPG Maker, l'engine detector e la +ricerca dell'eseguibile. + +**Prova del flusso completo (21/08/2026).** Con Yume Nikki in esecuzione, premuto +«STRING IT!» nella scheda del gioco. L'app mostrava già la strategia corretta — +«RPG Maker · RT — 0 file, 0 stringhe» — e il flusso è arrivato in fondo: + +```text +[gs-hook] connesso a GameStringer via IPC +[gs-hook] dizionario pre-caricato: 0 voci da …\GameStringer\gs-hook-cache.gstc +[gs-hook] sorgente attiva: GDI (ExtTextOutW/DrawTextW) (livello 2) +[gs-hook] sorgente attiva: GDI/GetGlyphOutline (estrazione) (livello 2) +``` + +Tre conferme indipendenti: il log scritto nell'istante del clic; `RPG_RT.exe` è +a **32 bit**, quindi è stata iniettata la x86 e la selezione dual-arch ha +funzionato; ed esiste una finestra «GameStringer Overlay», che +`ensure_overlay_window()` crea **solo** dentro il ramo di successo +dell'iniezione. È la prima volta che il percorso dal pulsante al gioco viene +percorso per intero. + +**La trappola.** Un ramo con un messaggio scritto bene, un commento circostanziato +e persino dei test a valle sembra codice vivo. Questo era morto da sempre, e non +per un difetto suo: per una condizione a monte che non poteva essere vera. +Prima di credere che un percorso funzioni, va provato con un input che lo +imbocchi davvero — qui è bastato installare un gioco. + ## Traduzione in tempo reale (IPC) ### Stato della catena — aggiornato al 21/08/2026 diff --git a/src-tauri/examples/probe_rpgmaker.rs b/src-tauri/examples/probe_rpgmaker.rs new file mode 100644 index 00000000..b188686e --- /dev/null +++ b/src-tauri/examples/probe_rpgmaker.rs @@ -0,0 +1,38 @@ +//! Interroga il rilevatore RPG Maker su un percorso, senza avviare l'app. +//! +//! Il ramo «RPG Maker classico» di `startAutoTranslate` scatta quando +//! `detect_rpgmaker_game` NON dice mv/mz **e** `extract_all_rpgmaker_strings` +//! ritorna 0. Prima di lanciare l'intero flusso conviene sapere se quelle due +//! condizioni sono davvero soddisfatte, e su QUALE percorso: i file RPG_RT +//! possono stare in una sottocartella, non nella radice d'installazione. +//! +//! ```text +//! cargo run --example probe_rpgmaker -- "" +//! ``` + +fn main() { + let path = match std::env::args().nth(1) { + Some(p) => p, + None => { + eprintln!("uso: probe_rpgmaker "); + std::process::exit(2); + } + }; + + println!("percorso: {path}\n"); + + match gamestringer::commands::rpgmaker_patcher::detect_rpgmaker_game(path.clone()) { + Ok(game) => { + println!(" rilevato: {game:#?}"); + } + Err(e) => { + println!(" detect_rpgmaker_game -> ERRORE: {e}"); + return; + } + } + + match gamestringer::commands::rpgmaker_patcher::extract_all_rpgmaker_strings(path) { + Ok(res) => println!("\n estrazione: {} stringhe", res.total_count), + Err(e) => println!("\n extract_all_rpgmaker_strings -> ERRORE: {e}"), + } +} diff --git a/src-tauri/src/commands/games.rs b/src-tauri/src/commands/games.rs index ed53b7e3..54dbb3e6 100644 --- a/src-tauri/src/commands/games.rs +++ b/src-tauri/src/commands/games.rs @@ -1845,7 +1845,44 @@ pub async fn find_executables_in_folder(folder_path: String) -> Result = candidates.into_iter().map(|(_, _, n)| n).collect(); + let mut executables: Vec = candidates.into_iter().map(|(_, _, n)| n).collect(); + + // Se la radice non ha eseguibili, guarda UN livello più sotto: molte + // installazioni Steam mettono il gioco in una sottocartella (Yume + // Nikki sta in `common/Yume Nikki/yumenikki/RPG_RT.exe`). Senza + // questo, l'esito è peggiore di «non trovato»: chi chiama ripiega + // sul nome del gioco (`YumeNikki.exe`), che non esiste, e cerca poi + // un processo che non esisterà mai. + // + // Un livello solo, e solo quando la radice è vuota: non è una + // scansione del disco, è il caso concreto che si incontra. + if executables.is_empty() { + if let Ok(mut dirs) = tokio::fs::read_dir(&folder_path).await { + while let Ok(Some(dir)) = dirs.next_entry().await { + let sub = dir.path(); + if !sub.is_dir() { + continue; + } + if let Ok(mut inner) = tokio::fs::read_dir(&sub).await { + while let Ok(Some(e)) = inner.next_entry().await { + let p = e.path(); + if p.extension().map(|x| x.to_string_lossy().to_lowercase()) + == Some("exe".to_string()) + { + if let Some(n) = p.file_name() { + executables.push(n.to_string_lossy().to_string()); + } + } + } + } + if !executables.is_empty() { + log::info!("📁 Eseguibili trovati in sottocartella: {:?}", sub); + break; + } + } + } + } + log::info!("✅ Trovati {} eseguibili (migliore: {:?})", executables.len(), executables.first()); Ok(executables) } diff --git a/src-tauri/src/commands/rpgmaker_patcher.rs b/src-tauri/src/commands/rpgmaker_patcher.rs index cad7d853..7246bc17 100644 --- a/src-tauri/src/commands/rpgmaker_patcher.rs +++ b/src-tauri/src/commands/rpgmaker_patcher.rs @@ -15,6 +15,9 @@ use crate::commands::encoding_utils; #[derive(Debug, Clone, Serialize, Deserialize)] pub enum RpgMakerVersion { + /// 2000/2003 — RPG_RT.ldb/.lmt, formato binario proprietario. + /// Nessuna stringa estraibile dai file: la traduzione passa dal runtime. + RT, XP, // .rxdata (Ruby Marshal) VX, // .rvdata (Ruby Marshal) VXAce, // .rvdata2 (Ruby Marshal) @@ -103,6 +106,17 @@ pub fn detect_rpgmaker_game(game_path: String) -> Result { log::info!("🎮 Rilevato RPG Maker {:?}: {} ({} file dati)", version, title, data_files.len()); + // Per RT il percorso utile è la cartella che contiene davvero i dati, non + // la radice d'installazione: chi legge questo risultato deve poterci + // lavorare senza rifare la ricerca. + let game_path = if matches!(version, RpgMakerVersion::RT) { + find_rpg_rt_dir(path, RPG_RT_MAX_DEPTH) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or(game_path) + } else { + game_path + }; + Ok(RpgMakerGame { path: game_path, version, @@ -111,6 +125,39 @@ pub fn detect_rpgmaker_game(game_path: String) -> Result { }) } +/// Cartella che contiene davvero i file dati di RPG Maker 2000/2003. +/// +/// La profondità va cercata, mai assunta — è la stessa lezione dei `Paks` di +/// Unreal (vedi `find_all_paks_dirs`). Un'installazione Steam mette spesso il +/// gioco in una sottocartella: Yume Nikki sta in +/// `common/Yume Nikki/yumenikki/`, e cercare `RPG_RT.ldb` solo nella radice +/// non lo trova. +/// +/// Si guardano i file DATI e non l'eseguibile: moltissimi giochi rinominano +/// `RPG_RT.exe` col titolo, ma `.ldb` e `.lmt` restano quelli. +fn find_rpg_rt_dir(root: &Path, max_depth: usize) -> Option { + if root.join("RPG_RT.ldb").exists() || root.join("RPG_RT.lmt").exists() { + return Some(root.to_path_buf()); + } + if max_depth == 0 { + return None; + } + let entries = fs::read_dir(root).ok()?; + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + if let Some(found) = find_rpg_rt_dir(&p, max_depth - 1) { + return Some(found); + } + } + } + None +} + +/// Profondità massima di ricerca. Tre livelli coprono le installazioni Steam e +/// GOG viste finora senza trasformare il rilevamento in una scansione del disco. +const RPG_RT_MAX_DEPTH: usize = 3; + /// Rileva la versione di RPG Maker fn detect_rpgmaker_version(game_path: &str) -> RpgMakerVersion { let path = Path::new(game_path); @@ -150,6 +197,11 @@ fn detect_rpgmaker_version(game_path: &str) -> RpgMakerVersion { } } + // 2000/2003: cercati in profondità, vedi find_rpg_rt_dir. + if find_rpg_rt_dir(path, RPG_RT_MAX_DEPTH).is_some() { + return RpgMakerVersion::RT; + } + RpgMakerVersion::Unknown } @@ -171,6 +223,12 @@ fn find_data_files(game_path: &str, version: &RpgMakerVersion) -> Result (path.join("Data"), "rvdata2"), RpgMakerVersion::VX => (path.join("Data"), "rvdata"), RpgMakerVersion::XP => (path.join("Data"), "rxdata"), + // 2000/2003: i dati stanno in .ldb/.lmt, formato binario proprietario + // che non sappiamo (ancora) leggere. Zero file dati NON è un errore: è + // esattamente il fatto che manda questi giochi alla traduzione a + // runtime, e va riportato come zero, non come fallimento — altrimenti + // il rilevamento fallisce e il ramo che li gestisce non viene raggiunto. + RpgMakerVersion::RT => return Ok(Vec::new()), RpgMakerVersion::Unknown => return Err("Versione non supportata".to_string()), }; @@ -1471,4 +1529,66 @@ mod tests { assert_eq!(stats.untranslated, 1); assert_eq!(stats.percentage, 66); } + + /// Crea una finta installazione RPG_RT sotto `sub` livelli di sottocartelle. + fn fixture_rt(nome: &str, sub: &[&str]) -> std::path::PathBuf { + let root = std::env::temp_dir().join(nome); + let _ = fs::remove_dir_all(&root); + let mut dir = root.clone(); + for s in sub { + dir = dir.join(s); + } + fs::create_dir_all(&dir).unwrap(); + // Solo i file DATI: l'eseguibile e' volutamente assente, perche' i + // giochi veri lo rinominano e cercarlo non funziona. + fs::write(dir.join("RPG_RT.ldb"), b"finto").unwrap(); + fs::write(dir.join("RPG_RT.lmt"), b"finto").unwrap(); + root + } + + #[test] + fn rt_riconosciuto_nella_radice() { + let root = fixture_rt("gs_rt_root", &[]); + let g = detect_rpgmaker_game(root.to_string_lossy().into_owned()).unwrap(); + assert!(matches!(g.version, RpgMakerVersion::RT)); + assert!(g.data_files.is_empty(), "RPG_RT non espone file dati leggibili"); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rt_riconosciuto_in_sottocartella() { + // Steam installa spesso il gioco un livello piu' in basso: Yume Nikki + // sta in `common/Yume Nikki/yumenikki/`. Cercare solo nella radice non + // lo trova, ed e' la stessa trappola dei `Paks` di Unreal. + let root = fixture_rt("gs_rt_sub", &["yumenikki"]); + let g = detect_rpgmaker_game(root.to_string_lossy().into_owned()).unwrap(); + assert!(matches!(g.version, RpgMakerVersion::RT)); + assert!( + g.path.ends_with("yumenikki"), + "il percorso deve puntare alla cartella dei dati, non alla radice: {}", + g.path + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rt_estrazione_ritorna_zero_non_errore() { + // Zero stringhe NON e' un fallimento: e' il fatto che manda questi + // giochi alla traduzione a runtime. Se qui si tornasse un errore, il + // ramo che li gestisce non verrebbe mai raggiunto. + let root = fixture_rt("gs_rt_zero", &["game"]); + let res = extract_all_rpgmaker_strings(root.to_string_lossy().into_owned()).unwrap(); + assert_eq!(res.total_count, 0); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn cartella_qualsiasi_non_e_rpg_maker() { + let root = std::env::temp_dir().join("gs_rt_none"); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("a").join("b")).unwrap(); + fs::write(root.join("gioco.exe"), b"x").unwrap(); + assert!(detect_rpgmaker_game(root.to_string_lossy().into_owned()).is_err()); + let _ = fs::remove_dir_all(&root); + } } diff --git a/src-tauri/src/engine_detector.rs b/src-tauri/src/engine_detector.rs index 2ddefaa1..1dc9b0f3 100644 --- a/src-tauri/src/engine_detector.rs +++ b/src-tauri/src/engine_detector.rs @@ -545,7 +545,33 @@ fn is_hendrix(path: &Path) -> bool { false } +/// True se `root` (o una sottocartella entro `max_depth`) contiene i file dati +/// di RPG Maker 2000/2003. Steam installa spesso il gioco in una sottocartella, +/// quindi la profondita' va cercata. Vedi `find_rpg_rt_dir` nel patcher. +fn rpg_rt_dir_exists(root: &Path, max_depth: usize) -> bool { + if root.join("RPG_RT.ldb").exists() || root.join("RPG_RT.lmt").exists() { + return true; + } + if max_depth == 0 { + return false; + } + match std::fs::read_dir(root) { + Ok(entries) => entries.flatten().any(|e| { + let p = e.path(); + p.is_dir() && rpg_rt_dir_exists(&p, max_depth - 1) + }), + Err(_) => false, + } +} + fn is_rpg_maker(path: &Path) -> bool { + // RPG Maker 2000/2003 (RPG_RT). Si guardano i file DATI, non l'eseguibile: + // moltissimi giochi rinominano RPG_RT.exe col titolo (Yume Nikki lo chiama + // RPG_RT.exe, Ib no), ma RPG_RT.ldb e RPG_RT.lmt non si toccano mai. + if rpg_rt_dir_exists(path, 3) { + return true; + } + // RPG Maker MV/MZ if path.join("www").exists() && path.join("www/data/System.json").exists() { return true;