Skip to content

v0.2.5

Latest

Choose a tag to compare

@github-actions github-actions released this 20 Jul 07:45
· 38 commits to main since this release

[v0.2.5] — 2026-07-20

Changed

  • Update NexusMods header version v0.2.3 → v0.2.4

Repaint just the version digit in the banner subtitle (3 → 4) to match
the v0.2.4 release. The glyph is re-rendered in the original font
(Roboto 12, colour #829EC4) with the background behind the old digit
reconstructed by inpainting, so the streak/gradient is preserved; only
84 px in the digit box change and the rest of the image is byte-identical.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com(ae219f5)

  • Refactor(settings): move GPU tuning help into rotating tips (translated)

The server-side GPU tuning block (OLLAMA_NUM_PARALLEL, HSA_ENABLE_SDMA,
OLLAMA_KV_CACHE_TYPE) was a permanent inline QLabel in the Ollama settings
group. Move it into the rotating general tips as three concise entries.

_show_tip() now routes tips through self.tr(), and each of the 7 UI locales
gets the three new tips translated (adapted from the prior block's wording),
replacing the now-orphaned "Server-side GPU tuning" message. Untranslated
tips still fall back to their English source.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com(382654d)

Fixed

  • Fix: repair Claude backend cache + term-protection wiring

Four bugs (found by auditing the KR fork against this repo), all confined to
the Claude backend and the batch-folder dialog — the Ollama path was correct:

  1. Term restore called a non-existent TermProtector.restore(); the method is
    restore_text(). The AttributeError was swallowed, so protected terms were
    never restored (placeholder tokens leaked into saved translations).
  2. if self.translation_cache: — TranslationCache defines len but no
    bool, so an empty cache is falsy and disabled cache read+write forever
    (it could never populate). Use is not None.
  3. Cache/TM were read with no is_retry guard, so a QC retranslation
    (retry_hint) got the same stale string back, silently defeating the retry.
  4. Cache write called a non-existent .put(); the method is .set(). This was
    masked by bug 2 and would have raised once the write path was unblocked.

batch_translate_dialog had the same broken names: protect()->protect_text()
(term protection was silently a no-op there), restore()->restore_text(),
put()->set().

Adds tests/test_claude_worker_cache_terms.py (4 tests) driving translate_batch
synchronously with a fake client + fake protector. Full suite: 601 passed.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com(a3228b9)

  • Fix(themes): stop the group-box border from striking through its title

Every built-in theme placed the QGroupBox title with
subcontrol-origin: margin, which paints it in the band above the border,
anchored to the widget's top edge. That band is exactly margin-top tall,
and it was 8px — while the title text is ~14px at the default 9pt font. The
border landed in the middle of the words, in every dialog in the app.

Qt resolves the QSS em unit to the font's full text height, so 1.0em is
the exact break-even point at every font size (verified 8pt–18pt). Use
margin-top: 1.4em for a constant 40% clearance that tracks the user's
font; a fixed px value breaks again as soon as the font grows (18px still
struck through at 14pt). The old padding-top: 16px only existed to push
content clear of the overlapping title, so it drops to 8px.

Fixes all 16 themes plus the same latent bug in dialogue_tree_dialog's
inline stylesheet. Adds tests/test_theme_groupbox_title.py (32 tests) to
pin the invariant: margin-band titles must reserve their band in em (>= 1.2).

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com(fe563f1)

  • Fix(tests): narrow re.Match before .group() so pyright passes

The Lint workflow's pyright job failed on fe563f1: _TITLE_RE.search(qss)
and _GROUPBOX_RE.search(qss) return Match | None, and calling .group(1)
straight on the result is a reportOptionalMemberAccess error.

Assert each match first. This also gives the test a real failure message when
a theme is missing the rule entirely, instead of an AttributeError on None.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com(6097239)

  • Fix(qa): never pair two fields of one menu as large-font variants (#9)

The _lrg worst-case check keyed variants on (base_swf, field_name,
usable_width). That key is not unique within a single menu: one SWF
commonly reuses a field name for several boxes of the same width
(text_tf appears 146 times across the shipped UI). When it did, the
unrelated fields were grouped as "variants" of each other and the
tightest won — so chargenmenu › text_tf (844px @ 29px) was measured
against a 126px title in the same menu. That is a six-character budget,
which calls every translation an overflow; 33 widgets also "swapped" to a
build that was not the large-font one at all.

variants() now requires an unambiguous 1:1 standard↔large pairing and
otherwise refuses, which removes both faults on the real install (105
swaps, all to a genuine _lrg build; 0 anomalies). Refusing is the honest
move — the same reason the character-id key was discarded — but it must
not be silent, so worst_case_with_reason() reports which build it
measured and why, and the dialog says so on every path, including
"⚠ its large-font twin cannot be told apart — it was NOT checked".

Two claims in the docs did not survive the data and are corrected: the
large-font build is not universally tighter (of 589 widgets in both
builds, 251 grow the font, 336 keep it and 2 SHRINK it — missionboard
goes 49px → 26px), so the UI names the build it measured instead of
asserting it was the accessibility one.

The flagship case is unaffected: «Розташування корабля» still fits
shipcrewmenu › Location_tf at 61 % (18px) and clips at 122 % in
shipcrewmenu_lrg (36px), same 264px box.

Co-authored-by: BuildBot build@local
Co-authored-by: Claude Opus 4.8 noreply@anthropic.com(e6ae11c)

  • Fix(startup): preload system libva so bundled FFmpeg finds vaMapBuffer2 (#10)

PySide6's wheels ship FFmpeg through stub libraries
(libQt6FFmpegStub-va*.so) that forward VA-API calls to the system libva.
The bundled libavutil.so.59 needs vaMapBuffer2 (libva >= 2.21), but the
shipped stub predates that symbol and does not export it. When VA-API
hardware video decoding kicks in -- e.g. the animated video background --
the loader can't resolve vaMapBuffer2 and the process aborts with a hard
"symbol lookup error" that Python can't catch.

Preload the real system libva with RTLD_GLOBAL at the start of main(),
before Qt Multimedia loads libavutil, so vaMapBuffer2 resolves against
the system library (2.23 exports it). Keeps hardware decoding working.
No-op off Linux or when libva is absent (no libva -> software decode ->
symbol never referenced).

Co-authored-by: BuildBot build@local
Co-authored-by: Claude Opus 4.8 noreply@anthropic.com(c29fe2a)

  • Fix(gender-dialog): survive empty results and use the right table signal (#11)

Two crashes in GenderDialog, both reachable from _check_gender_agreement:

  1. With zero mismatches, _setup_ui() returns early before building
    self._table (it shows only the "no issues found" label + Close), but
    init still called _populate(), which unconditionally touched
    self._table -> AttributeError. Guard _populate() to no-op when there
    are no mismatches, mirroring _setup_ui().

  2. With any mismatches, _setup_ui() connected self._table.currentRowChanged
    -- a signal QTableWidget does not have (it belongs to QListWidget) --
    so the dialog crashed at construction. Use the correct
    QTableWidget.currentCellChanged(row, col, prevRow, prevCol) via a small
    lambda adapter feeding the existing _on_row_changed(row) slot.

The dialog now works whether or not the check finds issues.

Co-authored-by: BuildBot build@local
Co-authored-by: Claude Opus 4.8 noreply@anthropic.com(f5f76e5)

  • Fix: revive source-keyed translation memory, plus 7 fixes found reviewing the KR fork (#12)

Audit of fngpsldk-ops/bethesda-strings-editor-KR_Edit for defects that still
apply here. Most of their fixes are already upstream; these eight are not. The
OpenAI-compat/Gemini backend work and their table-sort repairs (which fix a bug
their own click-to-sort feature introduced) are deliberately not carried over.

Correctness

  • TranslationMemory.len/bool read only _by_id, so a memory keyed purely
    by source text reported itself empty. That is everything the Official-TM miner
    produces and everything a TMX import produces, and five separate gates test
    exactly that value: worker attachment, the lookup gate in both backends,
    the save-on-exit snapshot, the browser dialog and the status-bar indicator.
    The TM half of "Mine Official Terminology" was dead on arrival. Both indexes
    now count, via max() rather than sum() because load() fills both maps for one
    logical entry.

  • ClaudeTranslationWorker called TranslationMemory.get(), which does not exist —
    the method is get_by_id. Not inside a try/except, so it propagated to
    fut.result() and failed every string in the batch with AttributeError the
    moment any TM was loaded. It also only ever consulted string IDs, leaving
    source-keyed memories unusable on this backend; it now runs the same
    id -> source -> fuzzy chain OllamaWorker does.

  • The chat panel's "Use as Translation" could never work once. _on_reply
    rewrites every ``` fence into a

     block before display, so the rendered
    text it scanned held no backticks at all — while the enable check ran against
    the raw reply and lit the button up anyway. Extraction now reads the stored
    history (added as the pure, testable last_code_block), a Review reply enables
    the button too, and a language tag is no longer captured into the suggestion.

  • QualityChecker's length thresholds are character counts standing in for
    display widths, calibrated on scripts that draw one narrow glyph per
    character. Korean and zh-Hans are supported targets and do neither, so the
    checks were useless in both directions: UI_OVERFLOW at 1.40x could essentially
    never fire on Hangul, and the 0.20x SUSPICIOUSLY_SHORT floor sat close enough
    to a normal Chinese ratio to flag correct work. Ratios are now converted to
    Latin-equivalent width units first, leaving narrow-script targets untouched.

  • review_translation used a flat max_tokens=1024, truncating the review of any
    book or note mid-sentence — and since the reply is appended to the chat
    history verbatim, the half-finished text then rode into the next Suggest. It
    now scales with the text under review, as translate() already did.

  • The crash-recovery snapshot was only cleared on a clean exit, so a crash after
    a successful save still offered to restore work already written to disk.

Performance

  • best_fuzzy_match is a linear scan costing a tokenisation and a word-level edit
    distance per entry — ~90 ms per lookup against a six-figure mined TM, on the
    path every uncached string takes. FuzzyIndex narrows the pool first and is
    sound: it drops only candidates fuzzy_score would have rejected anyway, from
    that function's own bounds (a multi-word source must match n-threshold+1
    positions, and the candidate's word count is capped at n+threshold; a
    single-word source takes the substring path, which can match without sharing a
    word, so those are indexed by length instead). Candidates come back in
    insertion order because best_fuzzy_match keeps the first candidate at the best
    score — a set would make ties resolve differently per run under hash
    randomisation, for the same memory and the same string. ~10x faster, results
    byte-identical. The lazy build is locked: get_fuzzy runs on every worker
    thread at once, and lookups tolerate a clear() landing mid-flight.

  • All nine word-frequency lists loaded on every launch regardless of the
    language pair, ~330 MB resident for a 35 MB Russian list nobody asked for.
    preload_language_dictionaries() warms the pair plus English (English-leak
    check, English-text protection) plus Russian for a Ukrainian target.
    Measured 331 MB -> 44 MB for an EN->KO session.

Tests: 937 pass, up from 888. test_claude_worker_cache_terms.py was standing in
a plain dict for the TM — a dict has .get(), which is exactly why the second bug
above survived a test suite that covered the path; it now uses the real class.
The FuzzyIndex tests are mostly differential (run both paths, demand the same
winner), because a pre-filter that quietly drops a viable candidate returns a
worse translation with no error anywhere.

Co-authored-by: BuildBot build@local
Co-authored-by: Claude Opus 4.8 noreply@anthropic.com(1ac2b9f)

Other

  • Docs: NexusMods app registered/un-quarantined; drop stale limitations

The NexusMods application is now registered and staff-approved (slug
0xra-bethesdastringseditor) and the mod page is un-quarantined, so the
docs no longer describe any pending limitation:

  • nexusmods_registration.md: flip §0 from "under moderation ⚠️ /
    Violation of API policy" to "registered & approved ✅"; mark the
    personal-key testing path (§5) and the slug-pending steps (§7) as
    historical/done (the app is SSO-only, no personal-API-key field).
  • nexusmods_description.txt: add "Sign in with Nexus Mods" (SSO) to the
    NexusMods section; advertise MCP servers on the Claude AI Assistant
    dock; replace the removed ти/ви register checker (Ctrl+Alt+R) with the
    inline prompt-level register-consistency guarantee.
  • README.md: same ти/ви and MCP updates; note NexusMods sign-in is SSO
    (registered app, no API key to paste); drop the deleted
    register_checker.py from the project-structure listing.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com(33da73c)

  • Nexusmods: address the automatic-quarantine "may be unsafe" flag

Add a prominent section to the mod description explaining that the
Nexus/antivirus auto-quarantine is a PyInstaller false positive, not
malware, and giving users concrete ways to verify it themselves rather
than trusting a claim: read the MIT-licensed source, run from source
(no binary), scan with the open-source ClamAV, check VirusTotal, and
verify the GPG-signed SHA256SUMS. Notes the source-built bootloader
mitigation already in CI. No code-signing claim (SignPath is not yet
wired up), so nothing here overstates the current build.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com(ee67b94)

  • Ci: bump actions to Node 24 majors + retry flaky Pages deploy

Two issues surfaced by the docs run:

  1. Node 20 deprecation warning — the pinned action majors run on Node 20.
    Bump every official actions/* to a verified Node-24 major:
    checkout v4→v5, setup-python v5→v6, upload-pages-artifact v3→v5,
    deploy-pages v4→v5, upload-artifact v4→v7, download-artifact v4→v8
    (across docs/test/lint/release). upload/download-artifact stay a
    matched pair on the v4+ format. Runtimes verified via each tag's
    action.yml runs.using.

  2. "Deployment failed, try again later." — actions/deploy-pages fails on
    the first status poll (~5s in, not the 10-min timeout), an
    intermittent GitHub Pages backend error (the run history alternates
    pass/fail with no overlapping runs). Make the deploy step tolerant:
    attempt once with continue-on-error, back off 30s, then retry; the
    job only goes red if the retry also fails. environment url falls back
    to whichever attempt succeeded.

release.yml only runs on tags, so its bumps verify at the next release;
the artifact usage is standard (name/path, merge-multiple) and unchanged.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com(3da3d0a)

  • Feat: add Claude Code CLI backend (subscription, no API cost)

Adds a subscription-backed translation/chat/review backend that shells
out to the local claude CLI instead of the metered Anthropic API, for
users who don't want to pay Claude API usage.

  • gui/claude_code_client.py: ClaudeCodeClient, a ClaudeClient drop-in that
    runs claude -p --output-format json --model <tier> --system-prompt … --strict-mcp-config (prompt on stdin). Strips ANTHROPIC_API_KEY /
    ANTHROPIC_AUTH_TOKEN from the child env so it always uses subscription
    (OAuth) auth, never silent API billing. Models claude-code:haiku/sonnet/
    opus map to CLI tier aliases (sonnet currently resolves to Sonnet 5).
  • Wired into ClaudeTranslationWorker (client chosen by model id), the chat
    panel, and quality review; main_window routes claude-code:* models, caps
    concurrency at 3, and skips the Ollama server-restart.
  • Token estimator: token-only pre-flight dialog for the subscription
    backend (no misleading USD), plus real post-batch usage — the CLI's
    per-call usage/total_cost_usd is accumulated and shown in a toast +
    status bar (labelled "not billed").
  • Settings: models surface in the model combo (+hint); Test Connection
    verifies the CLI is installed instead of pinging Ollama.
  • Tests: tests/test_claude_code_client.py (31 tests, mocked subprocess).
  • Docs: README, CLAUDE.md, NexusMods description.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com(aed8514)

  • Feat: add Translation Prompt Editor (per-language style rule + addendum)

Customize the translation system prompt without editing code, via
Translation → Translation Prompt Editor. Two editable layers — a
per-target-language override for Rule 1 (style/register) and a global
addendum appended to every prompt — with a live preview of the assembled
prompt. The formatting-token rules (2-7) stay fixed so placeholders can't
be broken.

State lives at module scope in ollama_worker (set/get_prompt_customizations,
default/effective_style_rule) and is read by to_system_prompt() in both the
normal and fix-mode paths, so all backends (Ollama, Claude API, Claude Code
CLI) honour it without threading values through every TranslationRequest.
Persisted as AppSettings.custom_style_rules / custom_prompt_addendum
(CONFIG_VERSION 38 -> 39); installed at startup and after Settings/editor
saves via main_window._apply_prompt_customizations().

Adds gui/prompt_editor_dialog.py and 15 hermetic tests. Docs updated.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com(cf735b7)

  • Feat: add translation tuning dials to the Prompt Editor

Adds six structured knobs to the Translation Prompt Editor — language
style, formality, vocabulary, grammar, expression/localization (multi),
and translation rigor. Turned-on dials render a "Translation preferences"
bullet block into the system prompt (before the addendum); neutral/default
choices contribute nothing.

Driven from a single spec (ollama_worker.PROMPT_DIALS) that feeds both
build_dials_prompt() and the dialog's combos/checkboxes, so the UI and the
prompt can't drift. Module state gains _CUSTOM_PROMPT_DIALS;
set_prompt_customizations() takes a dials arg and get_prompt_customizations()
now returns a 3-tuple (dial lists deep-copied). Injected into both the normal
and fix-mode paths of to_system_prompt(), so all backends (Ollama, Claude API,
Claude Code CLI) honour it.

Persisted as AppSettings.custom_prompt_dials (CONFIG_VERSION 39 -> 40). The
editor's left column is now scrollable and prunes default/empty choices on
save. test_prompt_editor grows 15 -> 25 tests. Docs updated.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com(3230a6e)

  • Feat: ESP coverage, TM visibility, table ops, fuzzy guard, API backoff

Six correctness/UX features ported as ideas from the KR fork audit:

  • ESP extraction (esp_handler): add GPOF/GPOG GameplayOption settings-menu
    titles; a resource-path safety filter that skips asset paths masquerading as
    text (e.g. DOOR/CNAM animation paths) so they aren't translated and break the
    mod; and a synthetic anti-hallucination context note for QUST/FULL quest
    titles (real NLDT notes still win).

  • Translation memory visibility: JSON snapshot save/load (auto-loaded on
    startup, re-saved on load and on close); the TM now lives on MainWindow and is
    re-attached in _init_translation_worker so a worker rebuild no longer drops it;
    a status-bar "TM: N" indicator; and a searchable TM browser dialog.

  • Apply to All Identical Originals (Ctrl+Alt+D) — propagate one row's
    translation to every row with the same source.

  • Delete key clears the selected rows' translation and reverts them to pending.

  • Fuzzy-TM digit guard — best_fuzzy_match rejects candidates whose numeric runs
    differ from the source (28LY != 30LY, order-sensitive).

  • ClaudeClient 429/5xx exponential-backoff retry (honours Retry-After; SDK
    auto-retry disabled so they don't compound); non-retriable errors propagate.

Adds 5 test files (+40 tests). Full suite: 637 passed. Docs updated.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com(0f8ff89)

  • I18n: update UI translations for all 7 locales

Re-ran lupdate to sync every .ts against the current source (1675 strings,
122 new per locale, 34 obsolete removed), then translated all 101 newly
added UI strings in every locale (uk_UA, de_DE, es_ES, fr_FR, ko_KR, pl_PL,
cs_CZ) and finalized 21 identical-source heuristic matches.

Covers the strings from recent features: Translation Prompt Editor + tuning
dials, TM browser + status indicator, Apply to All Identical Originals,
Validate Translation Folder, Companion Strings, the Claude Code CLI backend
and its detection messages, Claude MCP servers, and NexusMods SSO.

Compiles to 1675 finished / 0 unfinished in every locale. Placeholders, HTML,
menu accelerators and escaping verified to round-trip. (.qm are gitignored and
regenerated via scripts/compile_translations.sh.)

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com(bf9ce73)

  • Feat(ko): deterministic Korean particle (조사) checker + corrected prompt rule

Korean particle allomorphs are selected by the 받침 (final consonant) of the
syllable they attach to. Hangul syllables decompose arithmetically, so
(ord(ch) - 0xAC00) % 28 yields the 받침 index and the correct allomorph is
computed, never guessed.

gui/ko_particle_checker.py — two checks:

  • check_placeholder_particles: sound, no dictionary. A single-form particle
    after a value placeholder (<Alias=…>, , %s, {name}) is always a
    latent bug — the substituted noun's 받침 is unknowable at translation time
    and the engine does no particle resolution — so both forms must be written
    (<Alias=Player>은(는)). Formatting tags (,
    , ) wrap known text
    and are deliberately not treated as placeholders.

  • check_batchim_particles: restricted to the provably sound subset. 가/과/로
    are productive Sino-Korean noun suffixes (모험가, 효과, 반응로 are single
    nouns, not stem+particle) that no word list enumerates, so the 이/가, 과/와
    and (으)로 pairs are excluded. The remaining 은/는 and 을/를 are guarded by:
    token not itself a dictionary word (없는, 가을), stem is one, and stem >= 2
    syllables — "-는" is also the adnominal verb ending and verb stems are
    overwhelmingly one syllable or vowel-final. Silent without the word list
    rather than guessing, since the auto-fixer acts on what it reports.

Measured at 0 false positives over 5353 real Korean strings, with full recall
on 사람는 / 함선를 / 바다은 / <Alias=Player>은 / %s를 / {path}로.

Wired into QualityChecker as KO_PARTICLE_PLACEHOLDER and KO_PARTICLE_MISMATCH,
both auto-fixable — every issue carries its exact replacement, so the
self-review chain repairs it mechanically instead of retranslating. Gated on
_HANGUL_TARGETS with a deferred import, so a non-Korean target never loads the
50k-entry Korean word list.

_TARGET_STYLE["ko"] now teaches the same rule: read acronyms aloud in Hangul
rather than from the Latin spelling (O2 → 오투 → O2는; H3 → 에이치삼, whose ㅁ
is a 받침 → H3은), the ㄹ → 로 exception (서울로), and both-forms after
placeholders.

Fixes five real defects the new checker found in our own Korean UI:
{enc}으로, {filename}으로 (x2) and {path}으로 in ko_KR.ts are now (으)로.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com(c6dfc99)

  • Chore(resources): refresh NexusMods header image

Same 1300x300 canvas, new artwork.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com(2247a2c)

  • Chore(scripts): add the header banner generator

Rebuilds resources/nexusmods_header.png from a supplied background image:
crops to 13:3, then composites the wordmark, feature chips and stat cards
at the coordinates measured from the original banner.

Needs Pillow + numpy (dev-only, not in requirements.txt).

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com(bd44609)

  • I18n(settings): translate remaining rotating tips into all 7 locales

The 58 non-GPU entries in _GENERAL_TIPS were English-only. Now that
_show_tip() routes through self.tr(), add a translated for each
under the SettingsDialog context in every UI locale (uk_UA, de_DE, es_ES,
fr_FR, ko_KR, pl_PL, cs_CZ). All 61 tips are now fully localized.

Technical tokens (shortcuts, file extensions, ESP/ESM, qcgemma4-st,
SHA-256, num_ctx, RU→UK, issue codes, ти/ви) are kept verbatim; Korean
particles resolved by reading Latin/number tokens aloud.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com(4a26c3e)

  • Chore(scripts): remove obsolete PIL icon generator

scripts/generate_icon.py built app_icon.png/.ico with PIL, but the shipped
icon is an AI (ComfyUI workflow) generated asset — the script no longer
matches reality and nothing references it. Remove it to avoid confusion.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com(0dde0cd)

  • Feat(tm): add Official-TM miner (align base-game localizations → TM + glossary) (#1)

Bethesda ships every official language for a plugin side-by-side, keyed on
identical string IDs, inside one localization archive. Aligning EN against an
official target by (base, ext, id) yields Bethesda's canonical rendering of
every weapon/faction/UI/quest term — with zero AI calls.

  • bethesda_strings/official_tm_miner.py: pure engine (scan BA2/loose → align
    within independent (base,ext) ID spaces → dedup TM (majority target, drops
    EN-left-in-English identity pairs) + mine filtered glossary with optional
    reference-language annotations). available_languages/scan_language/
    align_indexes/build_tm_pairs/mine_glossary/mine_official.
  • gui/official_tm_dialog.py: OfficialTMDialog — Data-folder picker, language
    auto-detect, QThread worker, glossary preview, import_requested(MineResult).
  • gui/translation_memory.py: add_pairs() source-keyed merge.
  • gui/string_table.py: import_translations(only_pending=True) so mined data
    never clobbers in-progress work (mirrors ESP migration).
  • gui/main_window.py: Translation-menu action + apply handler (merge TM →
    snapshot → pre-fill pending rows; add canonical GlossaryEntry rows → save).
  • tests/test_official_tm_miner.py: 17 pure tests, no game files.
  • CLAUDE.md: documented module, dialog, helpers, and test.

Verified against real Starfield data: EN→DE mined 190,367 TM entries +
17,815 glossary terms in 27s. Full suite 748 passed.

Co-authored-by: BuildBot build@local
Co-authored-by: Claude Opus 4.8 noreply@anthropic.com(cc3cc8b)

  • Feat(prompt): player-gender-aware translation (Settings + Prompt Editor) (#2)

English "you"/adjectives carry no grammatical gender, but many target
languages do, so the model was guessing a gender per player-referring line.
Add a player-character gender the translator declares once and that every
backend applies consistently.

  • ollama_worker: separate _PLAYER_GENDER layer (set/get_player_gender) kept
    out of the get_prompt_customizations() 3-tuple; _player_gender_directive()
    emits masculine/feminine/neutral guidance, injected by to_system_prompt()
    in both normal and fix-mode paths, only for gendered targets
    (_GENDERED_TARGETS; no-op for en/ja/ko/zhhans).
  • AppSettings.player_gender (CONFIG_VERSION 40->41 + migration); editable in
    Settings -> Translation Preferences and the Prompt Editor (live preview).
  • Installed at the single main_window._apply_prompt_customizations() hook.
  • gui/player_gender.py (pure): is_player_referring/find_player_referring_rows
    flag gender-sensitive source strings; Translation menu -> "Find
    Player-Referring Strings" selects them.
  • tests/test_player_gender.py (38) + CLAUDE.md docs.

Note: a .strings file stores one text per ID, so the choice applies to the
whole translation for every player (no runtime M/F switch) - hence Neutral.

Co-authored-by: BuildBot build@local
Co-authored-by: Claude Opus 4.8 noreply@anthropic.com(653050e)

  • Feat(prompt): warn before translating player-referring strings with no player gender set (#3)

An unset player gender only matters when the batch actually contains
player-referring lines and the target language inflects for gender — so
warn there, before any AI call, rather than as a post-hoc QualityChecker
code (by then the model has already guessed a gender per line, and been
paid for it).

_check_player_gender_nudge() gates _start_translation() (the single choke
point for both Translate Selected and Translate All, just before the Claude
pre-flight). It bails out early — costing nothing on the common path —
unless all four hold: warn_player_gender_unset is on, player_gender is
unset, the target is gendered, and at least one of the requests' source
texts is player-referring.

The dialog offers Set Gender… (a picker that writes the setting and
re-installs it via _apply_prompt_customizations() so the pending batch
picks it up at prompt-build time; dismissing it cancels the batch so the
choice can't be skipped by accident), Translate Anyway, and Cancel, plus a
"Don't warn me again" checkbox.

  • ollama_worker: is_gendered_target() — public predicate over _GENDERED_TARGETS
  • player_gender: count_player_referring_texts() over a batch's source texts
  • app_settings: warn_player_gender_unset (CONFIG_VERSION 41 → 42 + migration)
  • tests: 16 new cases (54 in test_player_gender.py; 802 suite-wide)

Co-authored-by: BuildBot build@local
Co-authored-by: Claude Opus 4.8 noreply@anthropic.com(0aa40cc)

  • Feat(qa): add UI width-fit simulator (measure rendered width, flag clipping) (#4)

The font checker verifies a glyph exists in the atlas, but not that the
label it spells fits its box. Cyrillic/German/Polish run 15-30% longer
than English and Scaleform widgets clip rather than shrink, so a
correctly-spelled translation still ships as a truncated button.

Nothing to measure against existed yet: _parse_definefont2 read the
CodeTable and stopped, and the TTF path discarded glyph ids and never
touched hmtx. So extract advance widths first, then build on them.

font_checker: FontSource.advances maps codepoint -> advance as a fraction
of the EM square, normalised at parse time.

  • SWF advances come from the FontAdvanceTable, which is present only
    when the tag's HasLayout flag (0x80) is set. A font may legitimately
    have glyphs but no widths: has_metrics is then False and we refuse to
    measure rather than inventing numbers.
  • DefineFont3 stores coordinates in a 20480-unit EM square vs
    DefineFont2's 1024. Reading one with the other's constant is a silent
    20x error, so the two are separate named constants.
  • hmtx is indexed by glyph, not character, so _read_cmap4/_read_cmap12
    now return {codepoint: glyph_id} maps instead of bare codepoint sets.

width_fit (pure Python): sum(advance_em) * font_px, the same summation the
renderer performs, minus kerning pairs (a sub-1% effect that errs toward
under-reporting, never inventing overflow). It measures what the player
sees: formatting tags contribute 0px, value placeholders (<Alias=...>, %s)
are substituted with representative text and the row marked approximate,
and caps-transforming widgets are measured after the transform.

The measurement is exact; the budget it is compared against is a model,
and the two are kept distinct. WidgetSpec.confidence is MEASURED or
ESTIMATED, budgets are user-editable, and every FitResult also carries
source_ratio (translated width / English width) which needs no budget at
all and is the strongest signal available, since the English fit by
construction. Only length-critical single-line widgets are checked: prose
wraps rather than clipping, so flagging it would be pure noise, and that
gate holds even under a widget override.

Font role is load-bearing: NB_Architekt has no Cyrillic at all, and
semibold RF_55_SB is ~19% wider than RF_35_M, so measuring a bold button
with the regular face under-reports overflow. Metrics default to the real
Starfield faces already committed under data/fonts/, so the tool needs
zero configuration.

Front-ends: width_fit_dialog (editable budgets, worst-overflow-first
results, CSV export) and a live indicator in visual_context_preview
("Tab label: 262/220px (119%) CLIPS"). The indicator is deliberately
separate from the existing OVERFLOW badge, which only reports whether text
outgrew the preview canvas -- that canvas resizes with the dock and so
cannot answer "will this clip in-game?".

Tests: 45, pure Python, no Qt and no game install. SWF cases use
hand-built tag bytes; TTF cases assert against the real committed fonts.

Co-authored-by: BuildBot build@local
Co-authored-by: Claude Opus 4.8 noreply@anthropic.com(2f90de7)

  • Feat(qa): read real widget bounds from the game's SWF DefineEditText records (#5)

The width-fit simulator measured text exactly but compared it against budgets
I had guessed, because a .strings file says nothing about how wide a button is.
The game does say, in the DefineEditText tag of every menu. So read them.

How wrong the guesses were: the HUD quest objective is 335px, not the 480px
that seemed reasonable -- 43% too generous. Both HUD presets are now taken
verbatim from the shipped SWFs and cite the field they came from; the rest
still say ESTIMATED.

swf.py: SWF container primitives, extracted so font_checker (glyphs, advance
widths) and swf_widgets (widget bounds) share one tag walker instead of two.
Twips are returned raw so nobody double-divides, and nothing raises on
malformed input -- game files get truncated, re-packed and modded.

swf_widgets.py: what is ground truth here is carefully bounded.

  • Bounds and margins are exact. The budget is usable_width_px (authored
    width minus margins), the space text actually gets.
  • Clip behaviour is exact, and it is what makes a field length-critical: no
    Multiline, no WordWrap, no AutoSize means text physically cannot reflow,
    so it truncates. That replaces a heuristic with a fact.
  • Font face is exact given a fontconfig: fields name their font by class
    ($MAIN_Font_Bold), which maps to a family (RF_55_M) we already bundle. No
    more guessing whether a widget is bold.
  • Font size usually is NOT. Starfield leaves HasFont clear and sets size from
    ActionScript; only ~4% of clipping fields declare one, in their HTML initial
    text. Those are DECLARED; the rest are DERIVED from box height (ratio 1.22,
    calibrated on the 24 fields that state both, spread 1.15-1.44).

DECLARED vs DERIVED is carried on every record and never flattened, because
width scales linearly with font size: a 20% size error is a 20% wrong verdict.
Confidence gains DERIVED between MEASURED and ESTIMATED to say exactly this.

A text field has no name of its own, so SymbolClass (names the sprite) is joined
to the PlaceObject2/3 instance name inside its DefineSprite body, which turns
character id 35 into BasicButton_Label.Label_tf. scan_game_ui reads loose
Interface/*.swf and Interface.ba2, loose shadowing archived exactly as the
game and localisation mods resolve them: ~250 SWFs, ~4600 fields, ~2560 clipping.

Icon fonts (Genesis Controller Buttons) resolve to no metrics and are refused
rather than measured -- a controller-glyph field is not text.

Also fixes a real latent bug: parse_fontconfig's _MAP_RE could not parse
Starfield's own syntax (map "$MAIN_Font" = "RF_35_M" -- note the =), so every
mapping in a real fontconfig.txt was silently dropped and the map came back {}.

Which field draws which string is still not knowable -- a .strings entry has no
link to a SWF field -- so the dialog supplies the catalogue and the user picks
the widget. Verified against a real install: 888 measurable widgets, and a
25-char Ukrainian label needing 389px in a 91px button (427%).

Tests: 23 new (875 total). Hand-built tag bytes, no game install required. They
pin the optional-field chain (a present-but-skipped field misaligning every byte
after it) and Starfield's HasFont-clear/HTML-size convention, so a FontHeight-
only parser cannot regress to finding zero sizes.

Co-authored-by: BuildBot build@local
Co-authored-by: Claude Opus 4.8 noreply@anthropic.com(13d59a5)

  • Feat(qa): check the _lrg large-font menus as the width worst case (#6)

Starfield ships a large-font accessibility build of most menus
(missionmenu_lrg.swf). The box is usually the SAME size while the text grows --
median x1.30 and up to x2.64 (missionmenu text_tf: 18px -> 48px in an unchanged
869px box) -- so the large-font menu, not the standard one, is what a label
actually has to survive. A translation can pass the normal menu and clip only
for players using the accessibility font, which is precisely the bug that ships
unnoticed.

catalogue.worst_case(field) returns the tightest build. It compares on
capacity_em (usable_width / font_px), because the two builds share a box and
differ only in font size: comparing pixel widths would call them identical and
miss the entire point.

The pairing key is (base_swf, field_name, usable_width) and deliberately NOT the
character id. The two menus are compiled separately so ids drift, and pairing on
them produced nonsense -- a "pair" whose large-font build was smaller
(354px@36 matched against 241px@25). Keying on the box the widget occupies gives
348 pairs with zero such anomalies, 156 of them strictly tighter and none where
the font shrinks. A test pins that anomaly so the id key cannot creep back.

Also drops zero-width fields as unmeasurable: a 0px box is not a tiny widget but
one ActionScript sizes at runtime, and measuring against it flags every string.

The dialog gets a "Worst case: large-font (accessibility) menu" checkbox, on by
default, and says so when the swap actually changed the widget -- otherwise the
numbers on screen would not match the combo entry above them.

Verified on a real install: «Розташування корабля» fits shipcrewmenu Location_tf
at 18px, and hits 122% in shipcrewmenu_lrg at 36px, in the same 264px box.

Tests: 7 new (882 total). Pure Python, no game install.

Co-authored-by: BuildBot build@local
Co-authored-by: Claude Opus 4.8 noreply@anthropic.com(972f79b)

  • Ci: move the last Node 20 actions to their Node 24 majors (#7)

GitHub is deprecating the Node 20 actions runtime; runners already force such
actions onto Node 24 and emit a deprecation warning on every run.

Three actions still declared node20, and each has a node24 major with identical
inputs (verified against their action.yml, not assumed):

astral-sh/ruff-action v3 -> v4.1.0
jakebailey/pyright-action v2 -> v3
softprops/action-gh-release v2 -> v3

ruff-action is pinned to an exact version rather than a floating major: it ships
immutable releases from v4 on and publishes no v4 tag, so @v4 is not a valid
ref and would fail the workflow at dispatch. Commented so nobody "fixes" it back.

Everything else was already node24 or composite (checkout@v5, setup-python@v6,
upload/download-artifact, deploy-pages, git-cliff-action), so nothing else moves.

The v4/v3 majors are pure runtime bumps with no API changes. ruff-action's only
noted breakage is very old self-hosted runners; every job here is GitHub-hosted.
The release job keeps body_path, so the existing "Argument list too long"
workaround for large release bodies is untouched.

Co-authored-by: BuildBot build@local
Co-authored-by: Claude Opus 4.8 noreply@anthropic.com(95e675b)

  • Ci: make the release pipeline rehearsable (dry run -> draft release) (#8)

The publish step (softprops/action-gh-release) only ever runs on a tag push, so
the v2->v3 bump in #7 has never actually executed. The first time it runs for
real would be a live release to a public repo with a live NexusMods mod page --
a bad place to discover a broken action.

Rehearsing it by pushing a throwaway pre-release tag is not the answer: the -
in the tag only skips the NexusMods upload, while the GitHub Release itself is
still created with draft: false. That publishes publicly and notifies watchers.

So make the pipeline rehearsable instead. workflow_dispatch gains a dry_run
input (default on) that builds, signs, and creates the release as a DRAFT:
private to maintainers, notifies nobody, and -- because GitHub only creates the
tag when a draft is published -- leaves no tag behind. It exercises the real
action with the real inputs, which is the thing that was untested.

Behaviour, verified against the expression semantics for every path:

real tag v0.2.5 release=yes draft=no nexusmods=yes (unchanged)
pre-release v0.3.0-rc1 release=yes draft=no nexusmods=no (unchanged)
dry run (branch) release=yes draft=YES nexusmods=no
dispatch, dry_run off release=no -- nexusmods=no

NexusMods is the one irreversible outward-facing step; it requires a real tag, so
a dry run cannot reach it. Commented to say so.

Two fixes fall out of this:

  • Version injection read GITHUB_REF_NAME unconditionally, which on a non-tag
    ref would bake a version of "main" into the build.
  • fail_on_unmatched_files: true, so a packaging break fails the release loudly
    instead of quietly publishing one with no binaries attached.

Co-authored-by: BuildBot build@local
Co-authored-by: Claude Opus 4.8 noreply@anthropic.com(55486e7)

  • Release v0.2.5

Bump version to 0.2.5 (main.py, pyproject.toml) and add the [0.2.5]
changelog section covering the 30 commits since v0.2.4: the UI width-fit
simulator and the real widget bounds + large-font worst case it grew into,
player-gender-aware translation with its pre-batch nudge, the Official-TM
miner, the Claude Code CLI backend, the Translation Prompt Editor and
tuning dials, the Korean particle checker, TM visibility, the per-pair
dictionary preload and fuzzy-index speedups, and the batch of fixes from
the KR-fork audit — chief among them the source-keyed translation memory
that reported itself empty and so switched itself off everywhere.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com(63119a1)