"""Fail-closed validation of the command surface implemented by the Tomie VM. The assembly hash selects the adapter; it does not exempt mutable script assets from validation. Unknown command arguments are rejected along with unknown ops. """ import re from app.converter.compatibility import Issue from app.converter.nani_expr import assignment, parse from app.games.tomie.profile import FUNCTIONS, validate_expressions ARGUMENTS = { "ModifyBackground": "AppearanceAndTransition Duration Scale TintColor", "ModifyTextPrinter": "Duration HideOther IdAndAppearance MakeDefault ScenePosition", "ModifyCharacter": "Duration IdAndAppearance LookDirection Scale ScenePosition TintColor Transition Visible", "PrintText": "AuthorId LineBreaks ResetPrinter RevealSpeed Text WaitForInput", "SkipInput": "", "AddChoice": "AutoPlay ButtonPath ButtonPosition ChoiceSummary Duration GotoPath HandlerId OnSelected ShowHandler", "Stop": "", "SetCustomVariable": "Expression", "HideAllCharacters": "Duration", "BeginIf": "Expression", "Else": "", "EndIf": "", "AppendLineBreak": "Count", "Spawn": "Params Path", "Wait": "WaitMode", "ElseIf": "Expression", "Gosub": "Path", "Goto": "Path", "Return": "", "DestroySpawned": "Path", "HideActors": "ActorIds Duration", "PlaySfx": "Duration FadeInDuration Loop SfxPath Volume", "WaitForInput": "", "HidePrinter": "", "AddItem": "Amount ItemId", "PlayVoice": "VoicePath Volume", "ClearChoiceHandler": "HandlerId Hide", "StopBgm": "FadeOutDuration", "PlayBgm": "BgmPath Duration FadeInDuration Loop Volume", "ShowToastUI": "Appearance Duration Text", "ModifyCamera": "Duration Zoom", "Skip": "Enable", "HideUI": "AllowToggle Duration UINames", "ProcessInput": "InputEnabled", "AutoSave": "", "ExitToTitle": "", "StopSfx": "FadeOutDuration", "RemoveItem": "Amount ItemId", "ShowUI": "UINames", "ShowPrinter": "", "InputCustomVariable": "PlayOnSubmit Summary VariableName", "ClearBacklog": "", "AppendText": "Text", } EFFECTS = set("ShakeCamera ShakePrinter ShakeCharacter DigitalGlitch PinkFlash RedFlash WhiteFlash Hands1 Hands2 Hands3 Rain EffectSuccess EffectFailed EffectHeart Gaze SkillUp LewdInswitterComment PregMiniGame CamShowUI".split()) UI_NAMES = {"MStat", "Calendar", "AppUI", "CGGalleryUI", "Inventory"} def inline_commands(value): """Same bounded comma/quote splitting as the browser's choice interpreter.""" if isinstance(value, list): if not all(isinstance(part, str) for part in value): raise ValueError("Неверный список команд выбора") return [part.strip() for part in value if part.strip()] if not isinstance(value, str) or len(value) > 30000: raise ValueError("Неверная команда выбора") result, start, depth, quote, escaped = [], 0, 0, "", False for index, char in enumerate(value): if escaped: escaped = False continue if quote: if char == "\\": escaped = True elif char == quote: quote = "" elif char in "\"'": quote = char elif char in "({": depth += 1 elif char in ")}": depth -= 1 if depth < 0: raise ValueError("Некорректные скобки команды выбора") elif char == "," and depth == 0: result.append(value[start:index].strip()) start = index + 1 if depth or quote: raise ValueError("Незакрытая команда выбора") result.append(value[start:].strip()) return [item for item in result if item] def validate_runtime(data): scripts, issues = data["scripts"], [] def issue(code, message, location): issues.append(Issue(code, message, location)) def target(value, script, location): if isinstance(value, dict): if "template" in value: return # expressions checked separately; the VM checks the resolved target name, label = value.get("name") or script, value.get("value") or "" elif isinstance(value, str): name, _, label = value.partition(".") name = name or script else: issue("invalid_target", "Неверный переход Naninovel", location) return if name not in scripts or (label and label not in scripts[name]["labels"]): issue("missing_target", f"Не найден переход {name}.{label}", location) def inline(value, script, location): try: if isinstance(value, dict) and isinstance(value.get("template"), str): # The template is parsed after interpolation by the VM. Its # expressions are validated by validate_expressions below. value = value["template"] for command in inline_commands(value): if not command.startswith("@"): continue name, _, body = command.partition(" ") if name not in {"@set", "@goto", "@gosub", "@return", "@hideChars", "@addItem", "@additem", "@removeItem", "@voice", "@hide"}: raise ValueError(f"Неизвестная команда выбора {name}") if name == "@set" and "{" not in body: assignment(body, FUNCTIONS) elif name in {"@goto", "@gosub"} and "{" not in body: target(body, script, location) for match in re.finditer(r"\{([^{}]+)\}", body): parse(match[1], FUNCTIONS) except ValueError as exc: issue("unsupported_choice", str(exc), location) for name, script in scripts.items(): for index, command in enumerate(script["commands"]): op, args = command["op"], command["args"] location = f"{name}:{command.get('line', -1)}" if op not in ARGUMENTS: issue("unsupported_command", f"Не поддерживается команда {op}", location) continue extra = set(args) - set(ARGUMENTS[op].split()) - {"Wait", "ConditionalExpression"} if extra: issue("unsupported_argument", f"Не поддерживаются параметры {op}: {', '.join(sorted(extra))}", location) if op in {"Spawn", "DestroySpawned"}: effect = args.get("Path") if not isinstance(effect, str) or effect not in EFFECTS: issue("unsupported_effect", "Не поддерживается эффект Tomie: " + str(effect), location) if op in {"ShowUI", "HideUI"}: names = args.get("UINames", []) if not isinstance(names, list) or any(not isinstance(value, str) or value not in UI_NAMES for value in names): issue("unsupported_ui", "Не поддерживается интерфейс Tomie: " + str(names), location) if op in {"BeginIf", "Else", "ElseIf"}: # Native Naninovel seeks in the playlist instead of retaining a # branch stack. An unmatched EndIf is a valid no-op; a branch # without a forward closing EndIf cannot be traversed safely. depth = 0 for following in script["commands"][index + 1:]: if following["op"] == "BeginIf": depth += 1 elif following["op"] == "EndIf": if depth == 0: break depth -= 1 else: issue("invalid_branch", f"{op} не имеет завершающего EndIf", location) if op in {"Goto", "Gosub"}: target(args.get("Path"), name, location) if op == "AddChoice": if "GotoPath" in args: target(args["GotoPath"], name, location) inline(args.get("OnSelected", []), name, location) expressions = validate_expressions(scripts) issues.extend(Issue("unsupported_expression", item["message"], item["location"], item["count"]) for item in expressions["issues"]) return issues