загружаю
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
# translate_util
|
||||
|
||||
Набор скриптов для перевода визуальной новеллы на движке CatSystem2.
|
||||
|
||||
Полный путь: игра → распаковка архивов → декомпиляция скриптов → перевод → сборка обратно → компиляция для игры.
|
||||
|
||||
## Требования
|
||||
|
||||
- Python 3.9+
|
||||
- Windows (нужны `.exe`-утилиты в папке `tool/`)
|
||||
- Ключ доступа к любому OpenAI-совместимому API (OpenAI, OpenRouter, локальный сервер и т.д.)
|
||||
|
||||
### Python-зависимости
|
||||
|
||||
Всё, кроме одного пакета, — стандартная библиотека Python (`json`, `re`, `csv`, `pathlib`, `subprocess`, `urllib`, `logging` и т.д.), ничего дополнительно ставить не нужно.
|
||||
|
||||
Единственная внешняя зависимость — официальная реализация формата NSV:
|
||||
|
||||
```
|
||||
pip install nsv
|
||||
```
|
||||
|
||||
Это готовый пакет с [PyPI](https://pypi.org/project/nsv/) (исходники: [nsv-format/nsv-python](https://github.com/nsv-format/nsv-python)), под Windows ставится как готовый wheel — компилятор не нужен. Без него `convert.py`, `names.py` и `translate.py` не запустятся (упадут с `ModuleNotFoundError: No module named 'nsv'`).
|
||||
|
||||
Наш файл `nsv_io.py` — тонкая обёртка над этим пакетом: сам он не парсит и не экранирует NSV, а только переводит между "строка формата NSV" и "запись перевода с полями line/speaker/pcm/text/translated_text", специфичными для этого проекта. `nsv_io.py` должен лежать рядом с `convert.py`, `names.py`, `translate.py` — они делают `from nsv_io import ...`.
|
||||
|
||||
Проверить, что Python установлен и виден из консоли:
|
||||
|
||||
```
|
||||
python --version
|
||||
```
|
||||
|
||||
Если команда не найдена — установите Python с [python.org](https://www.python.org/downloads/) (при установке на Windows отметьте галочку "Add python.exe to PATH").
|
||||
|
||||
### Сторонние утилиты (не Python-пакеты)
|
||||
|
||||
Эти `.exe` — не часть репозитория, их нужно положить в папку `tool/` самостоятельно (взять из готового тулкита для CatSystem2 или найти по названию):
|
||||
|
||||
| Файл | Для чего | Используется в |
|
||||
|---|---|---|
|
||||
| `exkifint_v3.exe` | распаковка `.int`-архивов игры | `extract.py` |
|
||||
| `cs2_decompile.exe` | декомпиляция `.cst` → `.txt` | `decompile.py` |
|
||||
| `mc.exe` | компиляция `.txt` → `.cst` обратно | `compile.py` |
|
||||
|
||||
Без них соответствующие шаги пайплайна работать не будут — остальные шаги (Txt to NSV, Names, Перевод, NSV to Txt) от них не зависят и работают на чистом Python.
|
||||
|
||||
## Структура папок
|
||||
|
||||
```
|
||||
translate_util/
|
||||
├── start.py запускать отсюда
|
||||
├── extract.py
|
||||
├── decompile.py
|
||||
├── convert.py
|
||||
├── names.py
|
||||
├── translate.py
|
||||
├── compile.py
|
||||
├── nsv_io.py обёртка над пакетом nsv под схему записей проекта
|
||||
├── config.json настройки API и перевода
|
||||
├── glossary.md глоссарий (имена, термины, заметки по стилю)
|
||||
├── prompt.txt системный промпт для LLM
|
||||
├── namestable.csv создаётся скриптом names.py
|
||||
├── tool/ exkifint_v3.exe, cs2_decompile.exe, mc.exe
|
||||
├── txt/ декомпилированные .txt (шаг 2)
|
||||
├── json/ .nsv файлы для перевода (шаг 3-5)
|
||||
├── txt_translated/ собранные переведённые .txt (шаг 6)
|
||||
├── update00/, update01/ оригинальные и переведённые архивы игры
|
||||
└── _translate_data/ кэш переводов и логи (создаётся автоматически)
|
||||
```
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```
|
||||
python start.py
|
||||
```
|
||||
|
||||
Покажет меню на 7 пунктов — можно проходить шаги по порядку, каждый раз возвращаясь в меню.
|
||||
|
||||
```
|
||||
1. Extract — достать файлы из архива игры (.int → .cst/.cstl)
|
||||
2. Decompile — .cst → .txt
|
||||
3. Txt to NSV — .txt → .nsv (подготовка к переводу)
|
||||
4. Names — собрать/применить перевод имён персонажей
|
||||
5. Перевод — прогнать текст через LLM
|
||||
6. NSV to Txt — .nsv → .txt (сборка перевода обратно)
|
||||
7. Compile — .txt → .cst (готово для игры)
|
||||
```
|
||||
|
||||
Каждый скрипт можно запускать и напрямую (`python decompile.py`, `python translate.py` и т.д.) — меню лишь избавляет от необходимости помнить порядок и имена файлов.
|
||||
|
||||
## Шаг за шагом
|
||||
|
||||
### 1. Extract — достать файлы из игры
|
||||
|
||||
```
|
||||
python extract.py
|
||||
```
|
||||
|
||||
Спросит путь к папке с игрой и `.exe` игры (для определения версии движка). Извлекает `.cst` (тексты) и `.cstl` (уже локализованные, если есть) файлы из всех архивов (`scene.int`, `fes.int`, `config.int`, `update00.int`...`update19.int`), учитывая, что апдейты перезаписывают базовые файлы. Результат кладётся в папку, которую вы укажете (по умолчанию `extracted_texts/text` и `extracted_texts/localized_text`).
|
||||
|
||||
### 2. Decompile — .cst → .txt
|
||||
|
||||
```
|
||||
python decompile.py
|
||||
```
|
||||
|
||||
Спросит папку с `.cst` (по умолчанию `update00`). Декомпилирует все файлы через `tool/cs2_decompile.exe` в папку `txt` (кодировка Shift-JIS/CP932).
|
||||
|
||||
### 3. Txt to NSV — подготовка к переводу
|
||||
|
||||
```
|
||||
python convert.py --txt2nsv
|
||||
```
|
||||
|
||||
Спросит папку с `.txt` (по умолчанию `txt`) и папку для результата (по умолчанию `json`). Разбирает каждый `.txt`, находит строки с текстом (отличает их от игровых команд), и сохраняет в компактном текстовом формате `.nsv` — по одной записи на реплику, с полями: номер строки, спикер, id войсовера (pcm), оригинальный текст, перевод (изначально пустой).
|
||||
|
||||
`.nsv` — обычный текстовый файл, его можно открыть и посмотреть в любом редакторе. Сам формат — [спецификация NSV](https://github.com/nsv-format/nsv), парсится пакетом `nsv`; маппинг на конкретные поля (line/speaker/pcm/text/translated_text) описан в `nsv_io.py`.
|
||||
|
||||
### 4. Names — перевод имён персонажей
|
||||
|
||||
```
|
||||
python names.py
|
||||
```
|
||||
|
||||
Меню на 2 пункта:
|
||||
|
||||
- **1 — собрать имена**: проходит по всем `.nsv` в указанной папке, собирает все уникальные значения поля "спикер" и складывает их в `namestable.csv` (в корне `translate_util`) с пустой колонкой `Translated`.
|
||||
- **2 — применить перевод**: читает `namestable.csv`, и для каждой строки, где колонка `Translated` заполнена, заменяет спикера во всех `.nsv` файлах.
|
||||
|
||||
Откройте `namestable.csv` (Excel, Google Таблицы, любой редактор с поддержкой UTF-8) и заполните колонку `Translated` для нужных имён, затем запустите пункт 2.
|
||||
|
||||
Пример содержимого:
|
||||
|
||||
```csv
|
||||
Original,Translated
|
||||
Michiru,Мичиру
|
||||
Sachi,Сачи
|
||||
Fan|A,Фанат|А
|
||||
```
|
||||
|
||||
Строки с пустым `Translated` игнорируются — можно переводить имена постепенно.
|
||||
|
||||
### 5. Перевод текста через LLM
|
||||
|
||||
Перед первым запуском настройте `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"api": {
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"api_key": "sk-ваш-ключ",
|
||||
"model": "gpt-4o",
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 4092,
|
||||
"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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Параметр | Что значит |
|
||||
|---|---|
|
||||
| `base_url` | адрес API — для OpenAI, OpenRouter или локального сервера (LM Studio, Ollama и т.д.) |
|
||||
| `api_key` | ваш ключ |
|
||||
| `model` | название модели у выбранного провайдера |
|
||||
| `temperature` | ниже — переводит более предсказуемо и буквально, выше — вольнее |
|
||||
| `max_tokens` | лимит длины ответа на один запрос |
|
||||
| `reasoning_effort` | необязательное поле, если модель поддерживает режимы рассуждения (например `"low"`/`"medium"`/`"high"`) |
|
||||
| `batch_size` | сколько реплик отправляется в LLM за один запрос |
|
||||
| `context_before` / `context_after` | сколько соседних реплик показывать модели для контекста (до — уже переведённые, после — оригинал) |
|
||||
| `max_retries` / `retry_delay_seconds` | сколько раз и с какой паузой повторять запрос при сбое |
|
||||
|
||||
Отредактируйте `glossary.md` — впишите имена персонажей, термины и заметки по стилю перевода. Формат:
|
||||
|
||||
```markdown
|
||||
## Characters
|
||||
- Michiru: Мичиру
|
||||
|
||||
## Terms
|
||||
- Idol: Айдол
|
||||
|
||||
## Notes
|
||||
- Michiru обращается к другим неформально.
|
||||
```
|
||||
|
||||
Запуск:
|
||||
|
||||
```
|
||||
python translate.py
|
||||
```
|
||||
|
||||
Спросит, какие `.nsv` файлы переводить — имена файлов через запятую (без расширения) или `all` для всех. Дальше работает автоматически:
|
||||
|
||||
- бьёт текст на пачки и отправляет в LLM вместе с глоссарием и контекстом соседних реплик;
|
||||
- пишет перевод сразу в файл после каждой пачки — можно прервать (Ctrl+C) и продолжить позже, ничего не потеряется;
|
||||
- строки, у которых перевод уже есть, пропускаются — safe перезапускать сколько угодно раз;
|
||||
- при сбое или странном ответе модели — повторяет запрос; если не получилось после всех попыток — идёт дальше, а в конце выводит список проблемных мест для повторного прогона;
|
||||
- одинаковый исходный текст переводится один раз и берётся из кэша при повторных встречах (файл `_translate_data/translation_cache.json`) — экономит и деньги, и время.
|
||||
|
||||
Логи каждого запуска — в `_translate_data/translate_logs/`, сырые запросы/ответы API — в `_translate_data/raw_api_logs/` (полезно для отладки, если модель ведёт себя странно).
|
||||
|
||||
Если после прогона остались проблемные пачки — просто запустите `python translate.py` ещё раз для тех же файлов, уже переведённое не тронется.
|
||||
|
||||
### 6. NSV to Txt — сборка перевода обратно
|
||||
|
||||
```
|
||||
python convert.py --nsv2txt
|
||||
```
|
||||
|
||||
Спросит:
|
||||
- папку с оригинальными `.txt` (по умолчанию `txt`) — используется как "скелет", все игровые команды и структура берутся отсюда;
|
||||
- папку с переведёнными `.nsv` (по умолчанию `json`);
|
||||
- папку для результата (по умолчанию `txt_translated`);
|
||||
- какое поле использовать — `text` (оригинал, для проверки, что ничего не сломалось) или `translated_text` (реальный перевод, стандартный выбор).
|
||||
|
||||
Соберёт `.txt`-файлы, готовые к компиляции, подставив перевод только в текстовые строки и не тронув остальное.
|
||||
|
||||
### 7. Compile — .txt → .cst
|
||||
|
||||
```
|
||||
python compile.py
|
||||
```
|
||||
|
||||
Спросит папку с переведёнными `.txt` (по умолчанию `txt_translated`) и папку для результата (по умолчанию `update01`). Компилирует всё через `tool/mc.exe`.
|
||||
|
||||
После этого `update01` можно класть обратно в игру (или собирать в архив — зависит от того, как игра подхватывает патчи).
|
||||
|
||||
## Формат .nsv
|
||||
|
||||
Простой текстовый формат ([спецификация](https://github.com/nsv-format/nsv), реализация — пакет `nsv`): одна запись — 5 строк подряд (номер, спикер, pcm, оригинал, перевод), затем пустая строка-разделитель. Пустое значение поля — одиночный `\`. Внутри текста `\` экранируется как `\\`, настоящий перенос строки — как `\n`.
|
||||
|
||||
Файл можно открыть в любом текстовом редакторе. LLM никогда не видит это экранирование напрямую — при отправке в модель текст уже "распакован" в обычный читаемый вид, а при сохранении на диск запаковывается заново автоматически.
|
||||
|
||||
## Частые проблемы
|
||||
|
||||
**`translate.py` падает с ошибкой про кодировку/JSON при чтении файла** — убедитесь, что используете `.nsv` файлы, созданные текущей версией `convert.py --txt2nsv`, а не старые `.json` от прежних версий скриптов.
|
||||
|
||||
**В игре после компиляции вместо буквы стоит `?`** — где-то в переводе оказался символ, не входящий в Shift-JIS/CP932 (например длинное тире `—`, `™`, эмодзи). `convert.py` при сборке (`--nsv2txt`) старается автоматически заменить самые частые проблемные символы, но если что-то новое проскочило — `compile.py`/`convert.py` покажут ошибку кодировки в консоли, укажут на файл, надо будет поправить перевод вручную в `.nsv`.
|
||||
|
||||
**Перевод потерял `[слова] [в] [скобках]` или `\`-теги** — это значит модель их не сохранила, несмотря на инструкцию в `prompt.txt`. Найдите строку в `.nsv`, поправьте вручную или сотрите `translated_text` у этой записи и запустите `translate.py` заново — тогда LLM переведёт её ещё раз.
|
||||
|
||||
**Хочу начать перевод заново с нуля** — очистите поле `translated_text` в нужных `.nsv` файлах (или пересоздайте их через `convert.py --txt2nsv`), и при желании удалите `_translate_data/translation_cache.json`, чтобы не подтягивался старый перевод из кэша.
|
||||
+105
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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
@@ -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
@@ -0,0 +1,15 @@
|
||||
## Characters
|
||||
- Michiru: Мичиру
|
||||
- Sachi: Сачи
|
||||
- Amane: Амане
|
||||
- Asako: Асако
|
||||
- Kazuki: Казуки
|
||||
- Makina: Макина
|
||||
- Nyanmel: Нянмел
|
||||
## Terms
|
||||
- Idol: Айдол
|
||||
- Magical Girl: Девочка-волшебница
|
||||
## Notes
|
||||
- Michiru обращается к другим неформально, используй разговорный стиль речи.
|
||||
- Сохраняй восклицательные интонации персонажа Michiru — она энергичная и громкая.
|
||||
- НИКОГДА не использу систему поливанова. Не Си, а Ши, не Ти, а Чи и так далее.
|
||||
@@ -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()
|
||||
@@ -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
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
nsv==0.2.4
|
||||
@@ -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
Binary file not shown.
+339
@@ -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()
|
||||
Reference in New Issue
Block a user