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:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 11:35:23 +03:00
1 parent bbbcc6a5d8
commit 12ec82c7e6
30 files changed
+700 -196

No files matched your search

+153
View File
@@ -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