diff --git a/Cargo.lock b/Cargo.lock
index 2224a9dd..46d16b19 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2744,6 +2744,7 @@ dependencies = [
"filetime",
"flate2",
"image",
+ "indexmap 2.14.0",
"libloading 0.9.0",
"ltk_fantome",
"ltk_file",
@@ -2751,6 +2752,7 @@ dependencies = [
"ltk_mod_project",
"ltk_modpkg",
"ltk_overlay",
+ "ltk_rst",
"ltk_wad",
"notify",
"notify-debouncer-mini",
@@ -2759,6 +2761,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
+ "serde_yaml_ng",
"slug",
"tar",
"tauri",
@@ -2793,13 +2796,14 @@ dependencies = [
[[package]]
name = "ltk_fantome"
-version = "0.5.1"
+version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b510d628512c4e3abbcd06cd759b3f1a08f8ee202c4a020a05b7dc1dd096d7a"
+checksum = "a6a6bb75085cc9274f6d80959bf52347bd579bbc919ee829c1b946d2c7040995"
dependencies = [
"camino",
"eyre",
"image",
+ "indexmap 2.14.0",
"itertools",
"ltk_mod_project",
"ltk_wad",
@@ -2860,10 +2864,11 @@ dependencies = [
[[package]]
name = "ltk_mod_project"
-version = "0.4.1"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a2b19b9af50be7da6534709046f6973c62275f37d284329ff0de6bb718eadf9e"
+checksum = "4ce4512cbd546953ed2bc55e80df8fd2def40fee9c59c803acbfedb318e7109d"
dependencies = [
+ "indexmap 2.14.0",
"serde",
"serde_json",
"thiserror 2.0.18",
@@ -2872,15 +2877,16 @@ dependencies = [
[[package]]
name = "ltk_modpkg"
-version = "0.5.0"
+version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2bdff18687f42db90e61344a8a098c59983025d5464c63851e2f150f08fe2d5"
+checksum = "780dfeaf47dd7d9ebf3c73a0d1a25b6fcee552cf194c0e7504c5c05601a3c86d"
dependencies = [
"binrw",
"byteorder",
"camino",
"glob",
"image",
+ "indexmap 2.14.0",
"itertools",
"ltk_io_ext 0.2.1",
"ltk_mod_project",
@@ -2898,16 +2904,18 @@ dependencies = [
[[package]]
name = "ltk_overlay"
-version = "0.3.1"
+version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "569035c8426863ad67180c8d83621422e757313311360f1d0c13e6d94ad22902"
+checksum = "3cce42f1500bb965793388b33ada86b8ece171d240642a704227a54a97e93e7d"
dependencies = [
"byteorder",
"camino",
+ "indexmap 2.14.0",
"ltk_fantome",
"ltk_file",
"ltk_mod_project",
"ltk_modpkg",
+ "ltk_rst",
"ltk_wad",
"memmap2",
"rayon",
@@ -2945,6 +2953,18 @@ dependencies = [
"thiserror 1.0.69",
]
+[[package]]
+name = "ltk_rst"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c47f87ae3e31064792f6f7a8b8d56da0ef9a83d92b419cce97f4d37a078a8110"
+dependencies = [
+ "byteorder",
+ "num_enum",
+ "thiserror 1.0.69",
+ "xxhash-rust",
+]
+
[[package]]
name = "ltk_wad"
version = "0.2.15"
@@ -4778,6 +4798,19 @@ dependencies = [
"syn 2.0.118",
]
+[[package]]
+name = "serde_yaml_ng"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f"
+dependencies = [
+ "indexmap 2.14.0",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
[[package]]
name = "serialize-to-javascript"
version = "0.1.2"
@@ -6171,6 +6204,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
dependencies = [
"chrono",
+ "indexmap 2.14.0",
"serde_json",
"thiserror 2.0.18",
"ts-rs-macros",
@@ -6270,6 +6304,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+[[package]]
+name = "unsafe-libyaml"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
+
[[package]]
name = "untrusted"
version = "0.9.0"
diff --git a/docs/plans/string-overrides-implementation.md b/docs/plans/string-overrides-implementation.md
new file mode 100644
index 00000000..235ae5d4
--- /dev/null
+++ b/docs/plans/string-overrides-implementation.md
@@ -0,0 +1,231 @@
+# String Overrides — Implementation Plan
+
+> Status: **implemented on feature branches** (2026-07-02): league-mod `feat/string-override-application` (pushed; Phase 2 complete incl. tests) and ltk-manager `feat/apply-string-overrides` (Phase 3 complete). Remaining: manual E2E (§7 step 3) and the release chain (§7 step 4, gated on PR #138 review).
+> Closes: ltk-manager [#123](https://github.com/LeagueToolkit/ltk-manager/issues/123), league-mod [#97](https://github.com/LeagueToolkit/league-mod/issues/97).
+> Depends on: league-toolkit [PR #138](https://github.com/LeagueToolkit/league-toolkit/pull/138) (`ltk_rst` API rework) — consumed as a **git dependency on the PR branch** while review is pending (head `d033293` as of 2026-07-02), so development is not blocked on the merge/publish.
+
+## 1. Current state (verified 2026-07-02)
+
+**Already done — do not redo:**
+
+| Piece | Where | Status |
+| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
+| Editor UI (per-layer, per-locale key/value editor) | `src/routes/workshop/$projectName/strings.tsx` | Done; shows "not applied yet" warning (lines 176–195) |
+| Save command + hook | `commands/workshop.rs:166` `save_layer_string_overrides`, `useSaveStringOverrides.ts` | Done |
+| Project persistence | `ltk_mod_project 0.4.1` — `ModProjectLayer.string_overrides: HashMap>` in `mod.config.json` | Done |
+| Ship in `.modpkg` | `ltk_modpkg 0.5.0` — `ModpkgLayerMetadata.string_overrides` (msgpack metadata, schema v2); `ProjectPacker::build_layer_metadata` copies them on pack | Done |
+| Ship in `.fantome` | `ltk_fantome 0.5.1` — `FantomeLayerInfo.string_overrides` (`META/info.json` → `"StringOverrides"`), writer emits them | Done |
+| Workshop import round-trip | `workshop/projects.rs` — both fantome (lines ~304–327) and modpkg (~418–420) importers preserve overrides | Done |
+| Overlay stage plumbing | `ltk_overlay::OverlayStage::ApplyingStringOverrides` exists (never emitted); ltk-manager maps it to local `OverlayStage::Strings` (`overlay/mod.rs:107`); frontend maps it in `useOverlayProgress.ts` | Plumbed, dormant |
+
+**Missing — the actual work:**
+
+1. No RST reader/writer in the dependency tree (`ltk_rst` not referenced anywhere).
+2. `ltk_overlay 0.3.1` never applies overrides: `collect`/`patch` pipeline ignores `ModProject.layers[].string_overrides` entirely.
+3. **Bug:** `ltk_overlay::FantomeContent::mod_project()` (`fantome_content.rs:189–222`) builds `default_layers()` and _discards_ fantome `StringOverrides` — fantome mods lose overrides at build time even though the archive carries them.
+4. No locale detection (nothing reads `LeagueClientSettings.yaml`; no YAML crate in `Cargo.lock`).
+5. No settings toggle for "apply to all locales".
+
+**Key facts for implementation:**
+
+- Game locale: `C:\Riot Games\League of Legends\Config\LeagueClientSettings.yaml` → `install.globals.locale` (e.g. `"en_US"`). The Config dir is a sibling of `Game/` under the League root.
+- Target file: chunk `data/menu/{locale_lower}/lol.stringtable` inside `Game/DATA/FINAL/Localized/Global.{locale}.wad.client` (one WAD per installed locale; verified `Global.en_US.wad.client` on this machine).
+- `ltk_rst` (post-#138 API): `Stringtable::from_reader` (defaults = latest format: V5, 38-bit hash/offset split, XXH3 — correct for the live game), `insert_str(key, value)` (lowercases + hashes + masks the key), `insert(hash, value)` (masks a full 64-bit hash), `to_writer` (deterministic, sorted, deduped output). No manual hashing needed.
+- CDragon key-name list (for optional UI autocomplete): `https://github.com/CommunityDragon/Data/blob/master/hashes/lol/hashes.rst.xxh3.txt` — ~7 MB, lines of `<16-hex full xxh3> `.
+- Locale keys in the data model are lowercase (`en_us`, `ko_kr`, …) plus `"default"`; the YAML value is `en_US` — normalize to lowercase when matching, but match WAD filenames case-insensitively.
+
+## 2. Design decisions
+
+**D1 — Application lives in `ltk_overlay`, not ltk-manager.** The overlay builder already owns game-WAD indexing, chunk conflict resolution, parallel patching, and incremental state. ltk-manager only supplies configuration (which locales to patch). This also satisfies league-mod #97 so the CLI benefits too.
+
+**D2 — Two-track dependency strategy** (never local path deps):
+
+- _Development (now):_ consume `ltk_rst` straight from the PR branch as a git dependency in `ltk_overlay`; consume the in-progress `ltk_overlay` in ltk-manager the same way (git dep on the league-mod feature branch). Cargo.lock pins the exact revs; update deliberately with `cargo update -p ltk_rst` / `-p ltk_overlay` when the branches move.
+- _Release:_ crates.io rejects git dependencies, so publishing still happens in order — merge PR #138 → publish `ltk_rst 0.2.0` → swap `ltk_overlay`'s git dep for the version → publish `ltk_overlay 0.4.0` → swap ltk-manager's git dep for the version. Git deps are a feature-branch-only state; nothing merges to a `main` with a git dep in it.
+
+The YAML dep (`serde_yaml_ng`) goes only into ltk-manager.
+
+**D3 — Merge/priority semantics** (must mirror chunk-conflict ordering so string conflicts and file conflicts resolve identically):
+
+- Iterate enabled mods in the same order the builder uses for chunk overrides; later/higher-priority wins ("last write wins").
+- Within a mod, iterate enabled layers ascending by layer priority.
+- Within a layer, apply the `"default"` locale bucket first, then the locale-specific bucket — so locale-specific beats `"default"` _within the same layer_, but a higher-priority mod's `"default"` still beats a lower-priority mod's locale-specific entry (mod priority dominates).
+
+**D4 — Base-table selection.** If an enabled mod ships a literal `lol.stringtable` chunk override for the target WAD, parse the _winning_ mod-provided chunk as the base instead of the game's copy, then apply key-level overrides on top. Otherwise extract + decompress the game's chunk.
+
+**D5 — Locale targeting.** `ltk_overlay` gets a mode enum; ltk-manager computes it from settings + YAML:
+
+- Toggle OFF (default): patch only the detected current locale.
+- Toggle ON: patch every installed locale (every `Global.*.wad.client` in `Localized/`).
+- Only build a localized WAD when its effective override map is non-empty (or when mods contribute regular chunk overrides to it, as today).
+
+**D6 — Raw-hash key escape hatch (recommended, small).** Accept keys of the form `{hex}` (1–16 hex digits, e.g. `{f772a83b33773223}`) as pre-computed hashes via `Stringtable::insert`, for entries whose plaintext key is unknown. Document in the strings editor.
+
+## 3. Phase 1 — league-toolkit: consume the PR ref now, publish later
+
+Repo: `LeagueToolkit/league-toolkit`, branch `rst-api-rework` (PR #138, open, awaiting review — head `d033293b1bf5e7108615bf00830d49da238c64b5` as of 2026-07-02).
+
+**Interim (unblocks Phase 2 immediately):** depend on the PR branch directly —
+
+```toml
+ltk_rst = { git = "https://github.com/LeagueToolkit/league-toolkit.git", branch = "rst-api-rework" }
+```
+
+Cargo.lock pins the exact rev; if review pushes new commits (or force-pushes), refresh deliberately with `cargo update -p ltk_rst` and re-run the string tests. Watch the PR for API changes requested in review — anything touching `Stringtable`/`RstFormat` surface ripples into `ltk_overlay/src/strings.rs`.
+
+**Release gate (parallel, not blocking development):**
+
+1. Get PR #138 reviewed and merged (assignee: Crauzer; reviewers DexalGT / alanpq).
+2. Bump `crates/ltk_rst/Cargo.toml` version `0.1.0` → `0.2.0` (the PR is a breaking rework of the already-published 0.1.0; the in-branch manifest still says 0.1.0).
+3. Publish `ltk_rst 0.2.0` to crates.io, then swap the git dep for the version (see D2).
+
+No code work needed here beyond the release — the API already covers everything this feature needs (read, keyed insert, hash insert, deterministic write, format handling incl. V2 font-config preservation and 40/39/38-bit splits).
+
+## 4. Phase 2 — league-mod: string application engine in `ltk_overlay`
+
+Repo: `X:\dev\league-mod` (sync with origin first — local shows `ltk_overlay 0.3.0`, crates.io is at 0.3.1). Target version: **`ltk_overlay 0.4.0`**.
+
+### 4.1 New module `crates/ltk_overlay/src/strings.rs`
+
+```rust
+/// Which locales string overrides are applied to.
+pub enum StringOverrideMode {
+ /// Skip string patching entirely.
+ Disabled,
+ /// Patch only these locales (normalized lowercase, e.g. "en_us").
+ Locales(Vec),
+ /// Patch every locale found in Game/DATA/FINAL/Localized.
+ AllInstalled,
+}
+
+/// locale -> field key -> value, merged across mods/layers per D3.
+pub(crate) fn collect_effective_overrides(
+ mods: &mut [EnabledMod],
+ target_locales: &[String],
+) -> Result>>;
+
+/// Parse `original` with ltk_rst, apply `overrides` (incl. `{hex}` raw-hash
+/// keys per D6), serialize back. Preserves the table's read format.
+pub(crate) fn patch_stringtable(
+ original: &[u8],
+ overrides: &HashMap,
+) -> Result>;
+```
+
+- `Cargo.toml`: add `ltk_rst` as the git dep from Phase 1 (swap to `ltk_rst = "0.2"` before publishing `ltk_overlay` — crates.io rejects git deps).
+- `collect_effective_overrides` reads `content.mod_project()` (already exposed by `ModContentProvider`) for each enabled mod, filters to `enabled_layers`, and merges per D3. For each target locale L, effective map = fold over (mod, layer) of `default` bucket then `L` bucket.
+- Installed-locale discovery: scan the game dir for `Localized/Global.*.wad.client` (case-insensitive), extract the locale token from the filename.
+
+### 4.2 Builder integration (`crates/ltk_overlay/src/builder/mod.rs`)
+
+- `OverlayBuilder::with_string_overrides(mode: StringOverrideMode)` (default `Disabled` — non-breaking for existing callers).
+- In `build()`, after `collect_all_override_metadata`/`resolve_overrides_for_wads` and before `patch_wads_parallel`:
+ 1. Emit `OverlayStage::ApplyingStringOverrides` (the reserved variant — first real emission; ltk-manager already maps it).
+ 2. Resolve target locales from the mode; compute effective override maps.
+ 3. For each locale with a non-empty map:
+ - Resolve the game WAD `Global.{locale}.wad.client` via `GameIndex` (verify the index includes the `Localized/` subtree; it should — it lives under `DATA/FINAL`). Missing WAD → `tracing::warn!` and skip.
+ - Compute the chunk hash for `data/menu/{locale}/lol.stringtable` (existing path-hash helpers, xxh64-lowercase — do _not_ reuse modpkg's xxh3 helpers).
+ - Base bytes per D4 (winning mod chunk override if present, else mount game WAD and decompress the chunk).
+ - `patch_stringtable(...)` → register the result as a synthetic override for that WAD/chunk so `partition_wads_from_meta` marks the WAD for building and `build_patched_wad` compresses it like any other override.
+ 4. Ensure a WAD whose _only_ contribution is string overrides still enters the build partition (today a mod with no file content contributes nothing, so the localized WAD would never be scheduled).
+
+### 4.3 Incremental-rebuild correctness
+
+- Fold into the localized WAD's fingerprint: a stable hash of its effective override map + the mode/locale list. Toggling the all-locales setting, changing the client locale, or editing any override must rebuild exactly the affected localized WADs (and removing all overrides must drop the WAD from the overlay).
+- Bump the overlay state schema version → one full rebuild on upgrade (ltk-manager already has force-rebuild-on-version-change from `e7ebe7b`).
+
+### 4.4 Fix `FantomeContent::mod_project()` (`crates/ltk_overlay/src/fantome_content.rs:189–222`)
+
+Parse `META/info.json` layers and map `FantomeLayerInfo.string_overrides` into the returned `ModProjectLayer`s instead of `default_layers()`. Without this, fantome mods never contribute overrides.
+
+### 4.5 Tests (in `ltk_overlay`)
+
+- `patch_stringtable` unit tests against small fixture tables (reuse/borrow the `ltk_rst` test fixtures): keyed override, raw-hash `{hex}` override, untouched entries preserved, deterministic output.
+- Merge-semantics tests for D3 (mod priority beats locale specificity; layer priority; `default` vs locale bucket).
+- Integration test: fixture game dir with a tiny `Localized/Global.en_us.wad.client` containing a tiny stringtable + one modpkg and one fantome mod with overrides → build → mount output WAD → assert patched values; second build with no changes → WAD reused; toggle mode → rebuild.
+
+### 4.6 Release
+
+Changelog, publish `ltk_overlay 0.4.0`. (No `ltk_modpkg`/`ltk_fantome`/`ltk_mod_project` changes required — their formats already carry the data. Optional upstream nicety, non-blocking: make `ltk_fantome`'s extractor round-trip `Layers` on extract; ltk-manager's own importer already does this itself.)
+
+## 5. Phase 3 — ltk-manager integration
+
+### 5.1 Dependencies (`src-tauri/Cargo.toml`)
+
+- `ltk_overlay` — during development, a git dep on the league-mod feature branch carrying Phase 2 (e.g. `{ git = "https://github.com/LeagueToolkit/league-mod.git", branch = "feat/string-override-application" }`); swap to `ltk_overlay = "0.4"` once published, before merging to main.
+- `serde_yaml_ng` (maintained `serde_yaml` fork; upstream `serde_yaml` is archived) — only for reading `LeagueClientSettings.yaml`.
+
+### 5.2 Locale detection — new `src-tauri/src/utils/locale.rs`
+
+```rust
+/// Reads install.globals.locale from /Config/LeagueClientSettings.yaml,
+/// where is the parent of the resolved game dir.
+/// Returns the locale lowercased (e.g. "en_us").
+pub fn detect_league_locale(game_dir: &Utf8Path) -> Option
+```
+
+- Deserialize only the needed nesting (`install.globals.locale`) with permissive structs (`#[serde(default)]` / `Option`s) — the file has many unrelated keys and Riot may add more.
+- Fallback chain: YAML missing/unparseable → scan `Localized/` for `Global.*.wad.client`; exactly one → use it; else `"en_us"` + `tracing::warn!`.
+- Unit tests: fixture YAML (copy the real structure), missing file, missing key.
+
+### 5.3 Settings toggle
+
+- `state.rs`: add `apply_string_overrides_to_all_locales: bool` to `Settings` with `#[serde(default)]` (default `false`).
+- Update the settings TS type (`src/lib/tauri.ts` / generated bindings) and the settings route UI: a `Switch` (from `@/components`), label "Apply string overrides to all installed locales", description explaining default behavior (current locale only).
+
+### 5.4 Overlay wiring (`src-tauri/src/overlay/mod.rs`, `ensure_overlay`)
+
+```rust
+let mode = if settings.apply_string_overrides_to_all_locales {
+ StringOverrideMode::AllInstalled
+} else {
+ StringOverrideMode::Locales(vec![
+ detect_league_locale(&game_dir).unwrap_or_else(|| "en_us".into()),
+ ])
+};
+// builder = builder.with_string_overrides(mode);
+```
+
+- The `ApplyingStringOverrides → OverlayStage::Strings` mapping already exists (`overlay/mod.rs:107`); verify the frontend label for the `Strings` stage in `useOverlayProgress.ts` reads sensibly (e.g. "Applying string overrides").
+
+### 5.5 UI polish (`src/routes/workshop/$projectName/strings.tsx`)
+
+- Remove the "String overrides are not applied yet" `AlertBox` (lines 176–195) and the issue-#123 link.
+- Replace with a short info note documenting semantics: `default` applies to all locales; a locale-specific entry beats `default`; `{hex}` keys target raw hashes.
+- Optional (small): new command `get_league_locale` (follow the 7-step CLAUDE.md command checklist) so the editor can show "Detected game locale: en_US" and pre-select that locale tab.
+
+### 5.6 Verification pass (no expected code changes)
+
+- `get_enabled_mods_for_overlay` (`mods/library.rs:698–769`) needs no changes — providers already expose `mod_project()`.
+- Confirm `with_blocked_wads(...)` never blocks `Localized/Global.*` WADs.
+- Confirm workshop pack (modpkg) and fantome export still emit overrides (they do today; regression-check after dep bumps).
+- `pnpm check` + `cargo clippy -p ltk-manager`.
+
+## 6. Phase 4 — optional follow-ups (separate PRs, not blocking)
+
+1. **Field-name autocomplete / browse** in the strings editor: download + cache CDragon `hashes.rst.xxh3.txt` (~7 MB) in app data; `Combobox` (from `@/components`) suggesting known keys, optionally showing the current default value by reading the game stringtable via `ltk_rst` (would add a direct `ltk_rst` dep to ltk-manager).
+2. **Value preview/diff**: show the original string next to the override for the detected locale.
+3. Upstream `ltk_fantome` extractor round-trip of `Layers` (see 4.6).
+
+## 7. Execution order & release checklist
+
+Development starts immediately on git deps (D2); publishing is deferred to the end.
+
+1. [x] league-mod: sync main; feature branch with `ltk_rst` git dep on `rst-api-rework`; implement Phase 2 (strings module, builder integration, fingerprints, fantome fix, tests). _Done: branch `feat/string-override-application`, commit `093541a`. Note: ltk-manager must take **all** league-mod crates that share types across the `ltk_overlay` API (`ltk_modpkg`, `ltk_mod_project`, `ltk_fantome`) from the same git branch — cargo treats git and crates.io copies as distinct crates._
+2. [x] ltk-manager: Phase 3 on a feature branch (`feat/apply-string-overrides`) with an `ltk_overlay` git dep on the league-mod feature branch. _Done: locale detection (`utils/locale.rs`), `apply_string_overrides_to_all_locales` setting + Patching-section toggle, builder wiring (`resolve_string_override_mode`), strings.tsx info box._
+3. [ ] Manual E2E on this machine (`C:\Riot Games\League of Legends`, locale `en_US`):
+ - Workshop project overriding a known key (pick one from the CDragon list, e.g. a champion/item name) → enable → build overlay → launch → verify in game.
+ - A `.fantome` mod with `StringOverrides` (covers the FantomeContent fix) and a packed/reimported `.modpkg`.
+ - Toggle "all locales" → only localized WADs rebuild; disable mod → overlay drops the localized WAD.
+4. [ ] Release chain (once PR #138 review lands): merge #138 → bump + publish `ltk_rst 0.2.0` → swap `ltk_overlay`'s git dep to the version, merge + publish `ltk_overlay 0.4.0` → swap ltk-manager's git dep to the version → merge `feat/apply-string-overrides`.
+5. [ ] Close ltk-manager #123 and league-mod #97.
+
+## 8. Risks & edge cases
+
+- **RST format drift**: `RstFormat::LATEST` (V5/38-bit/XXH3) matches the live game; if Riot shifts again, `ltk_rst` has detection/pinning (`Stringtable::reader().detect_hash_bits()`). Patch step should log the parsed format.
+- **Hash-algorithm confusion**: RST key hashing (XXH3 since 14.15, masked to 38 bits — `ltk_rst` handles it) is distinct from WAD path hashing (xxh64-lowercase) and modpkg layer/wad hashing (xxh3). Never cross the helpers. (Note: cdragon-rs's xxh64/39-bit is outdated info — trust `ltk_rst`.)
+- **Locale casing**: normalize map keys to lowercase; match WAD filenames case-insensitively (`en_US` in filenames vs `en_us` in data).
+- **User locale not installed / YAML absent** (PBE, region moves, custom installs): fallback chain in 5.2; never fail the build over locale detection.
+- **Mods shipping whole stringtable chunks**: handled by D4; document that key-level overrides always win over a raw shipped table.
+- **Large localized WADs**: only rebuilt when overrides exist or change (4.3); otherwise untouched.
+- **PR #138 churn**: development tracks the unreviewed branch, so review feedback may change the `ltk_rst` API (or force-push the branch) under us. Cargo.lock pins the rev, so breakage only lands on a deliberate `cargo update -p ltk_rst`; budget for small adaptation in `strings.rs`. The merge + publish is still required before anything ships to main (D2).
diff --git a/package.json b/package.json
index ef521953..422b93e3 100644
--- a/package.json
+++ b/package.json
@@ -41,6 +41,7 @@
"@tanstack/react-query": "^5.100.1",
"@tanstack/react-query-devtools": "^5.100.1",
"@tanstack/react-router": "^1.168.23",
+ "@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-deep-link": "^2.4.8",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 156c236b..7f1354a6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -32,6 +32,9 @@ importers:
'@tanstack/react-router':
specifier: ^1.168.23
version: 1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@tanstack/react-table':
+ specifier: ^8.21.3
+ version: 8.21.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@tanstack/react-virtual':
specifier: ^3.13.24
version: 3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -913,6 +916,13 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ '@tanstack/react-table@8.21.3':
+ resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
+ engines: {node: '>=12'}
+ peerDependencies:
+ react: '>=16.8'
+ react-dom: '>=16.8'
+
'@tanstack/react-virtual@3.13.24':
resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==}
peerDependencies:
@@ -957,6 +967,10 @@ packages:
'@tanstack/store@0.9.3':
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
+ '@tanstack/table-core@8.21.3':
+ resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
+ engines: {node: '>=12'}
+
'@tanstack/virtual-core@3.14.0':
resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==}
@@ -3929,6 +3943,12 @@ snapshots:
react-dom: 19.2.5(react@19.2.5)
use-sync-external-store: 1.6.0(react@19.2.5)
+ '@tanstack/react-table@8.21.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ dependencies:
+ '@tanstack/table-core': 8.21.3
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+
'@tanstack/react-virtual@3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@tanstack/virtual-core': 3.14.0
@@ -3992,6 +4012,8 @@ snapshots:
'@tanstack/store@0.9.3': {}
+ '@tanstack/table-core@8.21.3': {}
+
'@tanstack/virtual-core@3.14.0': {}
'@tanstack/virtual-file-routes@1.161.7': {}
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index aea86a85..4ff131a8 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -26,17 +26,20 @@ tauri-plugin-single-instance = "2"
tauri-plugin-autostart = "2"
tauri-plugin-window-state = "2"
-ltk_modpkg = { version = "0.5.0", features = ["project"] }
-ltk_mod_project = { version = "0.4.1" }
+ltk_modpkg = { version = "0.6.0", features = ["project"] }
+ltk_mod_project = { version = "0.5.0" }
ltk_mod_core = { version = "0.1.0" }
-ltk_fantome = { version = "0.5.1" }
-ltk_overlay = { version = "0.3.1" }
+ltk_fantome = { version = "0.6.0" }
+ltk_overlay = { version = "0.4.0" }
+ltk_rst = "0.2.0"
ltk_wad = "0.2.12"
ltk_file = { version = "0.2.8", features = ["serde"] }
camino = "1.2"
+serde_yaml_ng = "0.10"
semver = "1.0"
toml = "0.8"
+indexmap = { version = "2", features = ["serde"] }
tokio = { version = "1", features = ["full"] }
libloading = "0.9.0"
@@ -45,7 +48,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
regex = "1"
-ts-rs = { version = "12", features = ["chrono-impl", "serde-json-impl"] }
+ts-rs = { version = "12", features = ["chrono-impl", "serde-json-impl", "indexmap-impl"] }
thiserror = "2"
anyhow = "1"
diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs
index 542f1e66..6bc7c906 100644
--- a/src-tauri/src/commands/mod.rs
+++ b/src-tauri/src/commands/mod.rs
@@ -29,6 +29,7 @@ mod profiles;
mod settings;
mod shell;
mod storage;
+mod strings;
mod workshop;
pub use app::*;
@@ -44,4 +45,5 @@ pub use profiles::*;
pub use settings::*;
pub use shell::*;
pub use storage::*;
+pub use strings::*;
pub use workshop::*;
diff --git a/src-tauri/src/commands/strings.rs b/src-tauri/src/commands/strings.rs
new file mode 100644
index 00000000..d8a11942
--- /dev/null
+++ b/src-tauri/src/commands/strings.rs
@@ -0,0 +1,37 @@
+use crate::error::{AppError, AppResult, IpcResult, MutexResultExt};
+use crate::state::SettingsState;
+use crate::strings::{StringKeyIndexState, StringKeySearchResult};
+use tauri::{AppHandle, State};
+
+/// Search known stringtable field names for the workshop strings editor.
+///
+/// The first call builds the suggestion index (downloading the CommunityDragon
+/// key list when missing and reading the game stringtable for value previews),
+/// so it can take a few seconds; subsequent calls are instant.
+#[tauri::command]
+pub fn search_string_keys(
+ query: String,
+ limit: Option,
+ app_handle: AppHandle,
+ settings: State,
+ index: State,
+) -> IpcResult {
+ search_string_keys_inner(&query, limit, &app_handle, &settings, &index).into()
+}
+
+fn search_string_keys_inner(
+ query: &str,
+ limit: Option,
+ app_handle: &AppHandle,
+ settings: &SettingsState,
+ index: &StringKeyIndexState,
+) -> AppResult {
+ let settings = settings.0.lock().mutex_err()?.clone();
+ let cache_dir = crate::state::get_app_data_dir(app_handle)
+ .ok_or_else(|| AppError::Other("Could not determine app data directory".to_string()))?
+ .join("hashes");
+
+ let index = index.get_or_build(&cache_dir, &settings)?;
+ let limit = limit.unwrap_or(50).min(200) as usize;
+ Ok(index.search(query, limit))
+}
diff --git a/src-tauri/src/commands/workshop.rs b/src-tauri/src/commands/workshop.rs
index 03b2b6d0..dfaccbca 100644
--- a/src-tauri/src/commands/workshop.rs
+++ b/src-tauri/src/commands/workshop.rs
@@ -5,6 +5,7 @@ use crate::workshop::{
ImportGitRepoArgs, PackProjectArgs, PackResult, SaveProjectConfigArgs, ValidationResult,
WorkshopLayerInfo, WorkshopProject, WorkshopState,
};
+use indexmap::IndexMap;
use std::collections::HashMap;
use tauri::State;
@@ -166,7 +167,7 @@ pub fn get_project_thumbnail(
pub fn save_layer_string_overrides(
project_path: String,
layer_name: String,
- string_overrides: HashMap>,
+ string_overrides: IndexMap>,
workshop: State,
) -> IpcResult {
workshop
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index 124e9b00..21113a82 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -17,6 +17,7 @@ pub mod patcher;
mod setup;
mod state;
mod storage;
+mod strings;
mod tray;
mod utils;
mod workshop;
@@ -140,6 +141,7 @@ fn main() {
commands::remove_project_thumbnail,
commands::get_project_thumbnail,
commands::save_layer_string_overrides,
+ commands::search_string_keys,
commands::get_layer_content_path,
commands::get_layer_info,
commands::create_project_layer,
diff --git a/src-tauri/src/overlay/mod.rs b/src-tauri/src/overlay/mod.rs
index 8f7c214e..ab9a16d1 100644
--- a/src-tauri/src/overlay/mod.rs
+++ b/src-tauri/src/overlay/mod.rs
@@ -93,12 +93,16 @@ impl ModLibrary {
let blocked_wads = resolve_blocked_wads(settings, &available_wads);
tracing::info!("Overlay: blocked_wads count={}", blocked_wads.len());
+ let string_override_mode = resolve_string_override_mode(settings, &game_dir);
+ tracing::info!("Overlay: string_override_mode={:?}", string_override_mode);
+
Self::clean_corrupt_overlay_state(&utf8_state_dir);
let app_handle_clone = self.app_handle().clone();
let mut builder =
ltk_overlay::OverlayBuilder::new(utf8_game_dir, utf8_overlay_root, utf8_state_dir)
.with_blocked_wads(blocked_wads)
+ .with_string_overrides(string_override_mode)
.with_progress(move |progress| {
let stage = match progress.stage {
ltk_overlay::OverlayStage::Indexing => OverlayStage::Indexing,
@@ -308,6 +312,27 @@ impl ModLibrary {
}
}
+/// Resolve which locales mods' string overrides should be applied to.
+///
+/// With the "all locales" setting on, every installed locale is patched.
+/// Otherwise only the locale the League client is configured to use — read
+/// from `LeagueClientSettings.yaml`, falling back to the sole installed locale
+/// and finally to `en_us` so string overrides still apply on unusual installs.
+pub(crate) fn resolve_string_override_mode(
+ settings: &Settings,
+ game_dir: &Path,
+) -> ltk_overlay::StringOverrideMode {
+ if settings.apply_string_overrides_to_all_locales {
+ return ltk_overlay::StringOverrideMode::AllInstalled;
+ }
+
+ let locale = crate::utils::locale::detect_league_locale(game_dir).unwrap_or_else(|| {
+ tracing::warn!("Falling back to 'en_us' for string overrides");
+ "en_us".to_string()
+ });
+ ltk_overlay::StringOverrideMode::Locales(vec![locale])
+}
+
/// Resolve the user's blocklist settings into a concrete, deduped list of WAD
/// filenames to hand to `ltk_overlay::OverlayBuilder::with_blocked_wads`.
///
diff --git a/src-tauri/src/setup.rs b/src-tauri/src/setup.rs
index fc490449..e751545d 100644
--- a/src-tauri/src/setup.rs
+++ b/src-tauri/src/setup.rs
@@ -49,6 +49,7 @@ pub fn run(app: &mut tauri::App) -> Result<(), Box> {
app.manage(settings_state);
app.manage(patcher_state);
app.manage(LinkedBinState::default());
+ app.manage(crate::strings::StringKeyIndexState::default());
app.manage(mod_library);
app.manage(workshop);
app.manage(hotkey_manager);
diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs
index 7afda860..6c14b39c 100644
--- a/src-tauri/src/state.rs
+++ b/src-tauri/src/state.rs
@@ -266,6 +266,11 @@ pub struct Settings {
/// downgraded to warnings and flagged mods load anyway. Default: true.
#[serde(default = "default_true")]
pub enforce_skinhack_scan: bool,
+ /// Whether mods' string overrides are applied to every installed locale
+ /// instead of only the locale the League client is configured to use.
+ /// Default: false (current locale only).
+ #[serde(default)]
+ pub apply_string_overrides_to_all_locales: bool,
}
impl Default for Settings {
@@ -301,6 +306,7 @@ impl Default for Settings {
elevate_injector: false,
auto_categorization_enabled: true,
enforce_skinhack_scan: true,
+ apply_string_overrides_to_all_locales: false,
}
}
}
@@ -328,6 +334,7 @@ mod tests {
assert!(settings.wad_blocklist.is_empty());
assert!(settings.auto_categorization_enabled);
assert!(settings.enforce_skinhack_scan);
+ assert!(!settings.apply_string_overrides_to_all_locales);
}
#[test]
@@ -370,6 +377,7 @@ mod tests {
elevate_injector: false,
auto_categorization_enabled: true,
enforce_skinhack_scan: true,
+ apply_string_overrides_to_all_locales: false,
};
let json = serde_json::to_string(&settings).unwrap();
let deserialized: Settings = serde_json::from_str(&json).unwrap();
diff --git a/src-tauri/src/strings/mod.rs b/src-tauri/src/strings/mod.rs
new file mode 100644
index 00000000..ec0d4579
--- /dev/null
+++ b/src-tauri/src/strings/mod.rs
@@ -0,0 +1,333 @@
+//! Autocomplete index for stringtable field names.
+//!
+//! Combines the community-maintained key-name list (CommunityDragon
+//! `hashes.rst.xxh3.txt`) with current values from the game's own stringtable
+//! for the detected locale, so the workshop strings editor can suggest valid
+//! field names and show what each one currently says in game.
+
+use crate::error::{AppError, AppResult, MutexResultExt};
+use crate::state::Settings;
+use serde::Serialize;
+use std::io::Cursor;
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, Mutex};
+use std::time::Duration;
+use ts_rs::TS;
+
+const HASH_LIST_URL: &str =
+ "https://raw.githubusercontent.com/CommunityDragon/Data/master/hashes/lol/hashes.rst.xxh3.txt";
+const HASH_LIST_FILE: &str = "hashes.rst.xxh3.txt";
+const HASH_LIST_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
+
+/// One autocomplete suggestion for a stringtable field.
+#[derive(Debug, Clone, Serialize, TS)]
+#[ts(export)]
+#[serde(rename_all = "camelCase")]
+pub struct StringKeySuggestion {
+ /// Field name, e.g. `game_character_displayname_ahri`.
+ pub key: String,
+ /// What the game's stringtable currently says for this field in the
+ /// indexed locale, when the key exists there.
+ pub value: Option,
+}
+
+/// Result of a suggestion query.
+#[derive(Debug, Clone, Serialize, TS)]
+#[ts(export)]
+#[serde(rename_all = "camelCase")]
+pub struct StringKeySearchResult {
+ pub suggestions: Vec,
+ /// Total number of known field names in the index.
+ pub total_keys: u32,
+ /// Locale whose stringtable supplied the `value` previews, when available.
+ pub locale: Option,
+}
+
+struct IndexEntry {
+ key: String,
+ value: Option,
+}
+
+/// In-memory suggestion index: known field names (sorted) joined with the
+/// game's current values.
+pub struct StringKeyIndex {
+ entries: Vec,
+ locale: Option,
+}
+
+impl StringKeyIndex {
+ /// Build the index from the cached/downloaded key list and, best-effort,
+ /// the game stringtable of the detected locale.
+ fn build(cache_dir: &Path, settings: &Settings) -> AppResult {
+ let key_list = load_key_list(cache_dir)?;
+
+ let table = load_game_stringtable(settings);
+ let locale = table.as_ref().map(|(locale, _)| locale.clone());
+
+ let mut entries: Vec = key_list
+ .lines()
+ .filter_map(|line| {
+ let (hex, name) = line.split_once(' ')?;
+ let name = name.trim();
+ if name.is_empty() {
+ return None;
+ }
+ let value = table.as_ref().and_then(|(_, table)| {
+ let hash = u64::from_str_radix(hex.trim(), 16).ok()?;
+ table.get(hash).map(str::to_string)
+ });
+ Some(IndexEntry {
+ key: name.to_string(),
+ value,
+ })
+ })
+ .collect();
+
+ entries.sort_by(|a, b| a.key.cmp(&b.key));
+ entries.dedup_by(|a, b| a.key == b.key);
+
+ tracing::info!(
+ "String key index built: {} keys, values from locale {:?}",
+ entries.len(),
+ locale
+ );
+
+ Ok(Self { entries, locale })
+ }
+
+ /// Rank matching keys for `query`: prefix matches first, then substring
+ /// matches, both in alphabetical order. An empty query lists the first
+ /// `limit` keys for discovery.
+ pub fn search(&self, query: &str, limit: usize) -> StringKeySearchResult {
+ let query = query.trim().to_lowercase();
+
+ let mut prefix: Vec<&IndexEntry> = Vec::new();
+ let mut contains: Vec<&IndexEntry> = Vec::new();
+ if query.is_empty() {
+ prefix.extend(self.entries.iter().take(limit));
+ } else {
+ for entry in &self.entries {
+ if prefix.len() >= limit {
+ break;
+ }
+ if entry.key.starts_with(&query) {
+ prefix.push(entry);
+ } else if contains.len() < limit && entry.key.contains(&query) {
+ contains.push(entry);
+ }
+ }
+ }
+
+ let suggestions = prefix
+ .into_iter()
+ .chain(contains)
+ .take(limit)
+ .map(|entry| StringKeySuggestion {
+ key: entry.key.clone(),
+ value: entry.value.clone(),
+ })
+ .collect();
+
+ StringKeySearchResult {
+ suggestions,
+ total_keys: self.entries.len() as u32,
+ locale: self.locale.clone(),
+ }
+ }
+}
+
+/// Lazily-built, app-managed [`StringKeyIndex`].
+#[derive(Default)]
+pub struct StringKeyIndexState(Mutex
diff --git a/src/modules/settings/components/PatchingSection.tsx b/src/modules/settings/components/PatchingSection.tsx
index 17e1f973..2b94ea15 100644
--- a/src/modules/settings/components/PatchingSection.tsx
+++ b/src/modules/settings/components/PatchingSection.tsx
@@ -46,6 +46,25 @@ export function PatchingSection({ settings, onSave }: PatchingSectionProps) {
/>
+
+