Russian narration for the Enshrouded notes: mod 0.4.0, installer, generation pipeline

This commit is contained in:
2026-10-18 15:56:52 +03:00
commit 73dee2d786
557 changed files with 4051 additions and 0 deletions
+209
View File
@@ -0,0 +1,209 @@
"""Step 1: from the installed game to work/notes.json + the EN originals as 24 kHz WAV.
- kfc-parser unpacks the descriptors we need (voiceover, sound, localisation, journal);
- localisation tags of EN and RU are read straight from the .dat content (24-byte tag records);
- every journal entry with a voiceover joins its EN sound and its EN/RU text;
- the RU text is turned into the text for the voice (tags off, dashes to commas, numbers in words);
- the EN audio (frameless QOA, 48 kHz) is decoded to work/en24/<k>.wav with its loudness.
The game files are only read. Keys n000..n524 follow the EN sound names sorted.
A game with this mod (or any EML mod) applied has a PATCHED enshrouded.kfc: its index then points to the
Russian takes. EML appends new content and keeps the original index as enshrouded.kfc.bak, so the
originals are read through work/orig: a copy of the .bak as enshrouded.kfc plus hard links to the .dat
and .kfc_resources files (the work folder must then be on the game's drive; otherwise run
`tools/emm.exe restore` first — the mod is applied again at the next game launch).
"""
import glob
import json
import os
import re
import shutil
import struct
import subprocess
import sys
import wave
from multiprocessing import Pool
import numpy as np
from .config import load
from .kfc import KFC
from .qoa import decode
from .ru_num import normalize
TYPES = ["VoiceoverResource", "SoundContainerResource", "SoundResource",
"LocaTagCollectionResource", "JournalRegistryResource"]
def source(cfg):
"""The game folder with the ORIGINAL index (see the module docstring)."""
bak = cfg.game / "enshrouded.kfc.bak"
if not bak.exists():
return cfg.game
orig = cfg.w("orig", "x").parent
shutil.copyfile(bak, orig / "enshrouded.kfc")
for f in list(cfg.game.glob("enshrouded_*.dat")) + [cfg.game / "enshrouded.kfc_resources"]:
dst = orig / f.name
if dst.exists():
dst.unlink()
try:
os.link(f, dst)
except OSError:
raise SystemExit(f"the game has EML mods applied and {orig} cannot hard-link {f.name} "
f"(another drive?): put paths.work on the game's drive, or run "
f"'{cfg.tools / 'emm.exe'} restore -g {cfg.game}' and extract again")
print("reading the original index from enshrouded.kfc.bak (the game has EML mods applied)")
return orig
def unpack(cfg, src):
out = cfg.w("unpack")
if all(glob.glob(str(out / t / "*.json")) for t in TYPES):
print("unpack: already done"); return out
exe = cfg.tools / "kfc-parser.exe"
flt = ",".join(f"tkeen::{t}" for t in TYPES)
subprocess.run([str(exe), "unpack", "-g", str(src), "-o", str(out), "-f", flt], check=True)
return out
def by_guid(d, t):
return {os.path.basename(f).split("_")[0]: f for f in glob.glob(str(d / t / "*.json"))}
def loca(cfg, kfc, d):
res = json.load(open(glob.glob(str(d / "LocaTagCollectionResource" / "*.json"))[0], encoding="utf-8"))
langs = {l["language"]: l["data"] for l in res["languages"]}
out = {}
for name in ("En_Us", "Ru_Ru"):
data = kfc.read_hash(langs[name]["hash"])
off, cnt = struct.unpack_from("<II", data, 0)
tags = {}
for i in range(cnt):
p = off + i * 24
tid, so, sl = struct.unpack_from("<III", data, p)
tags[str(tid)] = data[p + 4 + so:p + 4 + so + sl].decode("utf-8", "replace")
out[name] = tags
print("loca", name, len(tags), "tags")
return out
def voice_index(d):
sc, sr = by_guid(d, "SoundContainerResource"), by_guid(d, "SoundResource")
rows = []
for f in glob.glob(str(d / "VoiceoverResource" / "*.json")):
vo = json.load(open(f, encoding="utf-8"))
en = vo["languages"][0] # fixed slots EN/DE/FR/ES/ZH
if en not in sc:
continue
for e in json.load(open(sc[en], encoding="utf-8"))["entries"]:
s = e["$value"].get("sound")
if s in sr:
r = json.load(open(sr[s], encoding="utf-8"))
rows.append(dict(vo=vo["$guid"], sound=s, name=r["debugName"],
sec=r["frameCount"] / r["framesPerSecond"], h=r["streamedData"]["hash"]))
return rows
def journal(d):
j = json.load(open(glob.glob(str(d / "JournalRegistryResource" / "*.json"))[0], encoding="utf-8"))
m = {}
def walk(o):
if isinstance(o, dict):
if o.get("voiceover") and "text" in o:
m[o["voiceover"]] = (o["name"]["value"], o["text"]["value"])
for v in o.values():
walk(v)
elif isinstance(o, list):
for v in o:
walk(v)
walk(j)
return m
def screen(t):
return re.sub(r"<[^>]+>", "", t or "")
YO = {"еще": "ещё", "все равно": "всё равно", "трясется": "трясётся", "придется": "придётся", "намек": "намёк",
"идет": "идёт", "ее ": "её ", "черный": "чёрный", "темн": "тёмн", "зеленый": "зелёный", "живет": "живёт",
"дает": "даёт", "берет": "берёт"}
def voice_text(t):
"""The RU screen text -> the text for the voice (the subtitles keep the screen text)."""
t = screen(t)
t = re.sub(r"[ \t]+\n", "\n", t); t = re.sub(r"\n[ \t]+", "\n", t)
t = re.sub(r"^\s*[—–-]\s*", "", t, flags=re.M) # list dashes at a line start
t = re.sub(r"\s+[—–]\s+", ", ", t) # a dash makes a long pause: a comma
t = re.sub(r"(\w)-(то|ка|нибудь|либо)\b", r"\1 \2", t)
for a, b in YO.items():
t = re.sub(r"\b" + a, b, t)
t = re.sub(r"\n{3,}", "\n\n", t).strip()
t = normalize(t).strip()
return re.sub(r"\.{4,}", "...", t)
_FIR = None
def _en_job(args):
game, k, r, out = args
global _FIR
if _FIR is None:
n = np.arange(-48, 49)
_FIR = np.sinc(n * 0.46) * np.hamming(len(n)); _FIR /= _FIR.sum()
s = np.asarray(decode(KFC(game).read_hash(r["h"]), int(r["en_sec"] * 48000)), np.float64)
v = s[::5]; v = v[np.abs(v) > 200]
rms = float(np.sqrt(np.mean(v * v))) if len(v) else 1000.0
y = np.convolve(s, _FIR, mode="same")[::2]
with wave.open(out, "wb") as w:
w.setnchannels(1); w.setsampwidth(2); w.setframerate(24000)
w.writeframes(np.clip(y, -32768, 32767).astype("<i2").tobytes())
return k, rms
def main():
cfg = load()
src = source(cfg)
d = unpack(cfg, src)
kfc = KFC(src)
L = loca(cfg, kfc, d)
rows = voice_index(d)
byvo = {}
for r in rows:
byvo.setdefault(r["vo"], []).append(r)
notes = []
for vo, (nm, tx) in journal(d).items():
for r in byvo.get(vo, []):
ru = L["Ru_Ru"].get(str(tx))
if not ru:
continue
notes.append(dict(name=r["name"], vo=vo, sound=r["sound"], en_sec=r["sec"], h=r["h"],
title_en=screen(L["En_Us"].get(str(nm))), en=L["En_Us"].get(str(tx)),
ru_screen=screen(ru), text=voice_text(ru)))
notes.sort(key=lambda x: x["name"])
for i, n in enumerate(notes):
n["k"] = f"n{i:03d}"
os.makedirs(cfg.w("en24", "x").parent, exist_ok=True)
jobs = [(str(src), n["k"], n, str(cfg.w("en24", f"{n['k']}.wav"))) for n in notes
if not os.path.exists(cfg.w("en24", f"{n['k']}.wav"))]
rms = {}
if os.path.exists(cfg.w("en_rms.json")):
rms = json.load(open(cfg.w("en_rms.json")))
with Pool(max(1, (os.cpu_count() or 2) - 1)) as p:
for k, v in p.imap_unordered(_en_job, jobs, chunksize=1):
rms[k] = v
json.dump(rms, open(cfg.w("en_rms.json"), "w"))
for n in notes:
n["en_rms"] = rms.get(n["k"])
json.dump(notes, open(cfg.w("notes.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print("notes", len(notes), "EN minutes", round(sum(n["en_sec"] for n in notes) / 60),
"RU chars", sum(len(n["text"]) for n in notes))
if len(notes) != 525:
print("WARNING: expected 525 voiced notes (game 1.0, 2026-10); a game update may have changed them",
file=sys.stderr)
if __name__ == "__main__":
main()