commit this

This commit is contained in:
dazedanon 2026-03-18 02:39:10 -05:00
parent 7044b8f408
commit d2c8b8c435
2 changed files with 985 additions and 0 deletions

2
.gitignore vendored
View file

@ -32,6 +32,7 @@
!package.nw
!SRPG_Unpacker.exe
!(SRPG_Unpacker Patcher).bat
!dev/ysbin_tool.py
# Ignore
previous_patch_sha.txt
@ -44,4 +45,5 @@ scene.dat
BSXScript_*
data\bgm
# Images

983
dev/ysbin_tool.py Normal file
View file

@ -0,0 +1,983 @@
#!/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
# 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完落ち編',
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..." — speaker is 16 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+FF65U+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
e['src'] = '\u300c' + m.group(2) # '「' + rest
# 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'].startswith('') and 'speaker' not in next_e:
next_e['speaker'] = src
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
return _post_process_speakers(entries)
# ── pack ─────────────────────────────────────────────────────────────────────
def patch_ystb(raw, translations):
"""
Patch translated text into a YSTB binary.
translations : {src_text: tl_text}
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
for op, argc in inst_list:
for ai in range(argc):
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')
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
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
# ── 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
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).')
def cmd_pack(args):
json_dir = args.json_dir
ysbin_dir = args.ysbin_dir
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)
# 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']
key = e.get('_raw', e['src'])
# Re-attach speaker for entries that were originally combined (Name「…」)
if '_raw' in e and 'speaker' in e:
tl = e['speaker'] + 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)
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}')
# ── 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')
# 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')
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)
else:
cmd_repack_ypf(args)
if __name__ == '__main__':
main()