From d2c8b8c4358d58b222b0fd2c8328152343ddd68a Mon Sep 17 00:00:00 2001 From: dazedanon Date: Wed, 18 Mar 2026 02:39:10 -0500 Subject: [PATCH] commit this --- .gitignore | 2 + dev/ysbin_tool.py | 983 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 985 insertions(+) create mode 100644 dev/ysbin_tool.py diff --git a/.gitignore b/.gitignore index 0e17197..89dbd99 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/dev/ysbin_tool.py b/dev/ysbin_tool.py new file mode 100644 index 0000000..4b0f4ac --- /dev/null +++ b/dev/ysbin_tool.py @@ -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完落ち編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..." — 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 + 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('= 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(' 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('= 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(' 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(' 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('= 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('