""" Speaker Format Detector for RPGMaker MV/MZ Mirrors the detection priority of rpgmakermvmz.py searchCodes() exactly: Pass 1 — scan 401/405 codes in order: 1. \\n / \\k inline nametag codes (always active, no flag needed) 2. 【Name】 alone on a 401 line (always active, no flag needed) 3. 【Name】dialogue on same 401 line (always active, no flag needed) 4. Name「dialogue」 inline quote -> INLINE401SPEAKERS 5. Short 401 (<40 chars) followed by 401 whose text starts with 「 " ( ( * [ -> FIRSTLINESPEAKERS Pass 2 — only if Pass 1 produced no reliable hits: 6. 101 code param[0] is a non-empty name string -> FACENAME101 Returns the best mode and confidence scores. """ from __future__ import annotations import json import re from pathlib import Path # ── Regexes matching rpgmakermvmz.py exactly ──────────────────────────────── # \\n / \\k nametag codes (always active in module) _NAMETAG_RE = re.compile( r"[\\]+[kKnN][wWcCrRrEe]?[\[<](?:[\\]*\w\[\d+\])?(.*?)(?:[\\]*\w\[\d+\])?[>]" ) # 【Name】 alone on the line (with optional trailing control codes) _BRACKET_ALONE_RE = re.compile( r"^\s*【[^】]+】(?:\s*|(?:[\\]+[A-Za-z]+(?:\[(?:[^\[\]]|\[[^\]]*\])*\])+\s*)*)$" ) # 【Name】dialogue on the same line _BRACKET_INLINE_RE = re.compile(r"^\s*【([^】]+)】(.+)", re.DOTALL) # Inline quote: Name「dialogue… _INLINE_QUOTE_RE = re.compile(r"^([^\s「」。、!?…\\\n]{1,20})「") # Dialogue starters that follow a FIRSTLINESPEAKERS name line _DIALOGUE_STARTERS = ("「", '"', "(", "(", "*", "[") # Minimum hits to trust a result _MIN_HITS = 3 def detect_speaker_format( files_dir: str | Path = "files", sample_size: int = 20, ) -> dict: """Scan up to *sample_size* map files and determine the speaker format. Returns: { "best_mode": "INLINE401SPEAKERS" | "FIRSTLINESPEAKERS" | "FACENAME101" | "ALWAYS_ON" | "NONE", "scores": { "nametag_codes": int, # \\n hits — always handled, no flag "bracket_401": int, # 【Name】 hits — always handled, no flag "INLINE401SPEAKERS": int, "FIRSTLINESPEAKERS": int, "FACENAME101": int, }, "total_401_groups": int, "files_scanned": int, "recommended_config": { "INLINE401SPEAKERS": bool, "FIRSTLINESPEAKERS": bool, "FACENAME101": bool, }, "confidence": "high" | "medium" | "low", "note": str, } """ files_dir = Path(files_dir) scores = { "nametag_codes": 0, "bracket_401": 0, "INLINE401SPEAKERS": 0, "FIRSTLINESPEAKERS": 0, "FACENAME101": 0, } total_groups = 0 files_scanned = 0 # Collect map files (Maps only, not MapInfos), largest first for better signal map_files = sorted( [p for p in files_dir.glob("Map[0-9]*.json") if p.is_file()], key=lambda p: p.stat().st_size, reverse=True, )[:sample_size] for fp in map_files: try: with open(fp, "r", encoding="utf-8-sig") as fh: data = json.load(fh) except Exception: continue files_scanned += 1 events = data.get("events") or [] for evt in events: if not evt: continue for page in evt.get("pages") or []: cmd_list = (page or {}).get("list") or [] _score_command_list(cmd_list, scores) # Count 401 groups i = 0 while i < len(cmd_list): if cmd_list[i].get("code") in (401, 405): total_groups += 1 while i < len(cmd_list) and cmd_list[i].get("code") in (401, 405, -1): i += 1 continue i += 1 # Also scan CommonEvents ce_path = files_dir / "CommonEvents.json" if ce_path.is_file(): try: with open(ce_path, "r", encoding="utf-8-sig") as fh: ce_data = json.load(fh) files_scanned += 1 for entry in ce_data or []: if not entry: continue _score_command_list(entry.get("list") or [], scores) except Exception: pass # ── Decision: 401-based patterns first, FACENAME101 only as fallback ──── always_on_hits = scores["nametag_codes"] + scores["bracket_401"] inline_hits = scores["INLINE401SPEAKERS"] first_hits = scores["FIRSTLINESPEAKERS"] face_hits = scores["FACENAME101"] total_401_hits = always_on_hits + inline_hits + first_hits recommended = { "INLINE401SPEAKERS": False, "FIRSTLINESPEAKERS": False, "FACENAME101": False, } if total_401_hits >= _MIN_HITS: if always_on_hits >= _MIN_HITS and always_on_hits >= inline_hits and always_on_hits >= first_hits: best_mode = "ALWAYS_ON" confidence = "high" if always_on_hits > (inline_hits + first_hits) else "medium" note = ( f"Speakers detected via \\\\n codes or 【Name】 brackets " f"({always_on_hits} hits) — no extra flags needed. " f"INLINE={inline_hits}, FIRSTLINE={first_hits}. " f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)." ) elif inline_hits >= first_hits: best_mode = "INLINE401SPEAKERS" recommended["INLINE401SPEAKERS"] = True confidence = "high" if inline_hits > first_hits * 2 else "medium" note = ( f"INLINE401SPEAKERS: {inline_hits} hits " f"(FIRSTLINESPEAKERS: {first_hits}, always-on: {always_on_hits}). " f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)." ) else: best_mode = "FIRSTLINESPEAKERS" recommended["FIRSTLINESPEAKERS"] = True confidence = "high" if first_hits > inline_hits * 2 else "medium" note = ( f"FIRSTLINESPEAKERS: {first_hits} hits " f"(INLINE401SPEAKERS: {inline_hits}, always-on: {always_on_hits}). " f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)." ) elif face_hits >= _MIN_HITS: best_mode = "FACENAME101" recommended["FACENAME101"] = True confidence = "high" if face_hits > _MIN_HITS * 2 else "medium" note = ( f"No 401-based speaker pattern found (401 hits: {total_401_hits}). " f"FACENAME101 recommended based on {face_hits} 101-code name hits. " f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)." ) else: best_mode = "NONE" confidence = "low" note = ( f"No reliable speaker pattern detected " f"(401 hits: {total_401_hits}, FACENAME101 hits: {face_hits}). " f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s). " "Check speaker settings manually." ) return { "best_mode": best_mode, "scores": scores, "total_401_groups": total_groups, "files_scanned": files_scanned, "recommended_config": recommended, "confidence": confidence, "note": note, } # ── Internal helpers ───────────────────────────────────────────────────────── def _score_command_list(cmd_list: list, scores: dict) -> None: """Walk a command list and score speaker patterns in module priority order.""" if not cmd_list: return i = 0 while i < len(cmd_list): cmd = cmd_list[i] code = cmd.get("code") params = cmd.get("parameters") or [] # ── 101: Show Text / face name ──────────────────────────────────────── if code == 101: # FACENAME101: param[0] is a non-empty face-name string face = params[0] if params else "" if isinstance(face, str) and face.strip(): scores["FACENAME101"] += 1 i += 1 continue # ── 401 / 405: dialogue line ────────────────────────────────────────── if code in (401, 405): text = (params[0] if params else "") or "" # 1. \\n nametag codes (always active — no flag needed) if _NAMETAG_RE.search(text): scores["nametag_codes"] += 1 i += 1 continue # module strips the nametag before further processing # 2+3. 【Name】 alone on line, or 【Name】dialogue inline (always active) if _BRACKET_ALONE_RE.match(text) or _BRACKET_INLINE_RE.match(text): scores["bracket_401"] += 1 i += 1 continue # 4. INLINE401SPEAKERS: Name「dialogue if _INLINE_QUOTE_RE.match(text): scores["INLINE401SPEAKERS"] += 1 i += 1 continue # 5. FIRSTLINESPEAKERS: short line followed by 401 starting with # a dialogue-starter character if ( len(text) < 40 and _has_japanese(text) and not any(text.lstrip().startswith(s) for s in _DIALOGUE_STARTERS) ): j = i + 1 while j < len(cmd_list) and cmd_list[j].get("code") == -1: j += 1 if j < len(cmd_list) and cmd_list[j].get("code") in (401, 405): next_params = cmd_list[j].get("parameters") or [] next_text = (next_params[0] if next_params else "") or "" # Strip leading RPGMaker control codes before checking starter next_stripped = re.sub( r"^(?:[\\]+[^cCnNiIkKvVSs{}]+?\[[\d\w\W]+?\]?\])+", "", next_text, ).lstrip() if next_stripped and next_stripped[0] in _DIALOGUE_STARTERS: scores["FIRSTLINESPEAKERS"] += 1 i += 1 continue i += 1 _JP_RE = re.compile( r"[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uF900-\uFAFF\uFF61-\uFF9F]" ) def _has_japanese(text: str) -> bool: return bool(_JP_RE.search(text))