1462 lines
61 KiB
Python
1462 lines
61 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
ysbin_tool.py — Extract / re-pack YU-RIS YSTB (.ybn) dialogue
|
||
|
||
Usage (run from the game root folder):
|
||
python dev/ysbin_tool.py extract # data/ysbin/*.ybn → json/
|
||
python dev/ysbin_tool.py pack # json/*.json → data/ysbin/ (patched in-place)
|
||
|
||
Optional flags:
|
||
--ysbin-dir PATH (default: data/ysbin)
|
||
--json-dir PATH (default: json)
|
||
|
||
Workflow:
|
||
1. Run "extract" to produce one JSON file per story script.
|
||
2. Fill in the "tl" field for each entry (leave blank to keep original).
|
||
3. Run "pack" to write the translations back into the .ybn files.
|
||
"""
|
||
|
||
import argparse, binascii, json, os, re, struct, sys, zlib as _zlib
|
||
|
||
# ── constants ────────────────────────────────────────────────────────────────
|
||
|
||
YSTB_MAGIC = b'YSTB'
|
||
PUSH_STRING = 0x4D # expression bytecode: push string literal
|
||
WORD_OP = 106 # WORD command — dialogue / narration
|
||
SELECT_OP = 45 # SELECT command — choice menu
|
||
CALL_OP = 44 # CALL command — es.CHAR.NAME and other engine calls
|
||
CHARNAME_YBN = 84 # yst00084.ybn — contains all es.CHAR.NAME declarations
|
||
|
||
# Known story-script names keyed by yst index
|
||
STORY_NAMES = {
|
||
124: '10エンディング',
|
||
125: '11回想モード用',
|
||
126: '11使えそうなテキスト',
|
||
127: '11抜いたけどパーツ取りに使えそう',
|
||
128: '1日常編改',
|
||
129: '2恋人後',
|
||
130: '3寝取り男登場',
|
||
131: '4寝取られ編',
|
||
132: '5寝取られh',
|
||
133: '6寝取られh',
|
||
134: '7寝取られh',
|
||
135: '8完落ち編',
|
||
136: '9完落ち編2',
|
||
143: 'scenario_start',
|
||
144: 'start',
|
||
145: 'UI機能',
|
||
146: 'エピローグ',
|
||
147: 'サウンド機能',
|
||
148: 'ノベルサンプル',
|
||
149: 'フラグセット',
|
||
150: '演出機能',
|
||
151: '加筆',
|
||
152: '画像表示関連機能',
|
||
153: '回想シーン',
|
||
154: '開発補助機能',
|
||
155: '基本的な命令のサンプル',
|
||
156: '使える地の文',
|
||
157: '消す項目',
|
||
158: '汎用性高い指示',
|
||
159: '頻繁に使う',
|
||
}
|
||
|
||
_JP_RE = re.compile(r'[\u3040-\u30ff\u4e00-\u9fff\uff00-\uffef]')
|
||
# Matches "SpeakerName「speech text..." or 『speech text..." — speaker is 1–6 chars, no brackets/whitespace
|
||
_SPEAKER_RE = re.compile(r'^([^「」『』\s]{1,6})([「『])(.+)$', re.DOTALL)
|
||
|
||
def _sanitize(text):
|
||
"""
|
||
If text contains a null byte, strip everything up to and including it
|
||
(likely a corrupt control prefix), then also strip any immediately following
|
||
half-width characters (e.g. half-width katakana like チ) that are also
|
||
artifacts. Returns the cleaned string, or None if nothing meaningful remains.
|
||
"""
|
||
if '\x00' in text:
|
||
text = text[text.index('\x00') + 1:]
|
||
# Strip leading half-width katakana/latin artifacts (U+FF65–U+FF9F and plain ASCII)
|
||
text = text.lstrip(''.join(chr(c) for c in range(0xFF65, 0xFF9F + 1)))
|
||
text = text.lstrip()
|
||
return text if text else None
|
||
|
||
# ── speaker post-processing ───────────────────────────────────────────────────
|
||
|
||
def _post_process_speakers(entries):
|
||
"""
|
||
Add 'speaker' key to dialogue entries and remove redundant standalone-name entries.
|
||
|
||
Two patterns handled:
|
||
A) Combined "Name「speech」" → speaker="Name", src="「speech」", _raw="Name「speech」"
|
||
(_raw is stored so pack can find & replace the original binary string)
|
||
|
||
B) Standalone entry_n.src="Name" + entry_{n+1}.src="「speech」"
|
||
→ entry_{n+1} gains speaker="Name", entry_n is dropped and IDs are re-numbered.
|
||
|
||
Narration entries (『…』, plain prose) are left untouched.
|
||
"""
|
||
# Phase 1 — split combined Name+speech entries (dialogue only)
|
||
for e in entries.values():
|
||
if e['type'] != 'dialogue':
|
||
continue
|
||
m = _SPEAKER_RE.match(e['src'])
|
||
if m:
|
||
e['speaker'] = m.group(1)
|
||
e['_raw'] = e['src'] # keep original for pack lookup
|
||
g2 = m.group(3)
|
||
e['src'] = g2[:-1] if g2 and g2[-1] in '」』' else g2 # strip brackets
|
||
|
||
# Phase 2 — merge standalone-name entries into the following speech entry
|
||
ids = sorted(entries.keys(), key=lambda x: int(x))
|
||
to_remove = []
|
||
for i, eid in enumerate(ids[:-1]):
|
||
e = entries[eid]
|
||
if e['type'] != 'dialogue':
|
||
continue
|
||
src = e['src']
|
||
# Skip entries that already have a speaker or contain bracket characters
|
||
if 'speaker' in e or '「' in src or '」' in src or '『' in src or '』' in src:
|
||
continue
|
||
# Only short Japanese-only text qualifies as a speaker name
|
||
if not _JP_RE.search(src) or len(src) > 20:
|
||
continue
|
||
next_e = entries[ids[i + 1]]
|
||
if next_e['type'] == 'dialogue' and next_e['src'][:1] in '「『' and 'speaker' not in next_e:
|
||
next_e['speaker'] = src
|
||
next_e['_raw'] = next_e['src'] # preserve original 「speech」 for pack lookup
|
||
s = next_e['src'][1:]
|
||
next_e['src'] = s[:-1] if s and s[-1] in '」』' else s # strip brackets
|
||
to_remove.append(eid)
|
||
|
||
for eid in to_remove:
|
||
del entries[eid]
|
||
|
||
# Re-number sequentially, enforcing key order: type, speaker, src, _raw, tl
|
||
new_entries = {}
|
||
for new_i, eid in enumerate(sorted(entries.keys(), key=lambda x: int(x))):
|
||
e = entries[eid]
|
||
ordered = {'type': e['type']}
|
||
if 'speaker' in e:
|
||
ordered['speaker'] = e['speaker']
|
||
ordered['src'] = e['src']
|
||
if '_raw' in e:
|
||
ordered['_raw'] = e['_raw']
|
||
ordered['tl'] = e['tl']
|
||
new_entries[str(new_i)] = ordered
|
||
return new_entries
|
||
|
||
|
||
# ── YSTB binary helpers ───────────────────────────────────────────────────────
|
||
|
||
def _xor(data, key):
|
||
"""XOR data bytes with the 4-byte repeating key."""
|
||
result = bytearray(data)
|
||
n = len(key)
|
||
for i in range(len(result)):
|
||
result[i] ^= key[i % n]
|
||
return bytes(result)
|
||
|
||
|
||
def _read_ystb(raw):
|
||
"""
|
||
Decrypt and parse a YSTB binary.
|
||
|
||
YSTB layout (all sizes in bytes):
|
||
[0:4] magic 'YSTB'
|
||
[4:8] unknown
|
||
[8:12] inst_cnt – number of instructions
|
||
[12:16] code_size – size of instruction table
|
||
[16:20] arg_size – size of attribute-descriptor table (12 bytes each)
|
||
[20:24] res_size – size of values blob
|
||
[24:32] unknown
|
||
[32 ..] instruction table (XOR-encrypted)
|
||
attribute table (XOR-encrypted)
|
||
values blob (XOR-encrypted)
|
||
|
||
The 4-byte XOR key is stored at attrs_off+8 in the *encrypted* data.
|
||
That position is the 'offset' field of the first attr descriptor which,
|
||
after decryption, is always 0 — so encrypted_byte XOR 0 == key_byte.
|
||
|
||
Returns (key, inst_list, attr_descs, vals_raw, header_bytes) or None.
|
||
inst_list : list of (opcode, argc)
|
||
attr_descs : list of [id, type, len, offset] (mutable)
|
||
vals_raw : decrypted values blob (bytes)
|
||
header_bytes: first 32 raw bytes (not encrypted)
|
||
"""
|
||
if raw[:4] != YSTB_MAGIC:
|
||
return None
|
||
|
||
inst_cnt = struct.unpack_from('<I', raw, 8)[0]
|
||
code_size = struct.unpack_from('<I', raw, 12)[0]
|
||
arg_size = struct.unpack_from('<I', raw, 16)[0]
|
||
res_size = struct.unpack_from('<I', raw, 20)[0]
|
||
|
||
instr_off = 32
|
||
attrs_off = instr_off + code_size
|
||
vals_off = attrs_off + arg_size
|
||
|
||
if arg_size < 12:
|
||
return None
|
||
|
||
key = list(raw[attrs_off + 8 : attrs_off + 12])
|
||
instr_raw = _xor(raw[instr_off : instr_off + code_size], key)
|
||
attrs_raw = _xor(raw[attrs_off : attrs_off + arg_size], key)
|
||
vals_raw = _xor(raw[vals_off : vals_off + res_size], key)
|
||
|
||
inst_list = [(instr_raw[i * 4], instr_raw[i * 4 + 1]) for i in range(inst_cnt)]
|
||
|
||
total_attrs = arg_size // 12
|
||
attr_descs = []
|
||
for i in range(total_attrs):
|
||
o = i * 12
|
||
attr_descs.append([
|
||
struct.unpack_from('<H', attrs_raw, o)[0], # id
|
||
struct.unpack_from('<H', attrs_raw, o + 2)[0], # type
|
||
struct.unpack_from('<I', attrs_raw, o + 4)[0], # len
|
||
struct.unpack_from('<I', attrs_raw, o + 8)[0], # offset into vals
|
||
])
|
||
|
||
return key, inst_list, attr_descs, vals_raw, raw[:32]
|
||
|
||
|
||
def _strings_from_expr(bytecodes):
|
||
"""Decode all PUSH_STRING values from expression bytecodes (cp932)."""
|
||
out, i = [], 0
|
||
while i < len(bytecodes):
|
||
if bytecodes[i] == PUSH_STRING and i + 3 <= len(bytecodes):
|
||
slen = struct.unpack_from('<H', bytecodes, i + 1)[0]
|
||
sbytes = bytecodes[i + 3 : i + 3 + slen]
|
||
if len(sbytes) == slen:
|
||
try:
|
||
out.append(sbytes.decode('cp932'))
|
||
except Exception:
|
||
pass
|
||
i += 3 + slen
|
||
else:
|
||
i += 1
|
||
return out
|
||
|
||
|
||
def _replace_in_expr(bytecodes, old_cp932, new_cp932):
|
||
"""Replace the first matching PUSH_STRING value inside expression bytecodes."""
|
||
buf = bytearray(bytecodes)
|
||
i = 0
|
||
while i < len(buf):
|
||
if buf[i] == PUSH_STRING and i + 3 <= len(buf):
|
||
slen = struct.unpack_from('<H', bytes(buf), i + 1)[0]
|
||
sbytes = bytes(buf[i + 3 : i + 3 + slen])
|
||
if sbytes == old_cp932:
|
||
new_len = len(new_cp932)
|
||
return (bytes(buf[:i + 1])
|
||
+ struct.pack('<H', new_len)
|
||
+ new_cp932
|
||
+ bytes(buf[i + 3 + slen:]))
|
||
i += 3 + slen
|
||
else:
|
||
i += 1
|
||
return bytes(bytecodes)
|
||
|
||
|
||
# ── extract ──────────────────────────────────────────────────────────────────
|
||
|
||
def _extract_choices(strings):
|
||
"""
|
||
From the flat string list inside a SELECT expression, return only the
|
||
actual choice labels (sit between 'ES.SEL.SET' and the first empty slot).
|
||
"""
|
||
try:
|
||
set_idx = next(i for i, s in enumerate(strings) if 'ES.SEL.SET' in s)
|
||
except StopIteration:
|
||
return []
|
||
choices = []
|
||
for s in strings[set_idx + 1:]:
|
||
if s in ("''", '""', ''):
|
||
break
|
||
clean = s.strip('"\'')
|
||
if clean and _JP_RE.search(clean):
|
||
choices.append(clean)
|
||
return choices
|
||
|
||
|
||
def extract_dialogue(raw):
|
||
"""
|
||
Extract all WORD_OP dialogue and SELECT_OP choice lines that contain
|
||
Japanese text.
|
||
Returns dict {str(id): {'type': 'dialogue'|'choice', 'src': ..., 'tl': ''}}
|
||
"""
|
||
parsed = _read_ystb(raw)
|
||
if not parsed:
|
||
return {}
|
||
|
||
key, inst_list, attr_descs, vals_raw, _ = parsed
|
||
entries = {}
|
||
eid = 0
|
||
attr_idx = 0
|
||
|
||
for op, argc in inst_list:
|
||
args_data = []
|
||
for ai in range(argc):
|
||
if attr_idx >= len(attr_descs):
|
||
args_data.append(None)
|
||
break
|
||
_, a_type, a_len, a_off = attr_descs[attr_idx]
|
||
if a_len > 0 and a_off + a_len <= len(vals_raw):
|
||
args_data.append((a_type, vals_raw[a_off : a_off + a_len]))
|
||
else:
|
||
args_data.append(None)
|
||
attr_idx += 1
|
||
|
||
if op == WORD_OP and args_data:
|
||
item = args_data[0]
|
||
if item:
|
||
a_type, val = item
|
||
texts = []
|
||
if a_type == 0:
|
||
try:
|
||
texts = [val.decode('cp932')]
|
||
except Exception:
|
||
pass
|
||
elif a_type == 3:
|
||
texts = _strings_from_expr(val)
|
||
for text in texts:
|
||
text = _sanitize(text)
|
||
if text and _JP_RE.search(text):
|
||
entries[str(eid)] = {'type': 'dialogue', 'src': text, 'tl': ''}
|
||
eid += 1
|
||
|
||
elif op == SELECT_OP:
|
||
for item in args_data:
|
||
if not item:
|
||
continue
|
||
a_type, val = item
|
||
strings = _strings_from_expr(val)
|
||
if not any('ES.SEL.SET' in s for s in strings):
|
||
continue
|
||
for choice in _extract_choices(strings):
|
||
entries[str(eid)] = {'type': 'choice', 'src': choice, 'tl': ''}
|
||
eid += 1
|
||
|
||
elif op == CALL_OP and args_data and args_data[0]:
|
||
# ES.SEL.SET "choice1" "choice2" ... — choices passed as CALL_OP args
|
||
a_type0, val0 = args_data[0]
|
||
if a_type0 == 3 and any('ES.SEL.SET' in s for s in _strings_from_expr(val0)):
|
||
for item in args_data[1:]:
|
||
if not item:
|
||
continue
|
||
a_type, val = item
|
||
strs = _strings_from_expr(val) if a_type == 3 else []
|
||
for s in strs:
|
||
clean = s.strip('"\'')
|
||
if clean and _JP_RE.search(clean):
|
||
entries[str(eid)] = {'type': 'choice', 'src': clean, 'tl': ''}
|
||
eid += 1
|
||
|
||
return _post_process_speakers(entries)
|
||
|
||
|
||
# ── pack ─────────────────────────────────────────────────────────────────────
|
||
|
||
def patch_ystb(raw, translations, wrap_nl_bytes=b'\x0a'):
|
||
"""
|
||
Patch translated text into a YSTB binary.
|
||
|
||
translations : {src_text: tl_text}
|
||
wrap_nl_bytes : bytes to insert as line-break separator (default b'\x0a').
|
||
YU-RIS native newline is b'\xef\xf0' (two-byte control code).
|
||
Returns patched bytes, or None if nothing changed (no matches found).
|
||
|
||
Strategy:
|
||
- Decrypt instr / attrs / vals sections.
|
||
- For each WORD_OP first-argument that has a translation, record the
|
||
new bytes in attr_replacements.
|
||
- Rebuild the vals blob sequentially (original order), substituting
|
||
replaced values; update attr_descs offsets/lengths accordingly.
|
||
- Re-encrypt and reassemble the file. The XOR key is preserved because
|
||
attr_descs[0].offset is still 0 after the rebuild.
|
||
"""
|
||
parsed = _read_ystb(raw)
|
||
if not parsed:
|
||
return None
|
||
|
||
key, inst_list, attr_descs, vals_raw, header = parsed
|
||
code_size = struct.unpack_from('<I', raw, 12)[0]
|
||
arg_size = struct.unpack_from('<I', raw, 16)[0]
|
||
res_size = struct.unpack_from('<I', raw, 20)[0]
|
||
|
||
instr_off = 32
|
||
instr_enc = raw[instr_off : instr_off + code_size] # instructions unchanged
|
||
|
||
# ── find which attr indices need new content ──────────────────────────────
|
||
attr_replacements = {} # attr_idx -> new raw bytes
|
||
attr_idx = 0
|
||
_sel_set_active = False # True inside a CALL_OP ES.SEL.SET argument list
|
||
|
||
for op, argc in inst_list:
|
||
for ai in range(argc):
|
||
if ai == 0:
|
||
_sel_set_active = False
|
||
if attr_idx >= len(attr_descs):
|
||
break
|
||
_, a_type, a_len, a_off = attr_descs[attr_idx]
|
||
|
||
if a_len > 0 and a_off + a_len <= len(vals_raw):
|
||
val = bytes(vals_raw[a_off : a_off + a_len])
|
||
|
||
if op == WORD_OP and ai == 0:
|
||
if a_type == 0:
|
||
try:
|
||
text = val.decode('cp932')
|
||
except Exception:
|
||
text = None
|
||
if text and text in translations:
|
||
tl = translations[text]
|
||
try:
|
||
new_bytes = tl.encode('cp932')
|
||
except Exception:
|
||
new_bytes = tl.encode('utf-8')
|
||
# Replace sentinel 0x01 with the requested newline bytes
|
||
if wrap_nl_bytes != b'\x0a':
|
||
new_bytes = new_bytes.replace(b'\x01', wrap_nl_bytes)
|
||
attr_replacements[attr_idx] = new_bytes
|
||
|
||
elif a_type == 3:
|
||
new_val = val
|
||
for s in _strings_from_expr(val):
|
||
if s in translations:
|
||
tl = translations[s]
|
||
try:
|
||
new_enc = tl.encode('cp932')
|
||
except Exception:
|
||
new_enc = tl.encode('utf-8')
|
||
new_val = _replace_in_expr(new_val, s.encode('cp932'), new_enc)
|
||
if new_val != val:
|
||
attr_replacements[attr_idx] = new_val
|
||
|
||
elif op == SELECT_OP:
|
||
# Choice text lives in expression bytecodes for any arg
|
||
strings = _strings_from_expr(val)
|
||
if any('ES.SEL.SET' in s for s in strings):
|
||
new_val = val
|
||
for choice in _extract_choices(strings):
|
||
if choice in translations:
|
||
tl = translations[choice]
|
||
try:
|
||
new_enc = tl.encode('cp932')
|
||
except Exception:
|
||
new_enc = tl.encode('utf-8')
|
||
new_val = _replace_in_expr(
|
||
new_val,
|
||
choice.encode('cp932'),
|
||
new_enc,
|
||
)
|
||
if new_val != val:
|
||
attr_replacements[attr_idx] = new_val
|
||
|
||
elif op == CALL_OP and ai == 0:
|
||
# Detect ES.SEL.SET to flag subsequent args as choice labels
|
||
if a_type == 3:
|
||
strs = _strings_from_expr(val)
|
||
if any('ES.SEL.SET' in s for s in strs):
|
||
_sel_set_active = True
|
||
|
||
elif op == CALL_OP and ai > 0 and _sel_set_active:
|
||
# Each arg after ES.SEL.SET is a quoted choice label
|
||
if a_type == 3:
|
||
strs = _strings_from_expr(val)
|
||
new_val = val
|
||
for s in strs:
|
||
clean = s.strip('"\'')
|
||
if clean in translations:
|
||
tl = translations[clean]
|
||
try:
|
||
new_enc = ('"' + tl + '"').encode('cp932')
|
||
except Exception:
|
||
new_enc = ('"' + tl + '"').encode('utf-8')
|
||
new_val = _replace_in_expr(new_val, s.encode('cp932'), new_enc)
|
||
if new_val != val:
|
||
attr_replacements[attr_idx] = new_val
|
||
|
||
attr_idx += 1
|
||
|
||
if not attr_replacements:
|
||
return None
|
||
|
||
# ── rebuild vals blob ─────────────────────────────────────────────────────
|
||
# Process attr_descs in index order; shared regions are deduplicated via
|
||
# seen_regions so that two descriptors pointing at the same original bytes
|
||
# still map to the same new offset.
|
||
new_vals = bytearray()
|
||
seen_regions = {} # (orig_off, orig_len) -> new_off
|
||
|
||
for i, (aid, a_type, a_len, a_off) in enumerate(attr_descs):
|
||
if a_len == 0:
|
||
attr_descs[i][3] = 0
|
||
continue
|
||
|
||
if i in attr_replacements:
|
||
new_off = len(new_vals)
|
||
new_vals += attr_replacements[i]
|
||
attr_descs[i][2] = len(attr_replacements[i])
|
||
attr_descs[i][3] = new_off
|
||
else:
|
||
region = (a_off, a_len)
|
||
if region in seen_regions:
|
||
attr_descs[i][3] = seen_regions[region]
|
||
else:
|
||
if a_off + a_len <= len(vals_raw):
|
||
new_off = len(new_vals)
|
||
new_vals += vals_raw[a_off : a_off + a_len]
|
||
attr_descs[i][3] = new_off
|
||
seen_regions[region] = new_off
|
||
else:
|
||
attr_descs[i][3] = 0
|
||
|
||
# attr_descs[0][3] is always 0 after this loop, so re-encrypting the attrs
|
||
# section preserves the key at attrs_off+8 of the output (key XOR 0 = key).
|
||
|
||
# ── re-encrypt ────────────────────────────────────────────────────────────
|
||
new_attrs_raw = bytearray()
|
||
for aid, a_type, a_len, a_off in attr_descs:
|
||
new_attrs_raw += struct.pack('<HHII', aid, a_type, a_len, a_off)
|
||
|
||
new_attrs_enc = _xor(bytes(new_attrs_raw), key)
|
||
new_vals_enc = _xor(bytes(new_vals), key)
|
||
|
||
# Update res_size in header
|
||
new_header = bytearray(header)
|
||
struct.pack_into('<I', new_header, 20, len(new_vals))
|
||
|
||
# Preserve any trailing bytes after the vals section (usually none)
|
||
attrs_off = instr_off + code_size
|
||
tail_off = attrs_off + arg_size + res_size
|
||
tail = raw[tail_off:]
|
||
|
||
return bytes(new_header) + instr_enc + new_attrs_enc + new_vals_enc + tail
|
||
|
||
|
||
# ── charname extract / patch ─────────────────────────────────────────────────
|
||
|
||
def extract_charnames(raw):
|
||
"""
|
||
Extract es.CHAR.NAME display names from yst00084.ybn.
|
||
|
||
Each es.CHAR.NAME call looks like:
|
||
CALL "es.CHAR.NAME" "KeyName" "DisplayName" "" ...
|
||
|
||
Returns dict {str(id): {'type': 'charname', 'key': jp_key, 'src': jp_display, 'tl': ''}}
|
||
where 'key' is what appears before 「 in dialogue strings (never translate this),
|
||
and 'src'/'tl' are the current/target display name shown in the speaker box.
|
||
"""
|
||
parsed = _read_ystb(raw)
|
||
if not parsed:
|
||
return {}
|
||
key, inst_list, attr_descs, vals_raw, _ = parsed
|
||
|
||
entries = {}
|
||
eid = 0
|
||
attr_idx = 0
|
||
|
||
for op, argc in inst_list:
|
||
args_strs = []
|
||
for ai in range(argc):
|
||
if attr_idx + ai < len(attr_descs):
|
||
_, a_type, a_len, a_off = attr_descs[attr_idx + ai]
|
||
if a_len > 0 and a_type == 3:
|
||
val = vals_raw[a_off : a_off + a_len]
|
||
strs = [s.strip('"\'\'') for s in _strings_from_expr(val)]
|
||
args_strs.append(strs)
|
||
else:
|
||
args_strs.append([])
|
||
else:
|
||
args_strs.append([])
|
||
|
||
if op == CALL_OP and args_strs and args_strs[0] == ['es.CHAR.NAME']:
|
||
jp_key = args_strs[1][0] if len(args_strs) > 1 and args_strs[1] else ''
|
||
jp_disp = args_strs[2][0] if len(args_strs) > 2 and args_strs[2] else jp_key
|
||
if jp_key and _JP_RE.search(jp_key):
|
||
entries[str(eid)] = {
|
||
'type': 'charname',
|
||
'key': jp_key,
|
||
'src': jp_disp,
|
||
'tl': '',
|
||
}
|
||
eid += 1
|
||
|
||
attr_idx += argc
|
||
|
||
return entries
|
||
|
||
|
||
def patch_charnames(raw, translations):
|
||
"""
|
||
Patch es.CHAR.NAME display names in yst00084.ybn.
|
||
|
||
translations : {jp_key_name: translated_display_name}
|
||
e.g. {'暁人': 'Akito', '琴音': 'Kotone'}
|
||
|
||
Only arg2 (DisplayName) of each es.CHAR.NAME call is changed.
|
||
arg1 (KeyName — what is matched against dialogue prefixes) is left
|
||
untouched so that 暁人「...」 lines continue to be recognised.
|
||
"""
|
||
parsed = _read_ystb(raw)
|
||
if not parsed:
|
||
return None
|
||
|
||
key, inst_list, attr_descs, vals_raw, header = parsed
|
||
code_size = struct.unpack_from('<I', raw, 12)[0]
|
||
arg_size = struct.unpack_from('<I', raw, 16)[0]
|
||
res_size = struct.unpack_from('<I', raw, 20)[0]
|
||
instr_off = 32
|
||
instr_enc = raw[instr_off : instr_off + code_size]
|
||
|
||
attr_replacements = {}
|
||
attr_idx = 0
|
||
|
||
for op, argc in inst_list:
|
||
args_info = []
|
||
for ai in range(argc):
|
||
if attr_idx + ai < len(attr_descs):
|
||
_, a_type, a_len, a_off = attr_descs[attr_idx + ai]
|
||
val = vals_raw[a_off : a_off + a_len] if a_len > 0 else None
|
||
args_info.append((attr_idx + ai, a_type, val))
|
||
else:
|
||
args_info.append((None, 0, None))
|
||
|
||
if op == CALL_OP and len(args_info) >= 3:
|
||
_, _, arg0_val = args_info[0]
|
||
if arg0_val:
|
||
arg0_strs = _strings_from_expr(arg0_val)
|
||
if arg0_strs and 'es.CHAR.NAME' in arg0_strs[0]:
|
||
_, _, arg1_val = args_info[1]
|
||
if arg1_val:
|
||
arg1_strs = _strings_from_expr(arg1_val)
|
||
jp_key = arg1_strs[0].strip('"') if arg1_strs else ''
|
||
if jp_key in translations:
|
||
disp_attr_idx, _, arg2_val = args_info[2]
|
||
if arg2_val is not None and disp_attr_idx is not None:
|
||
arg2_strs = _strings_from_expr(arg2_val)
|
||
if arg2_strs:
|
||
old_quoted = arg2_strs[0] # e.g. '"琴音"'
|
||
try:
|
||
old_enc = old_quoted.encode('cp932')
|
||
new_quoted = '"' + translations[jp_key] + '"'
|
||
new_enc = new_quoted.encode('cp932')
|
||
except Exception:
|
||
old_enc = old_quoted.encode('utf-8')
|
||
new_quoted = '"' + translations[jp_key] + '"'
|
||
new_enc = new_quoted.encode('utf-8')
|
||
new_val = _replace_in_expr(arg2_val, old_enc, new_enc)
|
||
if new_val != arg2_val:
|
||
attr_replacements[disp_attr_idx] = new_val
|
||
|
||
attr_idx += argc
|
||
|
||
if not attr_replacements:
|
||
return None
|
||
|
||
new_vals = bytearray()
|
||
seen_regions = {}
|
||
for i, (aid, a_type, a_len, a_off) in enumerate(attr_descs):
|
||
if a_len == 0:
|
||
attr_descs[i][3] = 0
|
||
continue
|
||
if i in attr_replacements:
|
||
new_off = len(new_vals)
|
||
new_vals += attr_replacements[i]
|
||
attr_descs[i][2] = len(attr_replacements[i])
|
||
attr_descs[i][3] = new_off
|
||
else:
|
||
region = (a_off, a_len)
|
||
if region in seen_regions:
|
||
attr_descs[i][3] = seen_regions[region]
|
||
else:
|
||
if a_off + a_len <= len(vals_raw):
|
||
new_off = len(new_vals)
|
||
new_vals += vals_raw[a_off : a_off + a_len]
|
||
attr_descs[i][3] = new_off
|
||
seen_regions[region] = new_off
|
||
else:
|
||
attr_descs[i][3] = 0
|
||
|
||
new_attrs_raw = bytearray()
|
||
for aid, a_type, a_len, a_off in attr_descs:
|
||
new_attrs_raw += struct.pack('<HHII', aid, a_type, a_len, a_off)
|
||
|
||
new_attrs_enc = _xor(bytes(new_attrs_raw), key)
|
||
new_vals_enc = _xor(bytes(new_vals), key)
|
||
|
||
new_header = bytearray(header)
|
||
struct.pack_into('<I', new_header, 20, len(new_vals))
|
||
|
||
attrs_off = instr_off + code_size
|
||
tail_off = attrs_off + arg_size + res_size
|
||
tail = raw[tail_off:]
|
||
|
||
return bytes(new_header) + instr_enc + new_attrs_enc + new_vals_enc + tail
|
||
|
||
|
||
# ── font-size patch ───────────────────────────────────────────────────────────
|
||
|
||
def patch_font_sizes(raw, style_sizes):
|
||
"""
|
||
Patch es.TD.SIZE.SET values in a YSTB binary (typically yst00107.ybn).
|
||
|
||
style_sizes : dict style_name -> (width_px, height_px)
|
||
Pass {'all': (w, h)} to patch every defined style.
|
||
|
||
Font size integers are stored as 4-byte values [0x42, 0x01, 0x00, size_px].
|
||
Styles are identified by the es.TDDEF.SET call that follows SIZE.SET.
|
||
|
||
Returns patched bytes, or None if nothing was changed.
|
||
"""
|
||
parsed = _read_ystb(raw)
|
||
if not parsed:
|
||
return None
|
||
|
||
key, inst_list, attr_descs, vals_raw, header = parsed
|
||
code_size = struct.unpack_from('<I', raw, 12)[0]
|
||
arg_size = struct.unpack_from('<I', raw, 16)[0]
|
||
res_size = struct.unpack_from('<I', raw, 20)[0]
|
||
|
||
instr_off = 32
|
||
instr_enc = raw[instr_off : instr_off + code_size]
|
||
|
||
# ── build style -> (width_attr_idx, height_attr_idx) map ─────────────────
|
||
# Pattern: CALL es.TD.SIZE.SET <width> <height> … CALL es.TDDEF.SET "Name"
|
||
size_map = {} # style_name -> (width_attr_idx, height_attr_idx)
|
||
pending_size_pair = None
|
||
attr_idx = 0
|
||
|
||
for op, argc in inst_list:
|
||
instr_kind = None
|
||
size_args = []
|
||
tddef_name = None
|
||
|
||
for ai in range(argc):
|
||
if attr_idx >= len(attr_descs):
|
||
break
|
||
_, a_type, a_len, a_off = attr_descs[attr_idx]
|
||
|
||
if op == CALL_OP:
|
||
if ai == 0 and a_type == 3 and a_len > 0:
|
||
strs = _strings_from_expr(bytes(vals_raw[a_off : a_off + a_len]))
|
||
if any('TD.SIZE.SET' in s for s in strs):
|
||
instr_kind = 'SIZE'
|
||
elif any('TDDEF.SET' in s for s in strs):
|
||
instr_kind = 'TDDEF'
|
||
elif instr_kind == 'SIZE' and ai in (1, 2):
|
||
size_args.append(attr_idx)
|
||
elif instr_kind == 'TDDEF' and ai == 1 and a_type == 3 and a_len > 0:
|
||
strs = _strings_from_expr(bytes(vals_raw[a_off : a_off + a_len]))
|
||
for s in strs:
|
||
clean = s.strip('"\'')
|
||
if clean:
|
||
tddef_name = clean
|
||
break
|
||
|
||
attr_idx += 1
|
||
|
||
if instr_kind == 'SIZE' and len(size_args) == 2:
|
||
pending_size_pair = size_args # [width_attr_idx, height_attr_idx]
|
||
elif instr_kind == 'TDDEF' and tddef_name and pending_size_pair:
|
||
size_map[tddef_name] = tuple(pending_size_pair)
|
||
pending_size_pair = None
|
||
|
||
# Expand 'all' sentinel
|
||
if 'all' in style_sizes:
|
||
wh = style_sizes['all']
|
||
style_sizes = {s: wh for s in size_map}
|
||
|
||
# ── build replacement table ───────────────────────────────────────────────
|
||
attr_replacements = {}
|
||
|
||
for style, (new_w, new_h) in style_sizes.items():
|
||
if style not in size_map:
|
||
available = ', '.join(sorted(size_map.keys()))
|
||
print(f' WARNING: style "{style}" not found. Available: {available}')
|
||
continue
|
||
w_idx, h_idx = size_map[style]
|
||
w_off = attr_descs[w_idx][3]
|
||
h_off = attr_descs[h_idx][3]
|
||
w_old = vals_raw[w_off + 3] if attr_descs[w_idx][2] == 4 else '?'
|
||
h_old = vals_raw[h_off + 3] if attr_descs[h_idx][2] == 4 else '?'
|
||
attr_replacements[w_idx] = bytes([0x42, 0x01, 0x00, new_w & 0xFF])
|
||
attr_replacements[h_idx] = bytes([0x42, 0x01, 0x00, new_h & 0xFF])
|
||
print(f' style "{style}": width {w_old}→{new_w}, height {h_old}→{new_h}')
|
||
|
||
if not attr_replacements:
|
||
return None
|
||
|
||
# ── rebuild vals blob (same pattern as patch_ystb / patch_charnames) ──────
|
||
new_vals = bytearray()
|
||
seen_regions = {}
|
||
|
||
for i, (aid, a_type, a_len, a_off) in enumerate(attr_descs):
|
||
if a_len == 0:
|
||
attr_descs[i][3] = 0
|
||
continue
|
||
if i in attr_replacements:
|
||
new_off = len(new_vals)
|
||
new_vals += attr_replacements[i]
|
||
attr_descs[i][2] = len(attr_replacements[i])
|
||
attr_descs[i][3] = new_off
|
||
else:
|
||
region = (a_off, a_len)
|
||
if region in seen_regions:
|
||
attr_descs[i][3] = seen_regions[region]
|
||
else:
|
||
if a_off + a_len <= len(vals_raw):
|
||
new_off = len(new_vals)
|
||
new_vals += vals_raw[a_off : a_off + a_len]
|
||
attr_descs[i][3] = new_off
|
||
seen_regions[region] = new_off
|
||
else:
|
||
attr_descs[i][3] = 0
|
||
|
||
new_attrs_raw = bytearray()
|
||
for aid, a_type, a_len, a_off in attr_descs:
|
||
new_attrs_raw += struct.pack('<HHII', aid, a_type, a_len, a_off)
|
||
|
||
new_attrs_enc = _xor(bytes(new_attrs_raw), key)
|
||
new_vals_enc = _xor(bytes(new_vals), key)
|
||
|
||
new_header = bytearray(header)
|
||
struct.pack_into('<I', new_header, 20, len(new_vals))
|
||
|
||
attrs_off = instr_off + code_size
|
||
tail_off = attrs_off + arg_size + res_size
|
||
tail = raw[tail_off:]
|
||
|
||
return bytes(new_header) + instr_enc + new_attrs_enc + new_vals_enc + tail
|
||
|
||
|
||
# ── word-wrap helper ────────────────────────────────────────────────────────────
|
||
|
||
def _wrap_text(text, width):
|
||
"""
|
||
Word-wrap `text` to at most `width` characters per line.
|
||
Returns the wrapped string with '\n' (0x0a) as the line separator.
|
||
Preserves any manual '\n' already present in the text.
|
||
"""
|
||
import textwrap
|
||
paragraphs = text.split('\n')
|
||
lines = []
|
||
for para in paragraphs:
|
||
if len(para) <= width:
|
||
lines.append(para)
|
||
else:
|
||
lines.extend(textwrap.wrap(para, width=width) or [''])
|
||
return '\n'.join(lines)
|
||
|
||
|
||
# ── CLI commands ──────────────────────────────────────────────────────────────
|
||
|
||
def cmd_extract(args):
|
||
ysbin_dir = args.ysbin_dir
|
||
out_dir = args.json_dir
|
||
os.makedirs(out_dir, exist_ok=True)
|
||
|
||
total_files = 0
|
||
total_lines = 0
|
||
|
||
# ── extract dialogue from story scripts ───────────────────────────────────
|
||
for fname in sorted(os.listdir(ysbin_dir)):
|
||
if not fname.startswith('yst') or not fname.endswith('.ybn'):
|
||
continue
|
||
num_str = fname[3:-4]
|
||
if not num_str.isdigit():
|
||
continue
|
||
idx = int(num_str)
|
||
|
||
fpath = os.path.join(ysbin_dir, fname)
|
||
with open(fpath, 'rb') as f:
|
||
raw = f.read()
|
||
|
||
entries = extract_dialogue(raw)
|
||
if not entries:
|
||
continue
|
||
|
||
name = STORY_NAMES.get(idx, '')
|
||
out_name = f'{idx:03d}_{name}.json' if name else f'{idx:03d}.json'
|
||
out_path = os.path.join(out_dir, out_name)
|
||
|
||
with open(out_path, 'w', encoding='utf-8') as f:
|
||
json.dump(entries, f, ensure_ascii=False, indent=2)
|
||
|
||
label = name or fname
|
||
print(f' [{idx:03d}] {label}: {len(entries)} lines → {out_path}')
|
||
total_files += 1
|
||
total_lines += len(entries)
|
||
|
||
print(f'\nExtracted {total_lines} dialogue lines across {total_files} file(s).')
|
||
|
||
# ── extract speaker display names from charname registry ──────────────────
|
||
charname_path = os.path.join(ysbin_dir, f'yst{CHARNAME_YBN:05d}.ybn')
|
||
if os.path.exists(charname_path):
|
||
with open(charname_path, 'rb') as f:
|
||
raw = f.read()
|
||
cn_entries = extract_charnames(raw)
|
||
if cn_entries:
|
||
cn_out = os.path.join(out_dir, f'{CHARNAME_YBN:03d}_charnames.json')
|
||
with open(cn_out, 'w', encoding='utf-8') as f:
|
||
json.dump(cn_entries, f, ensure_ascii=False, indent=2)
|
||
print(f' [{CHARNAME_YBN:03d}] charnames: {len(cn_entries)} names → {cn_out}')
|
||
print(' Fill "tl" to set the name-box display name (key never changes).')
|
||
|
||
|
||
def cmd_pack(args):
|
||
json_dir = args.json_dir
|
||
ysbin_dir = args.ysbin_dir
|
||
wrap_width = getattr(args, 'wrap', 0) or 0
|
||
wrap_nl_str = getattr(args, 'wrap_newline', 'eff0') or 'eff0'
|
||
try:
|
||
wrap_nl_bytes = bytes.fromhex(wrap_nl_str.replace(' ', '').replace('0x', ''))
|
||
except ValueError:
|
||
wrap_nl_bytes = b'\xef\xf0'
|
||
patched = 0
|
||
skipped = 0
|
||
|
||
for fname in sorted(os.listdir(json_dir)):
|
||
if not fname.endswith('.json'):
|
||
continue
|
||
|
||
# Parse yst index from filename prefix (NNN_name.json or NNN.json)
|
||
m = re.match(r'^(\d+)', fname)
|
||
if not m:
|
||
print(f' SKIP {fname}: cannot parse index from filename')
|
||
skipped += 1
|
||
continue
|
||
idx = int(m.group(1))
|
||
|
||
json_path = os.path.join(json_dir, fname)
|
||
with open(json_path, 'r', encoding='utf-8') as f:
|
||
entries = json.load(f)
|
||
|
||
# ── charname JSON: translate speaker display names in yst00084.ybn ───
|
||
if idx == CHARNAME_YBN:
|
||
cn_translations = {}
|
||
for e in entries.values():
|
||
if e.get('type') != 'charname':
|
||
continue
|
||
if not e.get('tl') or e['tl'] == e.get('src', ''):
|
||
continue
|
||
cn_translations[e['key']] = e['tl']
|
||
|
||
if not cn_translations:
|
||
print(f' SKIP {fname}: no charname translations filled in')
|
||
skipped += 1
|
||
continue
|
||
|
||
ybn_path = os.path.join(ysbin_dir, f'yst{idx:05d}.ybn')
|
||
if not os.path.exists(ybn_path):
|
||
print(f' SKIP {fname}: {ybn_path} not found')
|
||
skipped += 1
|
||
continue
|
||
|
||
with open(ybn_path, 'rb') as f:
|
||
raw = f.read()
|
||
|
||
result = patch_charnames(raw, cn_translations)
|
||
if result is None:
|
||
print(f' SKIP {fname}: no matching charname strings found in binary')
|
||
skipped += 1
|
||
continue
|
||
|
||
with open(ybn_path, 'wb') as f:
|
||
f.write(result)
|
||
|
||
print(f' [{idx:03d}] {fname}: {len(cn_translations)} name(s) patched → {ybn_path}')
|
||
patched += 1
|
||
continue
|
||
|
||
# ── dialogue / choice JSON: translate WORD_OP strings ─────────────────
|
||
# Build {lookup_key: replacement_text} for every translated entry.
|
||
# - dialogue with _raw: lookup by original binary string, re-attach speaker prefix
|
||
# - dialogue without _raw: lookup by src directly
|
||
# - choice: lookup by src directly (bare choice text in expression bytecodes)
|
||
translations = {}
|
||
for e in entries.values():
|
||
if not e.get('src') or not e.get('tl') or e['tl'] == e['src']:
|
||
continue
|
||
tl = e['tl']
|
||
# Word-wrap English speech text if requested.
|
||
# Only wrap if the translation looks like English (no CJK).
|
||
if wrap_width and tl and not any(0x3000 <= ord(c) <= 0x9fff for c in tl):
|
||
tl = _wrap_text(tl, wrap_width)
|
||
# If a non-default newline byte is requested, store a sentinel
|
||
# that will be replaced after cp932 encoding below.
|
||
if wrap_nl_bytes != b'\x0a':
|
||
tl = tl.replace('\n', '\x01') # temp sentinel (0x01 unused in text)
|
||
key = e.get('_raw', e['src'])
|
||
# Re-attach the ORIGINAL speaker prefix from _raw so the engine
|
||
# continues to recognise the name in its es.CHAR.NAME registry.
|
||
# Never use e['speaker'] here — the translator may have changed it.
|
||
if '_raw' in e:
|
||
raw_str = e['_raw']
|
||
bracket = next((i for i, c in enumerate(raw_str) if c in '「『'), -1)
|
||
if bracket >= 0:
|
||
suffix = raw_str[-1] if raw_str[-1] in '」』' else ''
|
||
tl = raw_str[:bracket + 1] + tl + suffix # wrap: Name「tl」 or Name『tl』
|
||
translations[key] = tl
|
||
|
||
if not translations:
|
||
print(f' SKIP {fname}: no translations filled in')
|
||
skipped += 1
|
||
continue
|
||
|
||
ybn_path = os.path.join(ysbin_dir, f'yst{idx:05d}.ybn')
|
||
if not os.path.exists(ybn_path):
|
||
print(f' SKIP {fname}: {ybn_path} not found')
|
||
skipped += 1
|
||
continue
|
||
|
||
with open(ybn_path, 'rb') as f:
|
||
raw = f.read()
|
||
|
||
result = patch_ystb(raw, translations, wrap_nl_bytes=wrap_nl_bytes)
|
||
if result is None:
|
||
print(f' SKIP {fname}: no matching strings found in binary')
|
||
skipped += 1
|
||
continue
|
||
|
||
with open(ybn_path, 'wb') as f:
|
||
f.write(result)
|
||
|
||
print(f' [{idx:03d}] {fname}: {len(translations)} string(s) patched → {ybn_path}')
|
||
patched += 1
|
||
|
||
print(f'\nPatched {patched} file(s), skipped {skipped}.')
|
||
|
||
|
||
# ── YPF repack ──────────────────────────────────────────────────────────────────────────
|
||
|
||
# Byte-swap tables from GARbro ArcYPF.cs
|
||
_YPF_SWAP_00 = bytes([
|
||
0x03, 0x48, 0x06, 0x35, 0x0C, 0x10, 0x11, 0x19, 0x1C, 0x1E,
|
||
0x09, 0x0B, 0x0D, 0x13, 0x15, 0x1B, 0x20, 0x23, 0x26, 0x29, 0x2C, 0x2F, 0x2E, 0x32,
|
||
])
|
||
_YPF_SWAP_04 = bytes([
|
||
0x0C, 0x10, 0x11, 0x19, 0x1C, 0x1E,
|
||
0x09, 0x0B, 0x0D, 0x13, 0x15, 0x1B, 0x20, 0x23, 0x26, 0x29, 0x2C, 0x2F, 0x2E, 0x32,
|
||
])
|
||
_YPF_SWAP_10 = bytes([
|
||
0x09, 0x0B, 0x0D, 0x13, 0x15, 0x1B, 0x20, 0x23, 0x26, 0x29, 0x2C, 0x2F, 0x2E, 0x32,
|
||
])
|
||
|
||
|
||
def _ypf_swap_table(version):
|
||
if version < 0x100:
|
||
return _YPF_SWAP_04
|
||
if 0x12C <= version < 0x196:
|
||
return _YPF_SWAP_10
|
||
return _YPF_SWAP_00
|
||
|
||
|
||
def _ypf_swap_len(swap, value):
|
||
"""GARbro DecryptLength: swap paired values in the table."""
|
||
for i in range(0, len(swap) - 1, 2):
|
||
if swap[i] == value:
|
||
return swap[i + 1]
|
||
if swap[i + 1] == value:
|
||
return swap[i]
|
||
return value
|
||
|
||
|
||
def _ypf_guess_key(ypf_data):
|
||
"""
|
||
Replicate GARbro's guess-key: decode the true name_len for the first
|
||
directory entry using the swap table, then XOR the 4th-from-last
|
||
encrypted name byte with '.' (assumes every filename ends in .xxx).
|
||
"""
|
||
swap = _ypf_swap_table(struct.unpack_from('<I', ypf_data, 4)[0])
|
||
name_len = _ypf_swap_len(swap, ypf_data[0x24] ^ 0xFF)
|
||
if name_len < 4:
|
||
raise ValueError('First YPF entry name too short to guess key')
|
||
enc_name = ypf_data[0x25 : 0x25 + name_len]
|
||
return enc_name[name_len - 4] ^ ord('.')
|
||
|
||
|
||
def _ypf_patch(ypf_data, substitutions):
|
||
"""
|
||
Rebuild a YPF archive with substituted files.
|
||
|
||
substitutions : {basename_lower: uncompressed_bytes}
|
||
e.g. {'yst00128.ybn': b'...'}
|
||
|
||
Files in substitutions are compared against their original YPF content
|
||
before deciding to recompress; unchanged files keep their original blob
|
||
so the data section stays byte-equal for unmodified entries.
|
||
|
||
Offset strategy: delta-based — only entries after a size-changing
|
||
substitution change their stored offset. Inter-file gaps in the original
|
||
data section are preserved, so the round-trip is byte-identical when no
|
||
file content actually changes.
|
||
|
||
Checksum note: this engine stores 0x00000000 in every checksum field and
|
||
does not validate it, so we never touch that field.
|
||
"""
|
||
assert ypf_data[:4] == b'YPF\x00', 'Not a YPF archive'
|
||
version = struct.unpack_from('<I', ypf_data, 4)[0]
|
||
count = struct.unpack_from('<I', ypf_data, 8)[0]
|
||
data_start = struct.unpack_from('<I', ypf_data, 12)[0] # abs offset of data section
|
||
|
||
swap = _ypf_swap_table(version)
|
||
extra = 4 if version >= 0x1D9 else (8 if version == 0xDE else 0)
|
||
meta_size = 0x12 + extra # metadata bytes per entry after the name
|
||
key = _ypf_guess_key(ypf_data)
|
||
|
||
# ── parse directory ───────────────────────────────────────────────────────────────
|
||
entries = []
|
||
pos = 0x20
|
||
for _ in range(count):
|
||
entry_start = pos
|
||
name_len_enc = ypf_data[pos + 4]
|
||
name_len = _ypf_swap_len(swap, name_len_enc ^ 0xFF)
|
||
pos += 5
|
||
|
||
enc_name = ypf_data[pos : pos + name_len]
|
||
name = bytes(b ^ key for b in enc_name).decode('cp932', errors='replace')
|
||
pos += name_len
|
||
|
||
meta_rel = pos - entry_start # offset of metadata within dir_raw
|
||
file_type = ypf_data[pos]
|
||
is_packed = bool(ypf_data[pos + 1])
|
||
unpacked_size = struct.unpack_from('<I', ypf_data, pos + 2)[0]
|
||
packed_size = struct.unpack_from('<I', ypf_data, pos + 6)[0]
|
||
data_off = struct.unpack_from('<I', ypf_data, pos + 10)[0]
|
||
pos += meta_size
|
||
|
||
basename = name.replace('\\', '/').split('/')[-1].lower()
|
||
entries.append({
|
||
'basename': basename,
|
||
'file_type': file_type,
|
||
'is_packed': is_packed,
|
||
'unpacked_size': unpacked_size,
|
||
'packed_size': packed_size,
|
||
'data_off': data_off,
|
||
'dir_raw': bytearray(ypf_data[entry_start:pos]),
|
||
'meta_rel': meta_rel,
|
||
})
|
||
|
||
dir_end = pos
|
||
dir_gap = bytes(ypf_data[dir_end : data_start]) # gap between dir end and data (usually empty)
|
||
|
||
# ── determine which substitutions actually change content ────────────────────────
|
||
for e in entries:
|
||
e['new_blob'] = None # default: keep original blob
|
||
if e['basename'] not in substitutions:
|
||
continue
|
||
raw = substitutions[e['basename']]
|
||
# Skip re-compression if disk content is identical to what's in the YPF
|
||
try:
|
||
orig_blob = ypf_data[e['data_off'] : e['data_off'] + e['packed_size']]
|
||
orig_raw = _zlib.decompress(orig_blob) if e['is_packed'] else orig_blob
|
||
if orig_raw == raw:
|
||
continue # unchanged — leave new_blob = None
|
||
except Exception:
|
||
pass
|
||
# Content changed: recompress
|
||
blob = _zlib.compress(raw, 9) if e['is_packed'] else raw
|
||
e['new_blob'] = blob
|
||
e['new_unpacked'] = len(raw)
|
||
e['new_packed'] = len(blob)
|
||
|
||
# ── collapse duplicate data_off values into unique regions ────────────────────────
|
||
# YPF archives sometimes deduplicate file data — multiple directory entries share
|
||
# the same data_off. Writing such a region more than once would shift all
|
||
# subsequent offsets and corrupt the archive. We build a unique region map and
|
||
# write each data block exactly once.
|
||
unique_regions = {} # orig_data_off -> dict with orig_size / new_blob etc.
|
||
for e in entries:
|
||
if e['data_off'] not in unique_regions:
|
||
unique_regions[e['data_off']] = {
|
||
'orig_size': e['packed_size'],
|
||
'new_blob': e['new_blob'],
|
||
'new_unpacked': e.get('new_unpacked'),
|
||
'new_packed': e.get('new_packed'),
|
||
}
|
||
elif e['new_blob'] is not None and unique_regions[e['data_off']]['new_blob'] is None:
|
||
# A substitution targets this shared region — use the new blob
|
||
unique_regions[e['data_off']].update({
|
||
'new_blob': e['new_blob'],
|
||
'new_unpacked': e['new_unpacked'],
|
||
'new_packed': e['new_packed'],
|
||
})
|
||
|
||
sorted_regions = sorted(unique_regions.items(), key=lambda x: x[0])
|
||
|
||
# ── assign new absolute offsets (delta-based, gap-preserving) ────────────────────
|
||
orig_to_new = {}
|
||
cur = data_start
|
||
prev_orig_end = data_start
|
||
|
||
for orig_off, r in sorted_regions:
|
||
gap = orig_off - prev_orig_end # gap in original between prev region and this
|
||
cur += gap # reproduce that gap in the output
|
||
orig_to_new[orig_off] = cur
|
||
cur += r['new_packed'] if r['new_blob'] is not None else r['orig_size']
|
||
prev_orig_end = orig_off + r['orig_size']
|
||
|
||
# ── update every directory entry ──────────────────────────────────────────────────
|
||
for e in entries:
|
||
e['new_data_off'] = orig_to_new[e['data_off']]
|
||
# Propagate new sizes only to entries whose region was substituted
|
||
if unique_regions[e['data_off']]['new_blob'] is not None:
|
||
r = unique_regions[e['data_off']]
|
||
e['new_unpacked'] = r['new_unpacked']
|
||
e['new_packed'] = r['new_packed']
|
||
e['new_blob'] = r['new_blob']
|
||
else:
|
||
e['new_blob'] = None
|
||
|
||
# ── rebuild directory ─────────────────────────────────────────────────────────────
|
||
new_dir = bytearray()
|
||
for e in entries:
|
||
raw = e['dir_raw']
|
||
mo = e['meta_rel']
|
||
struct.pack_into('<I', raw, mo + 10, e['new_data_off'])
|
||
if e['new_blob'] is not None:
|
||
struct.pack_into('<I', raw, mo + 2, e['new_unpacked'])
|
||
struct.pack_into('<I', raw, mo + 6, e['new_packed'])
|
||
# checksum field (mo+14) left as-is — engine stores 0
|
||
new_dir += raw
|
||
|
||
# ── rebuild data section from unique regions, preserving original gaps ────────────
|
||
new_data = bytearray()
|
||
prev_orig_end = data_start
|
||
|
||
for orig_off, r in sorted_regions:
|
||
gap = orig_off - prev_orig_end
|
||
if gap > 0:
|
||
new_data += ypf_data[prev_orig_end : orig_off]
|
||
if r['new_blob'] is not None:
|
||
new_data += r['new_blob']
|
||
else:
|
||
new_data += ypf_data[orig_off : orig_off + r['orig_size']]
|
||
prev_orig_end = orig_off + r['orig_size']
|
||
|
||
# Trailing bytes beyond the last known region
|
||
if prev_orig_end < len(ypf_data):
|
||
new_data += ypf_data[prev_orig_end:]
|
||
|
||
# ── assemble ──────────────────────────────────────────────────────────────────────
|
||
hdr = bytearray(ypf_data[:0x20]) # version/count/data_start/padding unchanged
|
||
return bytes(hdr) + bytes(new_dir) + dir_gap + bytes(new_data)
|
||
|
||
|
||
def cmd_verify_ypf(args):
|
||
"""
|
||
Diagnostic: parse a YPF, show key structural info, and verify a round-trip
|
||
reconstruction produces an identical directory section.
|
||
"""
|
||
ypf_path = args.ypf
|
||
if not os.path.exists(ypf_path):
|
||
print(f'ERROR: YPF not found: {ypf_path}')
|
||
sys.exit(1)
|
||
|
||
print(f'Reading {ypf_path} ...')
|
||
with open(ypf_path, 'rb') as f:
|
||
ypf_data = f.read()
|
||
|
||
assert ypf_data[:4] == b'YPF\x00', 'Not a YPF archive'
|
||
version = struct.unpack_from('<I', ypf_data, 4)[0]
|
||
count = struct.unpack_from('<I', ypf_data, 8)[0]
|
||
data_start = struct.unpack_from('<I', ypf_data, 12)[0]
|
||
print(f'version={version:#x} count={count} data_start={data_start:#x} ({data_start})')
|
||
|
||
swap = _ypf_swap_table(version)
|
||
extra = 4 if version >= 0x1D9 else (8 if version == 0xDE else 0)
|
||
meta_size = 0x12 + extra
|
||
key = _ypf_guess_key(ypf_data)
|
||
print(f'key={key:#04x} extra_bytes={extra} meta_size={meta_size}')
|
||
|
||
# Parse directory
|
||
pos = 0x20
|
||
entries = []
|
||
for _ in range(count):
|
||
name_len_enc = ypf_data[pos + 4]
|
||
name_len = _ypf_swap_len(swap, name_len_enc ^ 0xFF)
|
||
pos += 5
|
||
enc_name = ypf_data[pos : pos + name_len]
|
||
name = bytes(b ^ key for b in enc_name).decode('cp932', errors='replace')
|
||
pos += name_len
|
||
is_packed = bool(ypf_data[pos + 1])
|
||
packed_size = struct.unpack_from('<I', ypf_data, pos + 6)[0]
|
||
data_off = struct.unpack_from('<I', ypf_data, pos + 10)[0]
|
||
checksum = struct.unpack_from('<I', ypf_data, pos + 14)[0]
|
||
pos += meta_size
|
||
entries.append({'name': name, 'data_off': data_off,
|
||
'packed_size': packed_size, 'checksum': checksum})
|
||
|
||
dir_end = pos
|
||
gap = data_start - dir_end
|
||
gap_status = 'OK (no gap)' if gap == 0 else f'WARNING: {gap} byte gap between dir end and data!'
|
||
print(f'dir_end={dir_end:#x} gap={gap} [{gap_status}]')
|
||
|
||
# First entry by data order — tells us if offsets are absolute
|
||
first = min(entries, key=lambda e: e['data_off'])
|
||
print(f'\nFirst entry by data offset: {first["name"]!r}')
|
||
print(f' data_off={first["data_off"]:#010x} data_start={data_start:#010x}')
|
||
diff = first['data_off'] - data_start
|
||
if diff == 0:
|
||
print(' → offsets are ABSOLUTE (data_off == data_start for first entry) ✓')
|
||
elif diff < 0:
|
||
print(f' → offsets appear RELATIVE (first entry starts {-diff} bytes before data_start?) ✗')
|
||
else:
|
||
print(f' → first entry is {diff} bytes into the data section')
|
||
|
||
# Checksum spot-checks
|
||
print('\nCheksum spot-checks (first 5 entries by offset):')
|
||
ok_count = err_count = 0
|
||
for e in sorted(entries, key=lambda x: x['data_off'])[:5]:
|
||
blob = ypf_data[e['data_off'] : e['data_off'] + e['packed_size']]
|
||
calc = _zlib.adler32(blob) & 0xFFFFFFFF
|
||
if calc == e['checksum']:
|
||
status = 'OK'
|
||
ok_count += 1
|
||
else:
|
||
status = f'FAIL (calc={calc:#010x} stored={e["checksum"]:#010x})'
|
||
err_count += 1
|
||
print(f' {e["name"]!r:44s} off={e["data_off"]:#010x} adler={status}')
|
||
|
||
# Round-trip directory check (empty substitutions)
|
||
rebuilt = _ypf_patch(ypf_data, {})
|
||
orig_dir = ypf_data[0x20 : data_start]
|
||
rt_dir = rebuilt[0x20 : data_start]
|
||
if orig_dir == rt_dir:
|
||
print('\nRound-trip directory bytes: IDENTICAL ✓')
|
||
else:
|
||
print(f'\nRound-trip directory bytes: DIFFER orig_len={len(orig_dir)} rt_len={len(rt_dir)}')
|
||
for i in range(min(len(orig_dir), len(rt_dir))):
|
||
if orig_dir[i] != rt_dir[i]:
|
||
print(f' First diff at dir+{i:#08x}: orig={orig_dir[i]:#04x} rebuilt={rt_dir[i]:#04x}')
|
||
break
|
||
if len(orig_dir) != len(rt_dir):
|
||
print(f' Length mismatch: orig={len(orig_dir)} rt={len(rt_dir)}')
|
||
|
||
# Check round-trip file size
|
||
if len(rebuilt) == len(ypf_data):
|
||
print(f'Round-trip file size: IDENTICAL ({len(ypf_data)} bytes) ✓')
|
||
else:
|
||
print(f'Round-trip file size: orig={len(ypf_data)} rebuilt={len(rebuilt)} delta={len(rebuilt)-len(ypf_data)}')
|
||
|
||
print(f'\nTotal entries: {count} | YPF size: {len(ypf_data)/1024/1024:.2f} MB')
|
||
|
||
|
||
def cmd_repack_ypf(args):
|
||
ypf_path = args.ypf
|
||
out_path = args.output
|
||
ysbin_dir = args.ysbin_dir
|
||
|
||
if not os.path.exists(ypf_path):
|
||
print(f'ERROR: YPF not found: {ypf_path}')
|
||
sys.exit(1)
|
||
|
||
print(f'Reading {ypf_path} ...')
|
||
with open(ypf_path, 'rb') as f:
|
||
ypf_data = f.read()
|
||
|
||
# Build substitutions from every yst*.ybn in ysbin_dir
|
||
substitutions = {}
|
||
for fname in os.listdir(ysbin_dir):
|
||
if fname.lower().startswith('yst') and fname.lower().endswith('.ybn'):
|
||
with open(os.path.join(ysbin_dir, fname), 'rb') as f:
|
||
substitutions[fname.lower()] = f.read()
|
||
print(f'Substituting {len(substitutions)} .ybn script file(s)...')
|
||
|
||
patched = _ypf_patch(ypf_data, substitutions)
|
||
|
||
with open(out_path, 'wb') as f:
|
||
f.write(patched)
|
||
orig_mb = len(ypf_data) / 1024 / 1024
|
||
new_mb = len(patched) / 1024 / 1024
|
||
print(f'Done. {orig_mb:.1f} MB → {new_mb:.1f} MB')
|
||
print(f'Written to {out_path}')
|
||
|
||
|
||
def cmd_patch_font_size(args):
|
||
ysbin_dir = args.ysbin_dir
|
||
target = os.path.join(ysbin_dir, 'yst00107.ybn')
|
||
if not os.path.exists(target):
|
||
sys.exit(f'ERROR: {target} not found')
|
||
|
||
styles = [s.strip() for s in args.style.split(',')]
|
||
style_sizes = {s: (args.size, args.size) for s in styles}
|
||
|
||
print(f'Patching font sizes in {target} ...')
|
||
raw = open(target, 'rb').read()
|
||
patched = patch_font_sizes(raw, style_sizes)
|
||
if patched is None:
|
||
print(' No changes (styles not found or already matching).')
|
||
return
|
||
|
||
with open(target, 'wb') as fh:
|
||
fh.write(patched)
|
||
print(f' Written {len(patched)} bytes.')
|
||
|
||
|
||
# ── entry point ───────────────────────────────────────────────────────────────
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description='Extract / re-pack YU-RIS YSTB (.ybn) dialogue, and repack into .ypf.',
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog=__doc__,
|
||
)
|
||
sub = parser.add_subparsers(dest='command', required=True)
|
||
|
||
# extract
|
||
p_ext = sub.add_parser('extract', help='.ybn → JSON')
|
||
p_ext.add_argument('--ysbin-dir', default='data/ysbin')
|
||
p_ext.add_argument('--json-dir', default='json')
|
||
|
||
# pack
|
||
p_pack = sub.add_parser('pack', help='JSON → .ybn (patch in-place)')
|
||
p_pack.add_argument('--ysbin-dir', default='data/ysbin')
|
||
p_pack.add_argument('--json-dir', default='json')
|
||
p_pack.add_argument('--wrap', type=int, default=0, metavar='N',
|
||
help='Auto word-wrap English lines to N chars (0 = off, try 50)')
|
||
p_pack.add_argument('--wrap-newline', default='eff0', metavar='HEX',
|
||
help='Hex bytes for line separator (default: eff0 = YU-RIS native newline 0xEF 0xF0)')
|
||
|
||
# repack-ypf
|
||
p_ypf = sub.add_parser('repack-ypf', help='data/ysbin/*.ybn → patched .ypf')
|
||
p_ypf.add_argument('--ysbin-dir', default='data/ysbin',
|
||
help='Directory of (patched) yst*.ybn files')
|
||
p_ypf.add_argument('--ypf', default='ypf/data.ypf',
|
||
help='Path to original .ypf archive')
|
||
p_ypf.add_argument('--output', default='data.ypf',
|
||
help='Output path for patched .ypf')
|
||
|
||
# verify-ypf
|
||
p_ver = sub.add_parser('verify-ypf', help='Diagnostic: inspect YPF structure and verify round-trip')
|
||
p_ver.add_argument('--ypf', default='ypf/data.ypf',
|
||
help='Path to .ypf archive to verify')
|
||
|
||
# patch-font-size
|
||
p_fnt = sub.add_parser('patch-font-size',
|
||
help='Change es.TD.SIZE.SET values in yst00107.ybn')
|
||
p_fnt.add_argument('--size', type=int, required=True,
|
||
help='New size in pixels (applied to both width and height)')
|
||
p_fnt.add_argument('--style', default='M',
|
||
help='Comma-separated style name(s) to patch, or "all" '
|
||
'(default: M). Known styles: M L LL S SS RUBY '
|
||
'BACKLOG SEL SEL_AL SEL_NA SAVELOAD FONTSEL FONTLISTSEL')
|
||
p_fnt.add_argument('--ysbin-dir', default='data/ysbin',
|
||
help='Directory containing yst*.ybn files (default: data/ysbin)')
|
||
|
||
args = parser.parse_args()
|
||
|
||
if args.command == 'extract':
|
||
cmd_extract(args)
|
||
elif args.command == 'pack':
|
||
cmd_pack(args)
|
||
elif args.command == 'verify-ypf':
|
||
cmd_verify_ypf(args)
|
||
elif args.command == 'patch-font-size':
|
||
cmd_patch_font_size(args)
|
||
else:
|
||
cmd_repack_ypf(args)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|