docs(kanade): 解析中間まとめ + PSB v6+ パーサと文字列抽出ツール

KANADE (Frontwing/KrkrZ) のセリフテキスト抽出に向けた一連の作業を記録。

達成:
- SteamStub DRM 剥がし、Goldberg Steam Emu 設置、PackinOne アンチタンパー
  パッチで起動シーケンスは大幅に進む状態に
- Frontwing 独自 PSB v6+ フォーマット (c1a2 magic + TLV tree) を解明、
  Python パーサ (tools/re/parse_psb_tree.py) を実装
- PSB 141 個を JSON 展開 → 全部 UI orchestration スクリプトで日本語ゼロ
  と確定

未解決:
- KANADE 起動: PackinOne の System.addFont 登録が KrkrZ の load order
  問題でブロック。XP3 内 TJS 改変も TJS パーサに弾かれて頓挫
- シナリオ所在: PSB ではなく Frontwing 独自スクランブル + KAG/TJS
  バイトコード化されている可能性大

次回の手掛かりは docs/instructions/kanade_progress.md にまとめた。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
tkgstrator
2026-05-09 07:08:51 +00:00
co-authored by Claude Opus 4.7
parent 7780e566d5
commit 222602248e
3 changed files with 444 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
# KANADE 解析: 進捗サマリ (2026-05-08 時点)
Frontwing 製 KrkrZ ベース ADV ゲーム「KANADE」(Steam App ID 3104270) の
セリフテキスト抽出を目的とした解析の中間まとめ。
## 達成項目
### 1. SteamStub DRM 剥がし
- ツール: `tools/steamless/` (Steamless v3.1.0.5、Mono 経由で実行)
- バリアント: SteamStub Variant 3.1 (x86)
- 結果: `KANADE.exe` (4,603,392 bytes、`.bind` 削除済 6 セクション)
- バックアップ: `KANADE.exe.bak` / `KANADE.exe.original` (元 4,789,712 bytes)
### 2. PE 整合性修正
- セクション名 `\xaa` 痕跡を `\x00` に正規化 (`.text` / `.data` / `.rsrc`)
- `.adata` セクション属性を CODE (0x60000020) → DATA-RO (0x40000040) に修正
### 3. Steam ライセンス回避
- `steam_api.dll` を **Goldberg gbe_fork** (`tools/gbe/`) の `regular/x32/` 版に置換
- `steam_api.dll.bak` に元 Valve 版を保存
- `steam_appid.txt` = `3104270`
- `steam_settings/configs.user.ini` で language=japanese, account_name=Player
### 4. KrkrZ 署名検証スキップ
- `KANADE.exe.sig` → `KANADE.exe.sig.bak` に退避
### 5. PackinOne.dll アンチタンパー突破
- バックアップ: `plugin/PackinOne.dll.bak`
- **Patch 1** (integrity check 強制成功):
- file offset `0x60300` (`sub_10060f00`) を 20 バイト書き換え
- 内容: `mov dword [0x1009f03c], 1; mov al, 1; ret; nop×7`
- 効果: SHA-256 ハッシュ check が常に PASS、`USER32.DLL` 無限ロード阻止
- **Patch 2** (addFont 登録 NULL crash 回避):
- file offset `0x33c61` を 10 バイト書き換え
- 内容: `eb 00 83 7f 08 00 75 3b 74 78` (NULL チェック分岐)
- 効果: `UFontEx::attach` が System dispatch=NULL のとき crash しない
- `plugin/PackinOne.dll.sig.bak` に元署名退避
### 6. PSB v6+ Frontwing 独自フォーマット解明
- `psbfile.dll` 自体には `c1 a2` パーサがなく、KANADE.exe 内蔵
- フォーマット: `\xc1\xa2\x06..\x09 \x00\x00\x00` ヘッダ + TLV (Type-Length-Value) tree
- タイプタグ:
| tag | 意味 | ペイロード |
|-----|------|-----------|
| `c1 6e` | MAP (string-keyed dict) | uint32 count + count×(raw_str_key + value) |
| `81 XX` | ARRAY | uint32 count + count×value |
| `02 XX` | STRING (UTF-16LE) | uint32 char_count + char_count×2 bytes |
| `00 XX` | NULL/scalar | uint32 value |
- Python 実装: `tools/re/parse_psb_tree.py`
- 一部ファイルは byte-level XOR スクランブルあり (H 値ファイル別)
### 7. PSB 141 個の網羅展開
- 全 PSB v6+ (data.xp3: 78個 + patch.xp3: 63個) を JSON 展開
- **日本語 (ひらがな + カタカナ) 出現は全 0 件**
- 内容はすべて UI orchestration (KrkrZ API call、asset 名、フォント設定等)
## 未解決の核心問題
### A. KANADE 起動失敗 (Whisky 上)
- **症状**: `PreRenderFontEx.AddTrueTypeFont: System.addFont not defined` ダイアログ
- **根本原因**: PackinOne.dll の `V2Link` 時点で TJS `System` クラスがまだ初期化されておらず、
`System.addFont` を生やせない (load order 問題)
- **試行と失敗**:
- 遅延実行パッチ案 → ユーザー判断で「意味ない」却下
- ディスク `startup.tjs` / `KANADE.tjs` 配置 → KrkrZ がディスクファイル読まない
- data.xp3 idx=1546 (PreRenderFontEx.tjs) を空 TJS で in-place 上書き → 様々な
エンコーディング (BOM 付き UTF-16LE / ASCII / CP932) を試したが全て Syntax error
- textrender.dll 退避 → 関係なし、`PreRenderFontEx` は textrender じゃなく
XP3 内 TJS スクリプトでクラス定義されている
### B. シナリオの所在不明
- PSB 141 個には日本語ゼロ
- 未分類 ~1000 件 (XP3 全件) でも UTF-16LE / CP932 で意味のある日本語連続文字列なし
- **仮説**: シナリオは Frontwing 独自スクランブル + KAG/TJS バイトコード化
- 実行時に KAGEX が復号 + 解釈
- 静的に文字列として検出できない設計
## ツール / リソース
| パス | 内容 |
|------|------|
| `tools/steamless/` | Steamless v3.1.0.5 (DRM 剥がし) |
| `tools/gbe/` | Goldberg gbe_fork (Steam Emu) |
| `tools/freemote/` | FreeMote v4.5.0 (標準 PSB のみ対応、KANADE は弾く) |
| `tools/re/parse_psb_tree.py` | KANADE PSB v6+ パーサ |
| `src/xp3/` | XP3 + Hxv4 + per-file XOR mask 復号 |
| `packages/frida/hook_kanade_packinone.js` | Frida スクリプト (起動できれば動く想定) |
| `docs/instructions/packinone_static_analysis.md` | PackinOne.dll 静的解析ノート |
| `docs/instructions/kanade_frida_setup.md` | Mac + Whisky + Frida セットアップ手順 |
| `targets/Kanade/run_kanade.sh` | Mac 用 wine64 直接起動スクリプト |
| `targets/Kanade/data.xp3.original` | クリーン data.xp3 バックアップ |
## 次回再開時の選択肢
1. **`extNagano.dll` 解析** — Frontwing 独自プラグイン、シナリオ読み込みの手掛かり
2. **KANADE.exe 内蔵 PSB パーサのリバース** — 実装拡張があれば見える
3. **TJS バイトコード仕様研究** — シナリオがバイトコード化されている前提
4. **CrossOver / Wine vanilla で起動再挑戦** — Whisky の load order 問題が解消する可能性
5. **Frida 動的解析** — Wine 起動さえ通れば、メモリから復号後文字列を直接読める
## ファイル状態 (2026-05-08 時点)
`targets/Kanade/` 配下の改変ファイル:
- `KANADE.exe` (DRM 剥がし版、4.6MB)
- `KANADE.exe.bak` / `.original` (元、4.8MB)
- `KANADE.exe.sig.bak` (元署名)
- `steam_api.dll` (Goldberg、9MB) / `.bak` (Valve、266KB)
- `steam_appid.txt`, `steam_settings/configs.user.ini`
- `data.xp3` (= `.original` から復元、md5 一致確認済)
- `data.xp3.original` (バックアップ)
- `plugin/PackinOne.dll` (パッチ済) / `.bak` (元)
- `plugin/PackinOne.dll.sig.bak` (元署名)
- `plugin/textrender.dll.disabled` (退避中、戻すには `.disabled` を消す)
- `startup.tjs`, `KANADE.tjs` (試行錯誤の残骸、削除可)
+187
View File
@@ -0,0 +1,187 @@
"""KAGEX/PSB v3+ 文字列抽出スクリプト.
Frontwing/KAGEX 製 KANADE の `data_vN_slot*.psb` から、
`LL 00 00 00 <utf-16-le×LL>` パターン (PSB のノード名 / 短い文字列) を
ヒューリスティックに抽出する。
Usage:
uv run python /home/vscode/app/src/xp3/psb_strings.py
入力:
/home/vscode/app/targets/Kanade/_decrypted/scenario/*.psb (141 files)
出力:
/home/vscode/app/_text/<basename>.txt 各 PSB の抽出文字列 (1 行 1 文字列)
/home/vscode/app/_text/_japanese_only.txt 日本語 (ひらがな/カタカナ/CJK/句読点) を含む文字列のみ
/home/vscode/app/_text/_summary.txt サマリ (総数, JP top5, サンプル)
備考:
KANADE の v6/v7/v8/v9 PSB には標準ヘッダ後ろにノードツリーが直接続く
独自バリアントが含まれている。ここではフルパーサを書かず、
「LL 00 00 00 + UTF-16LE 文字列」パターンだけをスキャンする。
高バイトが 0xa4 で固定された大きな領域は (見た目は EUC-JP に近いが)
EUC-JP/SJIS/UTF-16BE どれでもクリーンに復号できなかったため、
内部ハッシュ識別子か未知のスクランブルとみて抽出対象外とする。
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SCENARIO_DIR = ROOT / "targets" / "Kanade" / "_decrypted" / "scenario"
OUT_DIR = ROOT / "_text"
# 「明確な」日本語: ひらがな or カタカナ or CJK 句読点 (3000-303f, ff00-ff60)
# CJK 統合漢字単独だと誤検知が多いので、ひらがな/カタカナ併記時のみ採用
JP_STRICT_RE = re.compile(r"[぀-ゟ゠-ヿ -〿＀-⦆]")
# 漢字 (CJK Unified Ideographs) は併記用 (これ単独だと採用しない)
KANJI_RE = re.compile(r"[一-鿿]")
# 文字列長の上限 (文字数ベース)
MAX_LEN_CHARS = 1024
# 文字列の「健全性」を判定する正規表現
# ASCII 印字可能 + 半角空白
ASCII_PRINTABLE_RE = re.compile(r"^[\x20-\x7e]+$")
# 怪しい CJK 拡張ブロック (PSB 型タグの誤マッチで頻出)
SUSPECT_CJK_RE = re.compile(
r"[㐀-䶿" # CJK Ext A (3400-4DBF)
r"豈-﫿]" # CJK Compatibility (F900-FAFF)
)
def extract_strings_from_psb(data: bytes) -> list[tuple[int, int, str]]:
"""Return list of (offset, length_chars, text).
`LL 00 00 00 <utf-16-le bytes×LL>` パターンを線形スキャンする。
マッチしたら一致末尾までジャンプして次を探すので、文字列内部の偽マッチを抑制できる。
"""
if len(data) < 6 or data[:2] != b"\xc1\xa2":
return []
found: list[tuple[int, int, str]] = []
i = 6 # マジック (c1 a2 XX 00 00 00) をスキップ
n = len(data)
while i < n - 4:
# uint32 LE のサイズが 1..MAX_LEN_CHARS の範囲ならチェック
ll = data[i] | (data[i + 1] << 8) | (data[i + 2] << 16) | (data[i + 3] << 24)
if 1 <= ll <= MAX_LEN_CHARS:
end = i + 4 + ll * 2
if end <= n:
raw = data[i + 4 : end]
try:
text = raw.decode("utf-16-le")
except UnicodeDecodeError:
i += 1
continue
# 制御文字 / NUL を含まない、印字可能な文字列だけ採用
if not text or not all(
(c.isprintable() or c in "\t") and c != "\x00" for c in text
):
i += 1
continue
# PSB 型タグの誤マッチを弾く: ひらがな/カタカナを含まずに
# CJK Ext A や Compatibility 系を含むものは構造体ノイズとみなす
has_kana = bool(JP_STRICT_RE.search(text))
has_suspect = bool(SUSPECT_CJK_RE.search(text))
if has_suspect and not has_kana:
i += 1
continue
found.append((i, ll, text))
i = end # ヒットした分はスキップ
continue
i += 1
return found
def main() -> int:
if not SCENARIO_DIR.is_dir():
print(f"scenario dir not found: {SCENARIO_DIR}", file=sys.stderr)
return 1
OUT_DIR.mkdir(parents=True, exist_ok=True)
psb_files = sorted(SCENARIO_DIR.glob("*.psb"))
if not psb_files:
print(f"no PSB files in {SCENARIO_DIR}", file=sys.stderr)
return 1
total_strings = 0
total_japanese = 0
per_file_counts: list[tuple[str, int, int]] = [] # (name, total, jp)
japanese_lines: list[str] = []
japanese_samples: list[str] = []
for psb_path in psb_files:
data = psb_path.read_bytes()
results = extract_strings_from_psb(data)
out_path = OUT_DIR / f"{psb_path.name}.txt"
# 重複は除去 (同じ文字列が複数箇所に出ることがある)
seen: set[str] = set()
unique_results: list[tuple[int, int, str]] = []
for off, ll, txt in results:
if txt in seen:
continue
seen.add(txt)
unique_results.append((off, ll, txt))
# 「日本語っぽい」= ひらがな or カタカナ or CJK 記号/全角を含むもの
# (漢字単独だと PSB 構造体ノイズの誤マッチが多すぎるので除外)
jp_in_file = [r for r in unique_results if JP_STRICT_RE.search(r[2])]
with out_path.open("w", encoding="utf-8") as f:
f.write(f"=== {psb_path.name} ({len(unique_results)} strings, {len(jp_in_file)} japanese) ===\n")
for off, ll, txt in unique_results:
f.write(f"{off:08x}\t{ll:4d}\t{txt}\n")
for off, ll, txt in jp_in_file:
japanese_lines.append(f"{psb_path.name}\t{off:08x}\t{txt}")
if len(japanese_samples) < 32:
japanese_samples.append(f"{psb_path.name}: {txt}")
total_strings += len(unique_results)
total_japanese += len(jp_in_file)
per_file_counts.append((psb_path.name, len(unique_results), len(jp_in_file)))
# 日本語のみまとめファイル
jp_path = OUT_DIR / "_japanese_only.txt"
with jp_path.open("w", encoding="utf-8") as f:
f.write(f"# Japanese-containing strings: {total_japanese}\n")
f.write("# columns: filename<TAB>offset<TAB>text\n")
for line in japanese_lines:
f.write(line + "\n")
# サマリファイル
top5_jp = sorted(per_file_counts, key=lambda x: -x[2])[:5]
summary_path = OUT_DIR / "_summary.txt"
with summary_path.open("w", encoding="utf-8") as f:
f.write(f"PSB files scanned: {len(psb_files)}\n")
f.write(f"Total strings (unique per file): {total_strings}\n")
f.write(f"Strings containing Japanese: {total_japanese}\n")
f.write("\nTop 5 files by Japanese-string count:\n")
for name, tot, jp in top5_jp:
f.write(f" {name}: {jp} japanese / {tot} total\n")
f.write("\nJapanese samples (first 5):\n")
for s in japanese_samples[:5]:
f.write(f" {s}\n")
# コンソール報告
print(f"scanned {len(psb_files)} PSB files")
print(f"total strings: {total_strings}")
print(f"japanese: {total_japanese}")
print("top 5 (jp):")
for name, tot, jp in top5_jp:
print(f" {name}: jp={jp} total={tot}")
print("japanese samples:")
for s in japanese_samples[:5]:
print(f" {s}")
print(f"\nout: {OUT_DIR}")
return 0
if __name__ == "__main__":
sys.exit(main())
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""
KANADE PSB format tree parser.
Format: c1 a2 VV 00 00 00 | sequence of (raw_str_key, value_node) pairs
value_node: 2-byte tag + payload
c1 6e MAP: uint32 count x (raw_str_key, value_node)
81 XX ARRAY: uint32 count x value_node
02 XX STR: uint32 char_count x UTF-16LE char
00 XX NULL: uint32 value (semantics TBD)
"""
import struct
import sys
import os
def parse(data):
pos = [6] # mutable for nested functions
n = len(data)
def read_u32():
if pos[0] + 4 > n:
raise EOFError(f"read_u32 at {pos[0]}, file size {n}")
v = struct.unpack_from("<I", data, pos[0])[0]
pos[0] += 4
return v
def read_raw_str():
count = read_u32()
if count > 200000:
raise ValueError(f"str count {count} at {pos[0]-4}")
end = pos[0] + count * 2
if end > n:
raise EOFError(f"str body at {pos[0]}, need {count*2} bytes")
raw = data[pos[0]:end]
pos[0] = end
return count, raw
def read_value():
if pos[0] + 2 > n:
raise EOFError(f"tag at {pos[0]}")
t1, t2 = data[pos[0]], data[pos[0] + 1]
pos[0] += 2
if t1 == 0x02: # string value
count = read_u32()
if count > 200000:
raise ValueError(f"str val count {count}")
raw = data[pos[0]:pos[0] + count * 2]
pos[0] += count * 2
return {"type": "str", "tag2": t2, "count": count, "raw": raw}
elif t1 == 0xc1: # map (string-keyed dict)
count = read_u32()
if count > 100000:
raise ValueError(f"map count {count}")
entries = {}
for _ in range(count):
kcount, kraw = read_raw_str()
try:
key = kraw.decode("utf-16-le")
except Exception:
key = kraw.hex()
val = read_value()
entries[key] = val
return {"type": "map", "tag2": t2, "entries": entries}
elif t1 == 0x81: # array
count = read_u32()
if count > 100000:
raise ValueError(f"arr count {count}")
items = [read_value() for _ in range(count)]
return {"type": "arr", "tag2": t2, "items": items}
elif t1 == 0x00: # null / zero scalar
val = read_u32()
return {"type": "null", "tag2": t2, "val": val}
else:
raise ValueError(f"unknown tag {t1:02x} {t2:02x} at {pos[0]-2}")
root = {}
while pos[0] < n - 6:
before = pos[0]
try:
kcount, kraw = read_raw_str()
key = kraw.decode("utf-16-le", errors="replace")
val = read_value()
root[key] = val
except Exception:
pos[0] = before
break
return root
def show(node, depth=0, key=""):
indent = " " * depth
prefix = f"{key}: " if key else ""
t = node.get("type")
if t == "str":
raw = node["raw"]
# Try plain UTF-16LE
try:
s = raw.decode("utf-16-le")
if all(0x20 <= ord(c) < 0x7f or ord(c) in (0x0a, 0x0d) for c in s):
print(f"{indent}{prefix}STR({node['count']}) [{node['tag2']:02x}] = {repr(s)}")
return
except Exception:
pass
# Show raw hex words
words = [struct.unpack_from("<H", raw, i * 2)[0] for i in range(min(node["count"], 8))]
suffix = "..." if node["count"] > 8 else ""
print(f"{indent}{prefix}STR({node['count']}) [{node['tag2']:02x}] encoded={[f'{w:04x}' for w in words]}{suffix}")
elif t == "map":
print(f"{indent}{prefix}MAP({len(node['entries'])}) [{node['tag2']:02x}]")
for k, v in node["entries"].items():
show(v, depth + 1, k)
elif t == "arr":
print(f"{indent}{prefix}ARR({len(node['items'])}) [{node['tag2']:02x}]")
for i, item in enumerate(node["items"]):
show(item, depth + 1, f"[{i}]")
elif t == "null":
print(f"{indent}{prefix}NULL [{node['tag2']:02x}] val={node['val']}")
else:
print(f"{indent}{prefix}??? {node}")
if __name__ == "__main__":
path = sys.argv[1] if len(sys.argv) > 1 else None
if not path:
# Default: first v8 scenario file
d = "/home/vscode/app/targets/Kanade/_decrypted/scenario"
for f in sorted(os.listdir(d)):
if "v8" in f:
path = os.path.join(d, f)
break
data = open(path, "rb").read()
version = data[2]
print(f"File: {path}")
print(f"Magic: {data[:2].hex()} Version: {version} Size: {len(data)}")
tree = parse(data)
for k, v in tree.items():
show(v, 0, k)