Skip to content

Recover the stranded Translation Bridge work, and measure whether it is worth finishing - #84

Merged
rouges78 merged 4 commits into
mainfrom
claude/recover-translation-bridge
Aug 21, 2026
Merged

Recover the stranded Translation Bridge work, and measure whether it is worth finishing#84
rouges78 merged 4 commits into
mainfrom
claude/recover-translation-bridge

Conversation

@rouges78

Copy link
Copy Markdown
Owner

Five commits on claude/bold-banach never merged after PR #19 took the first half of that branch. By the time they were reviewed, Injekt — the consumer of much of that code — had been archived (#83). This recovers the parts that survive Injekt's removal, then measures the question that recovery raised: is the shared memory bridge worth finishing at all?

What came back

Nothing here references Injekt; the recoverable slice turned out to be self-contained.

Dictionary engine — a single hash-indexed store replaces two parallel maps, so a duplicate key updates in place and the entry count stops drifting. Plus a 500k-entry cap, a CSV parser that honours quoted fields and "" escapes, batch lookup, mtime-based hot reload, and save/load of a dictionary directory.

Shared memory — Response Data becomes a real circular buffer with a tail the client advances. A full buffer now marks the slot Error instead of overwriting replies the plugin has not read yet. The Shmem moves behind an Arc<SharedShmem> co-owned by the server thread, replacing the raw-pointer-as-usize handoff and the unsound Send/Sync on TranslationBridge. Header counters are written once per batch from the Rust stats, removing the read-modify-write race against the plugin.

AI fallback — cache misses feed an mpsc queue drained by translation_bridge_drain_misses. Its non-Windows stub already shipped with e3262e58; main had an orphan command, defined only for Linux and never registered. This adds the Windows implementation and the missing main.rs line.

PROTOCOL_VERSION goes to 2: the new response_data_tail field changes the layout every client must agree on, and the branch left the constant at 1.

The measurement

Recovering this raised a fair question — is a Named Pipe fast enough, or does the shared memory path need finishing? Measured rather than guessed. Same workload on both, 3000 iterations after warmup, release build, reproducible via cargo test --release --lib ipc_bench:

transport p50 p95 p99
Named Pipe 9.2us 18.9us 30.4us
shared memory 0.4us 0.5us 0.8us

The pipe is ~20x slower per string, and it does not matter. GSTranslator::Translate() checks a process-local cache first and only calls IPC on a miss, and that cache is persisted across sessions. The ~88 strings/frame the pipe affords at p95 is not a rendering budget — it is a budget of strings never seen before, which decays to zero within seconds of play.

ipc_bench.rs compiles only under cfg(test), so it stays reproducible without entering the binary.

What the measurement turned up

No request/response path is complete on both sides. The overlay pipe is one-way by design; GameStringerTranslator has a real client in gs-hook but no Rust server; ue_translator/ipc_bridge.rs has a server that is a loop calling sleep; and the shared memory plugin's QueryBackend still returns null.

One correction is folded in: an earlier draft of the log claimed the DLL looks for a pipe name Rust never declares. Reading the UTF-16 strings out of the prebuilt DLLs shows otherwise — gs-hook.dll carries GameStringerTranslator, unity_auto_translator.dll carries GameStringerUETranslator. Two separate channels with two separate clients, each correctly named. Renaming either into the other would disconnect a prebuilt binary. The real defect is the missing server, not the name.

All of it is in docs/METODI-DI-TRADUZIONE.md with the commands to re-check.

Verification

cargo check (8 warnings, all pre-existing in process_utils.rs — the 3 in translation_bridge are gone), cargo test translation_bridge (22 passed, two of them new), cargo clippy (no new warnings), tsc --noEmit, eslint, i18n:check (802 = baseline), tauri:check-cmds (no invoke into the void), dead:check (no new dead modules).

Worth knowing before merging

This is maintenance on a dormant subsystem, not a feature. The bugs it fixes are real and sitting in main today, but nothing consumes the Translation Bridge yet. Given the numbers above, finishing the pipe server looks like the better investment than finishing the C# QueryBackend — the hard half of the pipe is already written in hook-dll/src/ipc.cpp.

🤖 Generated with Claude Code

rouges78 and others added 4 commits August 21, 2026 10:05
PR #19 landed only the first half of that branch: the shared-memory IPC
itself. Five later commits never merged, and by the time they were
reviewed their other consumer — Injekt — had been archived (#83). This
port keeps the parts that survive that removal; nothing here references
Injekt.

Dictionary engine:
- single hash-indexed storage instead of two parallel maps, so a
  duplicate key updates in place and the entry count stops drifting
- 500k-entry cap to bound memory on oversized imports
- CSV parser that honours quoted fields and "" escapes
- batch lookup, mtime-based hot reload, save/load of a dictionary dir

Shared memory:
- Response Data becomes a real circular buffer with a C#-advanced tail;
  a full buffer now marks the slot Error instead of overwriting replies
  the plugin has not read
- Shmem moves behind an Arc<SharedShmem> co-owned by the server thread,
  replacing the raw-pointer-as-usize handoff and the unsound Send/Sync
  on TranslationBridge
- header counters are written once per batch from the Rust stats, which
  removes the read-modify-write race against the plugin

Cache misses now feed an mpsc queue drained by translation_bridge_drain_misses
for AI fallback. Its non-Windows stub already shipped with e3262e5; this
adds the Windows implementation and the missing main.rs registration.

The Tauri commands reach the dictionary and the miss queue directly
rather than through Mutex<TranslationBridge>, dropping a lock level.

Dictionary APIs whose only caller was Injekt are kept behind
#[allow(dead_code)] for gs-hook to pick up.

Verified: cargo check, cargo test translation_bridge (22 passed, two of
them new), clippy, tsc --noEmit, eslint, i18n:check, tauri:check-cmds,
dead:check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The recovered circular buffer adds response_data_tail to
SharedMemoryHeader, which changes the layout every client must agree on,
but the branch left PROTOCOL_VERSION at 1. Nothing breaks today — the
only would-be client is GameStringer.Satellite, whose QueryBackend is
still a TODO returning null — and that is exactly why the bump is free
now and expensive later.

is_valid() already rejects a version mismatch, so a stale plugin built
against the v1 layout fails the handshake instead of misreading offsets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The open question was whether a Named Pipe round trip holds up at
rendering frequency, or whether the shared memory bridge is worth
finishing. Measured instead of guessed: same workload on both, 3000
iterations after warmup, release build.

Named Pipe    p50 9.2us   p95 18.9us  p99 30.4us
shared memory p50 0.4us   p95  0.5us  p99  0.8us

So the pipe is ~20x slower per string — and it does not matter.
GSTranslator::Translate() checks a process-local cache first and only
calls IPC on a miss, and that cache is persisted across sessions. The
~88 strings/frame the pipe affords at p95 is not a rendering budget, it
is a budget of strings never seen before, which decays to zero within
seconds of play.

Measuring also surfaced that no request/response path is complete on
both sides: the overlay pipe is one-way by design, the DLL's translator
pipe has no Rust server, ipc_bridge's server is a loop that sleeps, and
the shared memory plugin's QueryBackend still returns null. The DLL even
looks for a pipe name Rust never declares. Recorded in the log with the
rest.

ipc_bench.rs compiles only under cfg(test), so it stays reproducible
without entering the binary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous entry said the DLL looks for a pipe name Rust never
declares, and that the two would not meet even if the server existed.
That was wrong. Reading the UTF-16 strings out of the prebuilt DLLs the
repo ships settles it:

  gs-hook.dll (x64, x86)      GameStringerOverlay + GameStringerTranslator
  unity_auto_translator.dll   GameStringerUETranslator

They are two separate channels with two separate clients.
unity_injector.rs and the Unity DLL agree on GameStringerUETranslator
and are fine as they are; gs-hook is the one with no server and no Rust
constant at all. Renaming either into the other would disconnect a
prebuilt binary — the name lives in a C++ header and the DLL would have
to be rebuilt, not just the Rust constant edited.

No code changes: there was no misnamed pipe to repair. The real defect
is unchanged and already recorded — both channels are missing their
server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rouges78
rouges78 merged commit ab6a644 into main Aug 21, 2026
7 checks passed
@rouges78
rouges78 deleted the claude/recover-translation-bridge branch August 21, 2026 09:53
rouges78 added a commit that referenced this pull request Aug 21, 2026
…op, runtime fallback (#85)

The measurement in #84 said finish the pipe, not the shared memory. This
closes the loop: a string the game draws is asked for over IPC,
translated, learned, and served from the dictionary the next time it
appears — then, if the files never budged at all, the whole run falls
back to translating at runtime.

Rust server (translator_pipe.rs) speaks the wire format the shipped DLLs
already embed: message-mode pipe, 12-byte header, UTF-16LE payload. It
answers from the Translation Bridge dictionary and queues misses into the
same channel translation_bridge_drain_misses drains. 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 exposed three
defects, all found by injecting into the GDI testapp rather than by
reading code:

- Translate() blocked up to 2s per miss on the thread that draws.
- The pipe handle lacked 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. The game froze on the first miss and the server
  logged a connection and zero requests.
- The dedup set had no expiry, and since the server stays silent on a
  miss, a string stayed pending forever and was never asked about again.
  The chain learned and could not tell.

Drain loop (lib/translation-bridge-drain.ts) drains misses, translates
with the app's AI stack, and feeds the dictionary back — with a
per-session cap, persistence across restarts, and the rule that an empty
or unchanged translation is never stored.

Orchestrator (lib/translation/runtime-fallback.ts) decides when to switch
paths, next to patch-outcome.ts and for the same reason: it cannot be
tested inside a 4300-line component. Structural blockers are reported
before the contingent one, and a partial static success is left alone.

Verified: 891 unit tests, cargo tests, clippy, tsc, eslint, i18n gates,
tauri:check-cmds, dead:check. End to end on a real injected process:
  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..."

Traps recorded in docs/METODI-DI-TRADUZIONE.md.

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