Compare commits

..
2 Commits
Author SHA1 Message Date
poison-flower 7c81bcbeab загружаю 2026-09-07 19:12:21 +03:00
poison-flower 1485819556 загружаю 2026-09-07 19:07:40 +03:00
15 changed files with 1272 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""
Скрипт компиляции .txt файлов обратно в .cst.
Запускать из папки translate_util.
Спрашивает, из какой папки брать .txt файлы (по умолчанию txt_translated)
и в какую папку положить итоговые .cst (по умолчанию update01).
mc.exe запускается из папки txt и кладёт результат (.cst) туда же,
рядом с .txt. Скрипт копирует .txt файлы из исходной папки в txt,
компилирует их, а затем переносит получившиеся .cst в целевую папку
(перезаписывая существующие без вопросов).
"""
import shutil
import subprocess
import sys
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
TOOL_EXE = BASE_DIR / "tool" / "mc.exe"
TXT_DIR = BASE_DIR / "txt"
def main():
if not TOOL_EXE.exists():
print(f"Ошибка: не найден {TOOL_EXE}")
sys.exit(1)
src_input = input("Из какой папки брать .txt файлы? [txt_translated]: ").strip()
src_dir_name = src_input if src_input else "txt_translated"
src_dir = BASE_DIR / src_dir_name
dst_input = input("В какую папку положить скомпилированные .cst? [update01]: ").strip()
dst_dir_name = dst_input if dst_input else "update01"
dst_dir = BASE_DIR / dst_dir_name
if not src_dir.is_dir():
print(f"Ошибка: папка не найдена: {src_dir}")
sys.exit(1)
TXT_DIR.mkdir(exist_ok=True)
dst_dir.mkdir(exist_ok=True)
txt_files = sorted(src_dir.glob("*.txt"))
if not txt_files:
print(f"В папке {src_dir} не найдено .txt файлов.")
sys.exit(0)
print(f"Найдено файлов: {len(txt_files)}")
print(f"Источник .txt: {src_dir}")
print(f"Рабочая папка компиляции: {TXT_DIR}")
print(f"Итоговая папка .cst: {dst_dir}\n")
ok_count = 0
fail_count = 0
same_dir = src_dir.resolve().samefile(TXT_DIR.resolve()) if src_dir.exists() and TXT_DIR.exists() else False
for txt_file in txt_files:
working_txt = TXT_DIR / txt_file.name
# Копируем исходный .txt в рабочую папку txt (перезаписываем, если есть),
# если источник и есть txt — копировать не нужно
if not same_dir:
shutil.copy2(txt_file, working_txt)
print(f"-> {txt_file.name}")
try:
result = subprocess.run(
[str(TOOL_EXE), working_txt.name],
cwd=str(TXT_DIR),
capture_output=True,
text=True,
)
if result.stdout:
print(result.stdout.strip())
if result.stderr:
print(result.stderr.strip())
if result.returncode != 0:
fail_count += 1
print(f" [!] Код возврата: {result.returncode}")
continue
compiled_cst = TXT_DIR / (working_txt.stem + ".cst")
if not compiled_cst.exists():
fail_count += 1
print(f" [!] Ожидаемый файл не найден: {compiled_cst}")
continue
# Переносим результат в целевую папку, перезаписывая без вопросов
dst_cst = dst_dir / compiled_cst.name
shutil.move(str(compiled_cst), str(dst_cst))
ok_count += 1
except Exception as e:
fail_count += 1
print(f" [!] Ошибка запуска: {e}")
print(f"\nГотово. Успешно: {ok_count}, с ошибками: {fail_count}")
if __name__ == "__main__":
main()
+24
View File
@@ -0,0 +1,24 @@
{
"api": {
"base_url": "https://example.com/api/v1",
"api_key": "sk-example",
"model": "google/gemini-3.8-flash",
"temperature": 0.3,
"max_tokens": 4092,
"reasoning_effort": "medium",
"timeout_seconds": 120
},
"translation": {
"source_language": "English",
"target_language": "Russian",
"batch_size": 10,
"context_before": 2,
"context_after": 2,
"max_retries": 3,
"retry_delay_seconds": 5
},
"paths": {
"json_dir": "json",
"glossary_file": "glossary.md"
}
}
+256
View File
@@ -0,0 +1,256 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""
Универсальный скрипт для локализации CatSystem2 (Shift-JIS).
2 в 1: Извлечение в .nsv и обратная сборка в .txt с поддержкой поля translated_text.
Поддерживает аргументы: --txt2nsv и --nsv2txt
Формат хранения — NSV (Newline-Separated Values, см. nsv.py) вместо JSON:
компактнее на диске, лучше диффы в git. LLM (в translate.py) никогда не видит
NSV-экранирование — оно разбирается и собирается заново только здесь и в
nsv.py; наружу и в промпт всегда идут обычные Python-строки.
"""
import re
import sys
import shutil
from pathlib import Path
from nsv_io import load_nsv_file, save_nsv_file
BASE_DIR = Path(__file__).resolve().parent
SRC_ENCODING = "cp932" # Shift-JIS
PURE_CONTROL_TOKENS = {"\\r", "\\p", "\\n", "\\@"}
def is_text_line(raw_line: str) -> bool:
stripped = raw_line.strip()
if not stripped or stripped in PURE_CONTROL_TOKENS:
return False
if "[" in raw_line or "「" in raw_line or "」" in raw_line:
return True
return False
def parse_speaker_and_text(raw_line: str):
"""Строгий разбор формата CatSystem2: 'Имя\\tТекст' или '\\tТекст' (без имени).
Разделитель между именем и текстом — ровно один таб, ничего больше.
Если строка начинается с таба — имени нет, текст идёт с отступом.
"""
if raw_line.startswith("\t"):
return None, raw_line[1:]
if "\t" in raw_line:
speaker, text = raw_line.split("\t", 1)
return speaker, text
# Строка без единого таба под критерии is_text_line не должна попадать,
# но на случай мусорных данных не теряем её молча.
return None, raw_line
def extract_entries(lines):
entries = []
pending_pcm = None
pcm_re = re.compile(r"^\s*pcm\s+(\S+)\s*$")
for idx, raw in enumerate(lines):
line_no = idx + 1
stripped = raw.strip()
pcm_match = pcm_re.match(stripped)
if pcm_match:
pending_pcm = pcm_match.group(1)
continue
if is_text_line(raw):
speaker, text = parse_speaker_and_text(raw)
entries.append({
"line": line_no,
"speaker": speaker,
"pcm": pending_pcm,
"text": text,
"translated_text": "",
})
pending_pcm = None
return entries
def txt_to_nsv():
print("\n--- РЕЖИМ 1: ИЗВЛЕЧЕНИЕ В .nsv ---")
src_dir = BASE_DIR / (input("Папка с исходными .txt [txt]: ").strip() or "txt")
dst_dir = BASE_DIR / (input("Папка для .nsv [translate]: ").strip() or "translate")
if not src_dir.is_dir():
print(f"Ошибка: папка не найдена: {src_dir}")
return
dst_dir.mkdir(exist_ok=True)
txt_files = sorted(src_dir.glob("*.txt"))
ok_count, fail_count = 0, 0
for txt_file in txt_files:
nsv_file = dst_dir / (txt_file.stem + ".nsv")
try:
# errors="replace" здесь осознанно: если в исходном декомпилированном
# .txt попадётся байт, нечитаемый в cp932, лучше получить "?" и продолжить,
# чем упасть на самом первом шаге пайплайна. Итоговый .nsv всё равно
# редактируется человеком/LLM дальше, ошибка будет заметна визуально.
with open(txt_file, "r", encoding=SRC_ENCODING, errors="replace", newline="") as f:
lines = [ln.rstrip("\r\n") for ln in f]
entries = extract_entries(lines)
save_nsv_file(nsv_file, entries)
ok_count += 1
print(f"-> {txt_file.name} (строк: {len(entries)})")
except Exception as e:
fail_count += 1
print(f"[!] Ошибка {txt_file.name}: {e}")
print(f"\nГотово. Успешно: {ok_count}, Ошибок: {fail_count}\n")
def fix_llm_symbols(text: str) -> str:
"""Заменяет символы от LLM, от которых падает Shift-JIS"""
replacements = {
'ё': 'е', 'Ё': 'Е',
'—': '―', '–': '-',
'«': '"', '»': '"',
'“': '"', '”': '"',
'‘': "'", '’': "'",
'…': '...',
'\u200b': '',
'\u00a0': ' ',
'\u2014': '―',
'\u2013': '-',
}
for bad, good in replacements.items():
text = text.replace(bad, good)
return text
def nsv_to_txt():
print("\n--- РЕЖИМ 2: СБОРКА ИЗ .nsv В TXT ---")
print("Какое поле с текстом использовать?")
print(" 1 - Использовать базовое поле 'text'")
print(" 2 - Использовать поле 'translated_text' (с откатом к 'text')")
while True:
mode_input = input("Ваш выбор [2]: ").strip()
if not mode_input:
mode = 2
break
elif mode_input in ("1", "2"):
mode = int(mode_input)
break
else:
print("Пожалуйста, введите 1 или 2.")
txt_input = input("Папка с ОРИГИНАЛЬНЫМИ .txt [txt]: ").strip() or "txt"
nsv_input = input("Папка с ПЕРЕВЕДЕННЫМИ .nsv [translate]: ").strip() or "translate"
out_input = input("Куда сохранить готовые .txt [txt_translated]: ").strip() or "txt_translated"
txt_dir = BASE_DIR / txt_input
nsv_dir = BASE_DIR / nsv_input
out_dir = BASE_DIR / out_input
if not nsv_dir.is_dir() or not txt_dir.is_dir():
print("Ошибка: папки с исходниками или переводами не найдены.")
return
out_dir.mkdir(exist_ok=True)
nsv_files = sorted(nsv_dir.glob("*.nsv"))
ok_count, fail_count = 0, 0
for nsv_file in nsv_files:
txt_file = txt_dir / (nsv_file.stem + ".txt")
out_file = out_dir / txt_file.name
temp_txt_file = out_dir / f"~temp_{txt_file.name}"
if not txt_file.exists():
print(f"[!] Нет оригинального txt для {nsv_file.name}, пропускаю...")
continue
try:
shutil.copy2(txt_file, temp_txt_file)
with open(temp_txt_file, "r", encoding=SRC_ENCODING, errors="replace", newline="") as f:
orig_lines = f.readlines()
entries = load_nsv_file(nsv_file)
for entry in entries:
idx = entry["line"] - 1
if idx >= len(orig_lines):
continue
if mode == 2:
text_raw = entry.get("translated_text") or entry.get("text", "")
else:
text_raw = entry.get("text", "")
text = fix_llm_symbols(text_raw)
speaker = entry.get("speaker")
orig_line = orig_lines[idx]
newline_chars = "\r\n" if orig_line.endswith("\r\n") else "\n"
clean_orig = orig_line.rstrip("\r\n")
if speaker:
orig_lines[idx] = f"{speaker}\t{text}{newline_chars}"
else:
m = re.match(r"^(\s+)", clean_orig)
sep = m.group(1) if m else " "
orig_lines[idx] = f"{sep}{text}{newline_chars}"
# errors="strict": если после fix_llm_symbols остался символ,
# не представимый в cp932, лучше упасть с понятной ошибкой сейчас,
# чем молча получить "?" в игре после компиляции.
with open(out_file, "w", encoding=SRC_ENCODING, errors="strict", newline="") as f:
f.writelines(orig_lines)
ok_count += 1
print(f"<- {nsv_file.name} собран успешно")
except Exception as e:
fail_count += 1
print(f"[!] Ошибка {nsv_file.name}: {e}")
finally:
if temp_txt_file.exists():
try:
temp_txt_file.unlink()
except Exception:
pass
print(f"\nГотово. Успешно: {ok_count}, Ошибок: {fail_count}\n")
def main():
if "--txt2nsv" in sys.argv or "--txt2json" in sys.argv:
txt_to_nsv()
return
elif "--nsv2txt" in sys.argv or "--json2txt" in sys.argv:
nsv_to_txt()
return
print("=== Утилита локализации CatSystem2 ===")
print("1. Разобрать игру (TXT -> NSV)")
print("2. Собрать перевод (NSV -> TXT)")
choice = input("Ваш выбор (1 или 2): ").strip()
if choice == '1':
txt_to_nsv()
elif choice == '2':
nsv_to_txt()
else:
print("Неверный выбор. До свидания!")
if __name__ == "__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""
Скрипт декомпиляции .cst файлов.
Запускать из папки translate_util.
Спрашивает, в какой папке лежат .cst файлы (по умолчанию update00/text),
и декомпилирует все .cst файлы из неё в папку txt с помощью
tool\\cs2_decompile.exe.
"""
import subprocess
import sys
from pathlib import Path
# Корень translate_util — папка, откуда запущен скрипт
BASE_DIR = Path(__file__).resolve().parent
TOOL_EXE = BASE_DIR / "tool" / "cs2_decompile.exe"
TXT_DIR = BASE_DIR / "txt"
def main():
if not TOOL_EXE.exists():
print(f"Ошибка: не найден {TOOL_EXE}")
sys.exit(1)
src_input = input("В какой папке лежат .cst файлы? [update00/text]: ").strip()
src_dir_name = src_input if src_input else "update00/text"
src_dir = BASE_DIR / src_dir_name
if not src_dir.is_dir():
print(f"Ошибка: папка не найдена: {src_dir}")
sys.exit(1)
TXT_DIR.mkdir(exist_ok=True)
cst_files = sorted(src_dir.glob("*.cst"))
if not cst_files:
print(f"В папке {src_dir} не найдено .cst файлов.")
sys.exit(0)
print(f"Найдено файлов: {len(cst_files)}")
print(f"Источник: {src_dir}")
print(f"Результат будет в: {TXT_DIR}\n")
ok_count = 0
fail_count = 0
for cst_file in cst_files:
print(f"-> {cst_file.name}")
try:
result = subprocess.run(
[str(TOOL_EXE), str(cst_file)],
cwd=str(TXT_DIR),
capture_output=True,
text=True,
)
if result.stdout:
print(result.stdout.strip())
if result.stderr:
print(result.stderr.strip())
if result.returncode == 0:
ok_count += 1
else:
fail_count += 1
print(f" [!] Код возврата: {result.returncode}")
except Exception as e:
fail_count += 1
print(f" [!] Ошибка запуска: {e}")
print(f"\nГотово. Успешно: {ok_count}, с ошибками: {fail_count}")
if __name__ == "__main__":
main()
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""
Интерактивный скрипт распаковки текстов из игр на CatSystem2.
Извлекает только .cst и .cstl файлы, учитывая порядок файлов обновлений.
"""
import subprocess
import sys
import shutil
import tempfile
from pathlib import Path
# Путь к утилите exkifint_v3.exe
BASE_DIR = Path(__file__).resolve().parent
TOOL_EXE = BASE_DIR / "tool" / "exkifint_v3.exe"
def clean_path(user_input: str) -> Path:
return Path(user_input.strip().strip('"').strip("'"))
def main():
if not TOOL_EXE.exists():
print(f"Ошибка: Исполняемый файл утилиты не найден: {TOOL_EXE}")
sys.exit(1)
# 1. Запрос пути к папке с игрой
game_dir_input = input("Укажите путь к папке с игрой: ")
game_dir = clean_path(game_dir_input)
if not game_dir.is_dir():
print(f"Ошибка: Папка '{game_dir}' не существует.")
sys.exit(1)
# 2. Поиск exe-файлов и выбор
exe_files = list(game_dir.glob("*.exe"))
if not exe_files:
print("В указанной папке не найдено .exe файлов!")
sys.exit(1)
print("\nНайденные исполняемые файлы:")
for i, exe in enumerate(exe_files, 1):
print(f"[{i}] {exe.name}")
exe_choice = input("\nУкажите цифру нужного exe и нажмите Enter: ").strip()
try:
exe_index = int(exe_choice) - 1
if exe_index < 0 or exe_index >= len(exe_files):
raise ValueError
selected_exe = exe_files[exe_index]
except ValueError:
print("Ошибка: Неверный выбор.")
sys.exit(1)
# 3. Название папки вывода
out_name = input("\nНазвание папки куда складывать извлечённые файлы [update00 по умолчанию]: ").strip()
if not out_name:
out_name = "update00" # Значение по умолчанию
out_dir = BASE_DIR / out_name
text_dir = out_dir / "text"
loc_dir = out_dir / "localized_text"
# Создаем целевые папки
text_dir.mkdir(parents=True, exist_ok=True)
loc_dir.mkdir(parents=True, exist_ok=True)
# Список базовых архивов и апдейтов в порядке приоритета (как в unpack.py)
target_archives = ["scene.int", "fes.int", "config.int"]
for i in range(20): # Добавляем update00.int - update19.int
target_archives.append(f"update{i:02d}.int")
print(f"\nНачинаю извлечение текста. Папка вывода: {out_dir}")
cst_count = 0
cstl_count = 0
# 4. Распаковка во временную папку для фильтрации
# Используем tempfile, чтобы мусор автоматически удалился после завершения
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Распаковываем архивы по очереди. Файлы из апдейтов будут
# перезаписывать базовые во временной папке.
for archive_name in target_archives:
archive_path = game_dir / archive_name
if archive_path.exists():
print(f"Обработка архива: {archive_name}...")
cmd = [str(TOOL_EXE), str(archive_path), str(selected_exe)]
subprocess.run(
cmd,
cwd=str(temp_path),
capture_output=True,
text=True,
encoding="cp1251",
errors="replace"
)
# 5. Поиск и перенос только нужных текстовых форматов
print("Сортировка извлеченных файлов...")
for file in temp_path.rglob("*"):
if file.is_file():
if file.suffix.lower() == ".cst":
shutil.copy2(file, text_dir / file.name)
cst_count += 1
elif file.suffix.lower() == ".cstl":
shutil.copy2(file, loc_dir / file.name)
cstl_count += 1
# Временная папка со всем нетекстовым мусором автоматически удаляется здесь
print(f"\nГотово!")
print(f"Извлечено .cst файлов: {cst_count} -> {text_dir}")
print(f"Извлечено .cstl файлов: {cstl_count} -> {loc_dir}")
if __name__ == "__main__":
main()
+15
View File
@@ -0,0 +1,15 @@
## Characters
- Michiru: Мичиру
- Sachi: Сачи
- Amane: Амане
- Asako: Асако
- Kazuki: Казуки
- Makina: Макина
- Nyanmel: Нянмел
## Terms
- Idol: Айдол
- Magical Girl: Девочка-волшебница
## Notes
- Michiru обращается к другим неформально, используй разговорный стиль речи.
- Сохраняй восклицательные интонации персонажа Michiru — она энергичная и громкая.
- НИКОГДА не использу систему поливанова. Не Си, а Ши, не Ти, а Чи и так далее.
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""
names.py — сбор уникальных имён спикеров из .nsv файлов в CSV-таблицу
для перевода, и обратное применение переведённых имён в .nsv файлы.
Файлы .nsv всегда в UTF-8 (пишутся convert.py/translate.py) — здесь нет
нужды в автоопределении кодировки, в отличие от более ранней версии этого
скрипта, где угадывание cp932/shift_jis/utf-8 могло молча испортить текст
на символах вроде "①", которых нет в cp932, но которые cp932 способен
"прочитать" без ошибки, просто дав кракозябры.
"""
import csv
from pathlib import Path
from nsv_io import load_nsv_file, save_nsv_file
def get_target_dir():
folder_input = input("Укажите путь к папке с .nsv файлами [по умолчанию: translate]: ").strip()
folder_path = folder_input.strip('"\'') if folder_input else "translate"
target_dir = Path(folder_path)
if not target_dir.is_dir():
print(f"Ошибка: папка '{target_dir}' не найдена.\n")
return None
return target_dir
def mode_extract():
target_dir = get_target_dir()
if not target_dir:
return
nsv_files = list(target_dir.rglob("*.nsv"))
if not nsv_files:
print(f"В папке '{target_dir}' не найдено файлов .nsv.\n")
return
print(f"Найдено .nsv файлов: {len(nsv_files)}. Сбор имён...")
unique_speakers = set()
for file_path in nsv_files:
try:
entries = load_nsv_file(file_path)
except Exception as e:
print(f"Пропуск файла {file_path.name} (не удалось прочитать: {e})")
continue
for e in entries:
speaker = e.get("speaker")
if speaker is not None and speaker.strip():
unique_speakers.add(speaker.strip())
output_csv = Path(__file__).resolve().parent / "namestable.csv"
sorted_speakers = sorted(unique_speakers)
with open(output_csv, "w", encoding="utf-8-sig", newline="") as f:
writer = csv.writer(f)
writer.writerow(["Original", "Translated"])
for speaker in sorted_speakers:
writer.writerow([speaker, ""])
print(f"\nГотово! Уникальных имён собрано: {len(sorted_speakers)}")
print(f"Файл сохранён: {output_csv}\n")
def mode_apply():
csv_path = Path(__file__).resolve().parent / "namestable.csv"
if not csv_path.is_file():
print(f"Ошибка: файл '{csv_path.name}' рядом со скриптом не найден.\n")
return
translation_map = {}
with open(csv_path, "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
orig = row.get("Original", "").strip()
trans = row.get("Translated", "").strip()
if orig and trans:
translation_map[orig] = trans
if not translation_map:
print("В таблице namestable.csv нет заполненных колонок 'Translated'. Нечего применять.\n")
return
print(f"Загружено переводов для замены: {len(translation_map)}")
target_dir = get_target_dir()
if not target_dir:
return
nsv_files = list(target_dir.rglob("*.nsv"))
if not nsv_files:
print(f"В папке '{target_dir}' не найдено файлов .nsv.\n")
return
print(f"Обработка {len(nsv_files)} файлов...")
total_replaced = 0
modified_files = 0
for file_path in nsv_files:
try:
entries = load_nsv_file(file_path)
except Exception as e:
print(f"Пропуск {file_path.name}: не удалось прочитать ({e}).")
continue
replaced_in_file = 0
for e in entries:
speaker = e.get("speaker")
if speaker is not None:
raw_val = speaker.strip()
if raw_val in translation_map:
e["speaker"] = translation_map[raw_val]
replaced_in_file += 1
if replaced_in_file > 0:
save_nsv_file(file_path, entries)
total_replaced += replaced_in_file
modified_files += 1
print(f"\nГотово!")
print(f"Изменено файлов: {modified_files}")
print(f"Всего произведено замен: {total_replaced}\n")
def main():
while True:
print("=== Меню ===")
print("1. Собрать уникальные имена в namestable.csv")
print("2. Применить перевод из namestable.csv обратно в .nsv")
print("0. Выход")
choice = input("Выберите действие [1/2/0]: ").strip()
print()
if choice == "1":
mode_extract()
elif choice == "2":
mode_apply()
elif choice == "0":
break
else:
print("Неверный ввод, попробуйте снова.\n")
if __name__ == "__main__":
main()
+37
View File
@@ -0,0 +1,37 @@
"""nsv_io.py — чтение/запись .nsv диалоговых файлов через пакет nsv."""
from pathlib import Path
import nsv
FIELDS = ("line", "speaker", "pcm", "text", "translated_text")
def load_nsv_file(path: Path) -> list[dict]:
with open(path, "r", encoding="utf-8", newline="") as f:
it = iter(nsv.load(f))
next(it, None)
entries = []
for line, speaker, pcm, text, translated_text in it:
entries.append({
"line": int(line or 0),
"speaker": speaker or None,
"pcm": pcm or None,
"text": text,
"translated_text": translated_text,
})
return entries
def save_nsv_file(path: Path, entries: list[dict]) -> None:
rows = [list(FIELDS)]
for e in entries:
rows.append([
str(e.get("line", "")),
e.get("speaker") or "",
e.get("pcm") or "",
e.get("text", ""),
e.get("translated_text", ""),
])
with open(path, "w", encoding="utf-8", newline="") as f:
nsv.dump(rows, f)
+41
View File
@@ -0,0 +1,41 @@
You are a professional video game / visual novel localization translator.
Keep your reasoning effort medium-low.
Translate the given lines from {source_lang} to {target_lang}.
CRITICAL FORMATTING RULES — the game engine will break if you don't follow these exactly:
1. The text contains word-by-word bracket markup like [word] [word] [word,] — this
marks timing boundaries used to sync voice-over playback. You MUST preserve this
same bracket structure: wrap your translated text in the same number and style of
square brackets, splitting the translated sentence into a similar number of
bracketed chunks. Do not remove or merge the brackets.
2. The text may contain backslash control tags such as \f26;\pc, \n, \p, \@,
\fl...\fn, \w0; and similar — each backslash is a SINGLE character, exactly as
shown here. These are engine formatting/typewriter-effect codes, NOT part of the
visible text. You must keep every such tag exactly as-is (single backslash, same
spelling), in the same relative position (start/middle/end of the line), and never
translate, remove, duplicate, or alter them.
3. Japanese-style quotation marks 「 and 」 (if present) should be preserved as-is;
do not replace them with regular quotes.
4. Do not add, remove, or reorder any lines. Only translate the visible natural-language
words; leave all markup, punctuation-as-markup, and tags untouched.
5. Preserve line breaks and whitespace structure implied by the brackets.
You will receive:
- An optional glossary of character names, terms, and style notes. Follow it strictly.
- Some CONTEXT lines before and after the lines you need to translate, for tone and
continuity, formatted the same way as the lines to translate: "[<line number>] <text>".
Do NOT translate or return the context lines — they are for reference only.
- A list of lines to translate under "=== LINES TO TRANSLATE ===", one per line, each
formatted as: [<line number>] <text>
Example:
[84] [|\f26;\pc「Hello] [there.」]
[96] [Some] [more] [text.」]
Return ONLY a JSON array, with no extra commentary, no markdown code fences, in this
exact format:
[{{"line": <line_number>, "text": "<translated text with original markup preserved>"}}, ...]
The array must contain exactly one entry for every line number you were asked to
translate — no more, no less. This JSON array is the ONLY place you use JSON string
escaping (e.g. a literal backslash in the text becomes \\ inside the JSON string) —
the input lines above are plain text, not JSON, and do not use JSON escaping.
+1
View File
@@ -0,0 +1 @@
nsv==0.2.4
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
start.py — главное меню утилиты перевода визуальной новеллы.
Запускать из папки translate_util:
python start.py
Показывает список доступных шагов и запускает выбранный скрипт.
"""
import subprocess
import sys
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
MENU_ITEMS = [
{
"key": "1",
"title": "Extract — достать файлы из архива",
"desc": "Достаёт файлы из int архива и помещает их в одноимённую папку",
"script": "extract.py",
},
{
"key": "2",
"title": "Decompile — достать текст из .cst файлов",
"desc": "Берёт зашифрованные .cst файлы (например из update00) и превращает их в читаемые .txt файлы в папке txt.",
"script": "decompile.py",
},
{
"key": "3",
"title": "Txt to NSV — подготовить текст к переводу",
"desc": "Превращает .txt файлы из папки txt в .nsv файлы в папке json — компактный текстовый формат, удобный для перевода и для git-диффов.",
"script": "convert.py --txt2nsv",
},
{
"key": "4",
"title": "Names — скрап всех имён",
"desc": "Собирает все имена из .nsv файлов и помещает в CSV в формате original,translated",
"script": "names.py",
},
{
"key": "5",
"title": "Перевод",
"desc": "Перевод с помощью OpenAI-соместимой LLM (Gemini, Claude, ChatGPT и т.д.)",
"script": "translate.py",
},
{
"key": "6",
"title": "NSV to Txt — собрать текст после перевода",
"desc": "Берёт переведённые .nsv файлы и вставляет текст обратно в .txt (структура и команды не трогаются). Результат — в папке txt_translated.",
"script": "convert.py --nsv2txt",
},
{
"key": "7",
"title": "Compile — собрать .cst обратно для игры",
"desc": "Компилирует .txt файлы (по умолчанию из txt_translated) обратно в .cst и кладёт их в папку update01.",
"script": "compile.py",
},
]
def print_menu():
print("=" * 60)
print(" Утилита перевода визуальной новеллы — главное меню")
print("=" * 60)
print()
for item in MENU_ITEMS:
print(f" {item['key']}. {item['title']}")
print(f" {item['desc']}")
print()
print(" 0. Выход")
print()
print("=" * 60)
def run_script(command_str: str):
parts = command_str.split()
script_name = parts[0]
args = parts[1:]
script_path = BASE_DIR / script_name
if not script_path.exists():
print(f"Ошибка: не найден файл {script_path}")
return
print(f"\nЗапуск: {command_str}\n")
run_cmd = [sys.executable, str(script_path)] + args
subprocess.run(run_cmd)
def main():
while True:
print_menu()
choice = input("Выберите пункт (0-7): ").strip()
if choice == "0":
break
matched = next((item for item in MENU_ITEMS if item["key"] == choice), None)
if matched:
run_script(matched["script"])
input("\nНажмите Enter, чтобы вернуться в меню...")
else:
print("\nНеверный выбор, попробуйте ещё раз.")
input("Нажмите Enter, чтобы продолжить...")
if __name__ == "__main__":
main()
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+339
View File
@@ -0,0 +1,339 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""
translate.py — перевод .nsv файлов через OpenAI-совместимый API.
Включает глобальный кэш переводов, сырое логирование запросов,
все системные файлы аккуратно складываются в папку _translate_data.
"""
import json
import re
import sys
import time
import urllib.request
import urllib.error
import logging
from datetime import datetime
from pathlib import Path
from nsv_io import load_nsv_file, save_nsv_file
BASE_DIR = Path(__file__).resolve().parent
CONFIG_PATH = BASE_DIR / "config.json"
PROMPT_PATH = BASE_DIR / "prompt.txt"
# --------------------------------------------------------------------------
# Настройка системных папок (кэш, логи)
# --------------------------------------------------------------------------
SYS_DIR = BASE_DIR / "_translate_data"
SYS_DIR.mkdir(exist_ok=True)
CACHE_PATH = SYS_DIR / "translation_cache.json"
LOG_DIR = SYS_DIR / "translate_logs"
LOG_DIR.mkdir(exist_ok=True)
log_filename = LOG_DIR / f"translation_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
RAW_LOG_DIR = SYS_DIR / "raw_api_logs"
RAW_LOG_DIR.mkdir(exist_ok=True)
raw_log_filename = RAW_LOG_DIR / f"raw_llm_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jsonl"
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
handlers=[
logging.FileHandler(log_filename, encoding="utf-8"),
logging.StreamHandler(sys.stdout)
]
)
def log_raw_api(request_body, raw_response_dict, error_msg=None):
"""Пишет сырой запрос и ответ в jsonl файл для дебага."""
entry = {
"timestamp": datetime.now().isoformat(),
"request": request_body,
"response": raw_response_dict,
"error": error_msg
}
with open(raw_log_filename, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
# --------------------------------------------------------------------------
# Загрузка конфига, глоссария, промпта и кэша
# --------------------------------------------------------------------------
def load_config():
if not CONFIG_PATH.exists():
logging.error(f"Ошибка: не найден файл конфигурации {CONFIG_PATH}")
sys.exit(1)
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def load_glossary(glossary_path: Path) -> str:
if not glossary_path.exists():
return ""
with open(glossary_path, "r", encoding="utf-8") as f:
return f.read().strip()
def load_prompt() -> str:
if not PROMPT_PATH.exists():
logging.error(f"Ошибка: не найден файл промпта {PROMPT_PATH}. Создайте его.")
sys.exit(1)
with open(PROMPT_PATH, "r", encoding="utf-8") as f:
return f.read()
def load_cache() -> dict:
if CACHE_PATH.exists():
try:
with open(CACHE_PATH, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError:
logging.warning("Предупреждение: файл кэша поврежден, создаем новый.")
return {}
def save_cache(cache_data: dict):
with open(CACHE_PATH, "w", encoding="utf-8") as f:
json.dump(cache_data, f, ensure_ascii=False, indent=2)
# --------------------------------------------------------------------------
# Фильтр текста
# --------------------------------------------------------------------------
def sanitize_text(text: str) -> str:
"""Очищает текст от символов, несовместимых со стандартным Shift-JIS (cp932)."""
if not text:
return ""
replacements = {
'—': '―', '–': '-', '−': '-',
'\xa0': ' ', '\u200b': '', '\u200c': '', '\u200d': '', '\ufeff': '',
'…': '...', '№': '#', '́': ''
}
for bad_char, good_char in replacements.items():
text = text.replace(bad_char, good_char)
return text
# --------------------------------------------------------------------------
# Построение промпта
# --------------------------------------------------------------------------
def format_glossary_block(glossary_text: str) -> str:
if not glossary_text:
return ""
return f"\n=== GLOSSARY ===\n{glossary_text}\n=== END GLOSSARY ===\n"
def format_context_block(label: str, entries) -> str:
if not entries:
return ""
lines_str = "\n".join(f'[{e["line"]}] {e["text"]}' for e in entries)
return f"\n=== {label} (reference only, do not translate) ===\n{lines_str}\n"
def format_batch_block(entries) -> str:
lines_str = "\n".join(f'[{e["line"]}] {e["text"]}' for e in entries)
return f"\n=== LINES TO TRANSLATE ===\n{lines_str}\n"
def build_messages(cfg, glossary_text, context_before_entries, batch_entries, context_after_entries):
tr_cfg = cfg["translation"]
system_prompt = load_prompt().format(source_lang=tr_cfg["source_language"], target_lang=tr_cfg["target_language"])
system_prompt += format_glossary_block(glossary_text)
user_parts = [
format_context_block("CONTEXT BEFORE", context_before_entries),
format_context_block("CONTEXT AFTER", context_after_entries),
format_batch_block(batch_entries)
]
return [
{"role": "system", "content": system_prompt},
{"role": "user", "content": "".join(p for p in user_parts if p)}
]
# --------------------------------------------------------------------------
# Вызов API
# --------------------------------------------------------------------------
def call_llm(cfg, messages):
api_cfg = cfg["api"]
url = api_cfg["base_url"].rstrip("/") + "/chat/completions"
body = {
"model": api_cfg["model"],
"messages": messages,
"max_tokens": api_cfg.get("max_tokens", 4000),
"temperature": api_cfg.get("temperature", 0.3),
}
if "reasoning_effort" in api_cfg:
body["reasoning_effort"] = api_cfg["reasoning_effort"]
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
url, data=data,
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_cfg['api_key']}"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=api_cfg.get("timeout_seconds", 120)) as resp:
resp_body = resp.read().decode("utf-8")
resp_json = json.loads(resp_body)
log_raw_api(body, resp_json)
return resp_json["choices"][0]["message"]["content"]
except urllib.error.URLError as e:
log_raw_api(body, None, str(e))
raise
def extract_json_array(text: str):
original_text = text
text = text.strip()
fence_match = re.search(r"```(?:json)?\s*(.*?)```", text, re.DOTALL)
if fence_match:
text = fence_match.group(1).strip()
if not text.startswith("["):
start, end = text.find("["), text.rfind("]")
if start != -1 and end != -1 and end > start:
text = text[start:end + 1]
try:
return json.loads(text)
except json.decoder.JSONDecodeError as e:
logging.error(f"\n [!] Ошибка парсинга JSON: {e}")
logging.error(f" [!] Сырой ответ:\n{original_text}\n")
raise
# --------------------------------------------------------------------------
# Основная логика перевода
# --------------------------------------------------------------------------
def get_translated_map(entries):
return {e["line"]: e["translated_text"] for e in entries if e.get("translated_text")}
def build_context_before(entries, idx, n, translated_map):
return [{"line": e["line"], "text": translated_map.get(e["line"], e["text"])} for e in entries[max(0, idx - n):idx]]
def build_context_after(entries, idx_end, n):
return [{"line": e["line"], "text": e["text"]} for e in entries[idx_end:idx_end + n]]
def translate_batch(cfg, glossary_text, entries, batch_idx_list, translated_map):
tr_cfg = cfg["translation"]
batch_entries = [entries[idx] for idx in batch_idx_list]
context_before = build_context_before(entries, batch_idx_list[0], tr_cfg["context_before"], translated_map)
context_after = build_context_after(entries, batch_idx_list[-1] + 1, tr_cfg["context_after"])
messages = build_messages(cfg, glossary_text, context_before, batch_entries, context_after)
expected_lines = {e["line"] for e in batch_entries}
max_retries = tr_cfg.get("max_retries", 3)
retry_delay = tr_cfg.get("retry_delay_seconds", 5)
last_error = None
for attempt in range(1, max_retries + 1):
try:
raw_response = call_llm(cfg, messages)
parsed = extract_json_array(raw_response)
result_map = {}
for item in parsed:
if "line" not in item or "text" not in item:
raise ValueError(f"Элемент без line/text: {item}")
result_map[item["line"]] = sanitize_text(item["text"])
if set(result_map.keys()) != expected_lines:
raise ValueError("Несовпадение строк в ответе.")
return result_map, None
except Exception as e:
last_error = e
if attempt < max_retries:
logging.warning(f" [!] Попытка {attempt}/{max_retries} ошибка: {e}. Повтор через {retry_delay}с...")
time.sleep(retry_delay)
else:
logging.error(f" [!] Все попытки исчерпаны: {e}")
return None, last_error
def translate_file(cfg, glossary_text, nsv_path: Path, global_cache: dict):
entries = load_nsv_file(nsv_path)
cache_applied = 0
for e in entries:
if not e.get("translated_text"):
orig_text = e.get("text", "")
if orig_text in global_cache:
e["translated_text"] = global_cache[orig_text]
cache_applied += 1
if cache_applied > 0:
logging.info(f" Восстановлено из кэша: {cache_applied} строк.")
save_nsv_file(nsv_path, entries)
translated_map = get_translated_map(entries)
line_to_idx = {e["line"]: i for i, e in enumerate(entries)}
pending_indices = [i for i, e in enumerate(entries) if not e.get("translated_text")]
if not pending_indices:
logging.info(" Все строки переведены, пропускаем.")
return []
batch_size = cfg["translation"]["batch_size"]
total_pending = len(pending_indices)
done_count = 0
problem_batches = []
logging.info(f" К переводу через API: {total_pending} строк")
i = 0
while i < len(pending_indices):
batch_idx_list = pending_indices[i:i + batch_size]
result_map, error = translate_batch(cfg, glossary_text, entries, batch_idx_list, translated_map)
if result_map is not None:
for line_no, translated_text in result_map.items():
idx = line_to_idx.get(line_no)
if idx is not None:
entries[idx]["translated_text"] = translated_text
translated_map[line_no] = translated_text
global_cache[entries[idx]["text"]] = translated_text
done_count += len(batch_idx_list)
else:
problem_batches.append({"lines": [entries[idx]["line"] for idx in batch_idx_list], "error": str(error)})
logging.info(f" Прогресс API: {min(done_count + sum(len(b['lines']) for b in problem_batches), total_pending)}/{total_pending} строк")
save_nsv_file(nsv_path, entries)
save_cache(global_cache)
i += batch_size
return problem_batches
# --------------------------------------------------------------------------
# main
# --------------------------------------------------------------------------
def main():
cfg = load_config()
paths_cfg = cfg["paths"]
global_cache = load_cache()
nsv_dir = BASE_DIR / paths_cfg["json_dir"]
glossary_path = BASE_DIR / paths_cfg["glossary_file"]
if not nsv_dir.is_dir():
logging.error(f"Ошибка: папка не найдена: {nsv_dir}")
sys.exit(1)
glossary_text = load_glossary(glossary_path)
all_nsv_files = sorted(nsv_dir.glob("*.nsv"))
if not all_nsv_files:
logging.error(f"В {nsv_dir} нет .nsv файлов.")
sys.exit(0)
choice = input(f"\nНайдено {len(all_nsv_files)} файлов. Имена через запятую (или 'all'): ").strip()
selected_files = all_nsv_files if choice.lower() == "all" else [nsv_dir / f"{n.strip()}.nsv" for n in choice.split(",") if (nsv_dir / f"{n.strip()}.nsv").exists()]
all_problems = {}
for nsv_path in selected_files:
logging.info(f"\n=== {nsv_path.name} ===")
problems = translate_file(cfg, glossary_text, nsv_path, global_cache)
if problems:
all_problems[nsv_path.name] = problems
logging.info("\n" + "=" * 60)
if all_problems:
logging.warning("Перевод завершён с проблемами в некоторых пачках. Запустите скрипт повторно.")
else:
logging.info("Перевод завершён успешно.")
if __name__ == "__main__":
main()