refactor: move game-specific import logic into profiles; unblock Tomie import
- Add app/games: GameProfile base, registry, ReaderHooks and renpy/naninovel/ tomie profiles. pipeline, preflight, detect and mods dispatch via the registry - Move tomie_* converter modules to app/games/tomie and split out reader hooks, import, mod validation and known defects - Tolerate the 31 authoring defects of the pinned Tomie build as report warnings (exact list, per-script counts); any new defect still blocks import - Frontend: runtime registry (runtimes.ts) with lazy Tomie launcher - Add profile/registry tests and docs/GAME_PROFILES.md; update IMPORT.md Full media export of the original Tomie build is not yet verified. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bbbcc6a5d8
commit
12ec82c7e6
30 files changed
+700
-196
No files matched your search
@@ -0,0 +1,153 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user