Close the in-game translation loop: pipe server, DLL client, drain loop - #85
Merged
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
rouges78
added a commit
that referenced
this pull request
Aug 21, 2026
#85 wired the runtime fallback where the generic workflow ends, but the per-engine branches of startAutoTranslate never get there: each returns on its own. A Ren'Py or Unreal game that failed simply stopped, and the runtime path stayed out of reach for exactly the games most likely to need it. Seven hook points. Five are run failures — the catch blocks of Danganronpa/Spike, Hendrix, Ren'Py, Visionaire and Tyrano/NW.js/Electron. Two are structural dead ends that until now told the user to go find the OCR page themselves: - Unreal with no .locres. The text is on screen, it just is not in the files, which is what gs-hook is for. - RPG Maker classic (RPG_RT 2000/2003), the textbook case: gs-hook's GDI source was tuned on exactly how RPG_RT composes a frame, and gs-hook/testapp models that back buffer glyph by glyph. Godot is deliberately not hooked: it does not fail, it routes to a working dedicated translator. The two kinds of failure cannot share a message. "This game resists file-based translation" is true when the engine exposes no text and a lie when Ollama just went down, and a catch block cannot tell them apart from the exception alone. A FallbackCause now picks between that line and one offering the runtime path without declaring the static path impossible. It changes only what we claim, never what we do — with the game already running, action and message are identical either way, and a test pins that so the distinction cannot quietly become two behaviours. Verified: 895 tests, tsc, eslint (0 errors), i18n:check at baseline 802, a11y:check at baseline 78. The new string is translated across all eleven locales. Each insertion is anchored on a unique existing line and asserted unique before applying. Still open: the fallback has not been seen firing on a real game end to end. Every link below it was verified with real injection; this path needs an installed title that fails its engine branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rouges78
added a commit
that referenced
this pull request
Aug 21, 2026
First real-game test of the runtime chain, on Father's Day (UE, Steam). The transport half works: the DLL connects to the Rust server inside a shipping title, and the game stays healthy — SendMessageTimeout answers, 103s CPU, 608 MB. That confirms the overlapped-I/O fix from #85 on something other than the test app, which matters, because that bug froze the process instantly. The other half does not. Zero requests reach the server, because the Unreal L1 source refuses to hook FText::ToString when its byte pattern is ambiguous — four matches here — and declining is the right call: a wrong hook in a shipping game means a crash. The GDI sources stay active but see nothing, since UE draws through Slate/Direct3D. The missing link is symbol resolution, not IPC. The trap: "connesso a GameStringer via IPC" reads like success and is only half the chain. The number that decides is how many requests reach the server; at zero the problem is upstream of the IPC, and staring at the pipe will never find it. Carries the corollary for #87 — hooking the fallback to the "Unreal without .locres" dead end is right in principle, but while that pattern stays ambiguous the runtime path has no text to translate on UE. RPG_RT and GDI games are different; that source works and has been seen working. Second entry, on the Paks folder, from falling into the log's own trap in a variant it did not cover: every UE game has at least two, and the first one found is usually Engine/Programs/CrashReportClient/Content/Paks. I measured that one and concluded REANIMAL shipped no localization — 45 MB of crash reporter against 15.6 GB of game; TerraTech Legion, 46 against 5.5 GB. The control that catches it is The Skin Stapler, which must come back positive at 1679 entries; while a method says otherwise, the method is what is broken. Docs only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The measurement in #84 settled the transport question: finish the pipe, not the shared memory. This closes the loop — a string the game shows is asked for over IPC, translated, learned, and served from the dictionary the next time it appears. Verified by injecting into a real process, not by reading code.
The three links
Rust server.
translator_pipe.rsserves 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. It answers from the Translation Bridge'sDictionaryEngineand pushes misses into the same mpsc queuetranslation_bridge_drain_missesdrains — one dictionary, one AI-fallback queue, two transports. On a miss it stays silent by design: the DLL owns the timeout, and answering with the original would poison its local cache permanently.C++ client. gs-hook's
dllmainnever calledIPC::Initialize(), so the client compiled into the shipped DLLs was dormant. Waking it up exposed two real defects (below). The miss path is now fire-and-forget: the request goes out, the draw returns the original immediately, and the response lands in the cache via a receive-thread callback.Drain loop.
lib/translation-bridge-drain.tsdrains the miss queue, translates with the app's AI stack, and feeds the dictionary back. Dependencies are injected so the interesting parts test 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, since that turns a miss into a permanently wrong hit that is never retried.save_to_dir/load_from_dir, dead since Injekt was archived, are now Tauri commands, so what the loop learns survives a restart. The overlay page also feeds its own translations back; it never did, and was re-translating the same line on every appearance.Three bugs found by running it
Translate()blocked up to 2 s per cache miss, on the thread that draws.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 inReadFile, the render thread'sWriteFilequeued 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. The handle is now overlapped and a dedicated sender thread owns all writes.The DLL's dedup set had no expiry. It 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 10 s TTL, above the drain loop's ~3 s round.
Measured, injected for real
And the loop closing, with a stub provider that prefixes
[IT]:That third line is the one that proves anything, and without the TTL it never appears. Reproduce with
cargo run --example translator_pipe_server, then inject intogs-hook/testapp.SendMessageTimeout(WM_NULL, 2000ms)on the game window is what separates "slow" from "deadlocked".Verification
cargo test --lib translator_pipe5/5,cargo test --lib translation_bridge22/22,vitest translation-bridge-drain14/14,tsc --noEmit, eslint,i18n:check(802 = baseline),tauri:check-cmds,dead:check, clippy (one pre-existing warning, unrelated). Both DLL architectures rebuilt.Traps recorded in
docs/METODI-DI-TRADUZIONE.md: the overlapped-pipe deadlock (with the bisect-by-experiment method that found it in minutes after reading the code sent me the wrong way), and the dedup-without-expiry one — a dedup and a cache look alike, but a cache key eventually gets filled and a dedup entry may never be, so if the condition that clears it can fail to happen, it needs a deadline.What is left for the one-button chain
The orchestrator: try the static path, fall back to runtime, write a per-game report. The plumbing under it now runs.
🤖 Generated with Claude Code