Skip to content

Close the in-game translation loop: pipe server, DLL client, drain loop - #85

Merged
rouges78 merged 6 commits into
mainfrom
claude/translator-pipe-server
Aug 21, 2026
Merged

Close the in-game translation loop: pipe server, DLL client, drain loop#85
rouges78 merged 6 commits into
mainfrom
claude/translator-pipe-server

Conversation

@rouges78

@rouges78 rouges78 commented Aug 21, 2026

Copy link
Copy Markdown
Owner

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.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. 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. 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 dllmain never called IPC::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.ts drains 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 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. 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

UI responsive DLL log server side
before False 3 lines, activation only connected, 0 requests
after True capture intact hits translated, misses queued

And the loop closing, with a stub provider that prefixes [IT]:

[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..."

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 into gs-hook/testapp. SendMessageTimeout(WM_NULL, 2000ms) on the game window is what separates "slow" from "deadlocked".

Verification

cargo test --lib translator_pipe 5/5, cargo test --lib translation_bridge 22/22, vitest translation-bridge-drain 14/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

rouges78 and others added 3 commits August 21, 2026 12:05
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>
@rouges78 rouges78 changed the title Give the GameStringerTranslator pipe its missing server Close the GameStringerTranslator loop: pipe server, and wake the DLL client Aug 21, 2026
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>
@rouges78 rouges78 changed the title Close the GameStringerTranslator loop: pipe server, and wake the DLL client Close the in-game translation loop: pipe server, DLL client, drain loop Aug 21, 2026
rouges78 and others added 2 commits August 21, 2026 13:10
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
rouges78 merged commit f26cd3e into main Aug 21, 2026
7 checks passed
@rouges78
rouges78 deleted the claude/translator-pipe-server branch August 21, 2026 11:51
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant