feat: add native Tomie adapter and strict import compatibility checks
This commit is contained in:
1 parent
c8d83b3fe7
commit
57a4edcbfd
45 files changed
+2985
-71
No files matched your search
@@ -85,6 +85,23 @@ def test_unsupported_renpy_logic_never_degrades_to_ready(source, reason):
|
||||
require_supported_scripts(data)
|
||||
|
||||
|
||||
def test_renpy_atl_animation_is_rejected_instead_of_becoming_static():
|
||||
data = script('transform moving:\n linear 1.0 xalign 1.0\nlabel start:\n "Hi"')
|
||||
with pytest.raises(CompatibilityError, match="ATL"):
|
||||
require_supported_scripts(data)
|
||||
|
||||
|
||||
def test_source_encoding_is_preserved_or_explicitly_rejected(tmp_path):
|
||||
from app.converter.source import read_source
|
||||
from app.converter.rpy_lexer import ParseError
|
||||
source = tmp_path / "script.rpy"
|
||||
source.write_bytes('# coding: cp1251\nlabel start:\n "Привет"'.encode("cp1251"))
|
||||
assert "Привет" in read_source(source)
|
||||
source.write_bytes(b'label start:\n "\xff"')
|
||||
with pytest.raises(ParseError, match="кодировку"):
|
||||
read_source(source)
|
||||
|
||||
|
||||
def test_directory_rejection_precedes_copying_media(tmp_path, monkeypatch):
|
||||
write(tmp_path / "source/game/script.rpy", 'label start:\n call screen map')
|
||||
def media(*args, **kwargs):
|
||||
@@ -191,8 +208,9 @@ def test_naninovel_resources_keep_keys_delimiters_and_context():
|
||||
records = text_records("Tips", "# comment\nHeading\nHelp:Title|Description\n", "Tips")
|
||||
assert [r["text"] for r in records.values()] == ["Title", "Description"]
|
||||
assert len(text_records("_synonyms", "word=hello,hi\n", "synonyms")) == 2
|
||||
with pytest.raises(ValueError, match="Повторяющийся"):
|
||||
text_records("DefaultUI", "Key:A\nKey:B", "UI")
|
||||
duplicates = text_records("Tips", "Key:A\nKey:B", "Tips")
|
||||
assert [r["text"] for r in duplicates.values()] == ["A", "B"]
|
||||
assert [r["occurrence"] for r in duplicates.values()] == [0, 1]
|
||||
info = LineInfo("id", "node", "script/resources.json", "resources", "", "<b>Hello {name}</b>", None,
|
||||
resource={"delimiters": "|\r\n"})
|
||||
validate_nani_translation(info, "<b>Привет {name}</b>")
|
||||
@@ -241,6 +259,34 @@ def test_unity_codec_restores_global_hooks_after_error():
|
||||
assert (helper.read_value, helper.write_value, helper.read_typetree_boost, helper.get_ref_type_node) == before
|
||||
|
||||
|
||||
def test_unity_package_closes_cyclic_readers_and_optional_none_fields(tmp_path):
|
||||
handle = (tmp_path / "asset").open("wb")
|
||||
reader = SimpleNamespace(stream=handle, files=None, cabs=None)
|
||||
asset = SimpleNamespace(reader=reader, files=None, cabs={"same": reader})
|
||||
environment = SimpleNamespace(files={"asset": asset}, cabs={"asset": asset})
|
||||
original = Package(environments=[environment], resources=[asset])
|
||||
original.close()
|
||||
assert handle.closed
|
||||
assert not original.resources and not original.environments
|
||||
(tmp_path / "asset").unlink()
|
||||
|
||||
|
||||
def test_unused_renpy_translation_entries_are_not_script_blockers(tmp_path):
|
||||
from app.converter import locales
|
||||
from app.converter.rpy_parser import ScriptData
|
||||
data = ScriptData()
|
||||
data.modules = {"script.rpy": [{"name": "start", "body": [{"op": "say", "what": "Hello", "_ln": 1}]}]}
|
||||
by_line = locales.assign_ids(data.modules)
|
||||
path = tmp_path / "tl/russian/script.rpy"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text('# game/script.rpy:1\ntranslate russian start_123:\n "Привет"\n\n\n\n\n\n'
|
||||
'translate russian removed_456:\n "Старая реплика"\n', encoding="utf-8")
|
||||
translations = locales.import_tl(tmp_path, data.modules, by_line, data.warn)
|
||||
assert translations["ru"].unmatched == 1
|
||||
assert not data.blockers
|
||||
assert any("реплики без пары" in message for message in data.warnings)
|
||||
|
||||
|
||||
def test_upload_rejects_unsupported_engine_without_queueing(tmp_path, monkeypatch):
|
||||
from app.routers import versions
|
||||
from app.uploads import upload_path
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_native_translation_and_mod_edits_rebuild_the_same_vm_input(tmp_path, monkeypatch):
|
||||
from app import mods
|
||||
from app.config import get_settings
|
||||
from app.converter.naninovel import _convert_package
|
||||
from app.converter.nani_package import Package, text_records
|
||||
from app.converter.tomie_format import unpack_modules
|
||||
from app.converter.tomie_profile import PROFILE
|
||||
from app.translation import apply_changes, text_index, locale_tables
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path / "data"))
|
||||
get_settings.cache_clear()
|
||||
package = Package(profile=PROFILE, defaults={"points": 3}, function_data={"names": ["Tomie"]})
|
||||
|
||||
def script(name, label, commands):
|
||||
return {"name": name, "lines": [{"line": 1, "label": label, "commands": commands}]}
|
||||
|
||||
package.scripts = {
|
||||
"StartGameSetting": script("StartGameSetting", "Start", [
|
||||
{"op": "PrintText", "args": {"Text": {"template": "Points: {points}"}}},
|
||||
{"op": "ExitToTitle", "args": {}}]),
|
||||
"MessagePlay": script("MessagePlay", "Start", [{"op": "Return", "args": {}}]),
|
||||
"AppScreen": script("AppScreen", "Start", [{"op": "Stop", "args": {}}]),
|
||||
}
|
||||
package.texts = text_records("Tips", "tip:Title|Description {points}", "Tips")
|
||||
media = {key: {} for key in ("backgrounds", "characters", "textures", "videos", "audio", "fonts")}
|
||||
monkeypatch.setattr("app.converter.tomie_assets.export_media", lambda *_: ({}, media, {}))
|
||||
try:
|
||||
result = _convert_package(tmp_path, tmp_path / "work", lambda _: None, package)
|
||||
index = text_index(result.manifest)
|
||||
assert {line.source for line in index.lines} == {"Points: {points}", "Title", "Description {points}"}
|
||||
line = next(line for line in index.lines if line.source.startswith("Points:"))
|
||||
translated = apply_changes(result.manifest, "ru", lines={line.tid: {"t": "Баллы: {points}"}})
|
||||
assert locale_tables(translated, "ru")[0][line.tid]["t"] == "Баллы: {points}"
|
||||
doc = mods.document(translated)
|
||||
mods.validate(doc, translated)
|
||||
node = next(node for module in doc["scripts"].values() for label in module["labels"]
|
||||
for node in label["body"] if node.get("tid") == line.tid)
|
||||
node["what"] = "Score: {points}"
|
||||
updated = mods.store(doc, translated)
|
||||
runtime = unpack_modules(mods.document(updated)["scripts"])
|
||||
assert runtime["StartGameSetting"]["commands"][0]["args"]["Text"] == {"template": "Score: {points}"}
|
||||
module = next(module for module in doc["scripts"].values() if module.get("nani", {}).get("name") == "StartGameSetting")
|
||||
module["labels"][0]["body"].insert(0, {"id": "inserted", "op": "nani_command", "command": {
|
||||
"op": "SetCustomVariable", "args": {"Expression": "points+=1"}}})
|
||||
assert unpack_modules(doc["scripts"])["StartGameSetting"]["labels"]["Start"] == 1
|
||||
mods.validate(doc, translated)
|
||||
node["command"]["op"] = "NativePlugin"
|
||||
with pytest.raises(ValueError, match="NativePlugin"):
|
||||
mods.validate(doc, translated)
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_video_recovery_checks_ranges_and_copies_only_declared_bytes(tmp_path):
|
||||
from app.converter.tomie_assets import recover_object
|
||||
data = tmp_path / "data"
|
||||
out = tmp_path / "out"
|
||||
data.mkdir(); out.mkdir()
|
||||
original = b"header-clip-trailer"
|
||||
(data / "resources.resource").write_bytes(original)
|
||||
tree = {"m_OriginalPath": "Assets/Video/clip.webm", "m_ExternalResources": {
|
||||
"m_Source": "resources.resource", "m_Offset": 7, "m_Size": 4}}
|
||||
obj = SimpleNamespace(type=SimpleNamespace(name="VideoClip"), read=lambda: SimpleNamespace(m_Name="Clip"),
|
||||
read_typetree=lambda: tree)
|
||||
recovered = recover_object(obj, data, out, "video")
|
||||
assert recovered["path"].read_bytes() == b"clip"
|
||||
assert (data / "resources.resource").read_bytes() == original
|
||||
tree["m_ExternalResources"]["m_Size"] = len(original)
|
||||
with pytest.raises(ValueError, match="диапазон"):
|
||||
recover_object(obj, data, out, "invalid")
|
||||
@@ -0,0 +1,91 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import os
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.converter.nani_expr import assignment, parse
|
||||
from app.converter.tomie_profile import ASSEMBLY_HASH, FUNCTIONS, constant_value, extract_tables, identify
|
||||
|
||||
|
||||
def instruction(op, operand=None):
|
||||
return SimpleNamespace(mnemonic=op, operand=operand)
|
||||
|
||||
|
||||
def test_il_table_reader_accepts_constants_and_refuses_code():
|
||||
code = [instruction("ldc.i4.2"), instruction("newarr"), instruction("dup"), instruction("ldc.i4.0"),
|
||||
instruction("ldstr", SimpleNamespace(rid=1)), instruction("stelem.ref"),
|
||||
instruction("dup"), instruction("ldc.i4.1"), instruction("ldstr", SimpleNamespace(rid=2)), instruction("stelem.ref")]
|
||||
assert constant_value(code, lambda rid: {1: "A", 2: "B"}[rid]) == ["A", "B"]
|
||||
with pytest.raises(ValueError, match="инициализация"):
|
||||
constant_value([instruction("call")], lambda _: "")
|
||||
with pytest.raises(ValueError, match="Неполная"):
|
||||
constant_value([instruction("ldc.i4.1"), instruction("newarr")], lambda _: "")
|
||||
|
||||
|
||||
def test_tomie_profile_requires_exact_assembly(tmp_path):
|
||||
path = tmp_path / "TomieWGM_Data/Managed/Assembly-CSharp.dll"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_bytes(b"a renamed unrelated game")
|
||||
assert identify(tmp_path) is None
|
||||
with pytest.raises(ValueError, match="не соответствует"):
|
||||
extract_tables(tmp_path)
|
||||
|
||||
|
||||
def test_profile_expression_functions_are_opt_in():
|
||||
with pytest.raises(ValueError, match="WillpowerDamage"):
|
||||
parse("WillpowerDamage(100, 50, 20, 0, 100)")
|
||||
assert parse("WillpowerDamage(100, 50, 20, 0, 100)", FUNCTIONS)[0] == "call"
|
||||
assert parse('\\"quoted\\"', FUNCTIONS) == ["const", "quoted"]
|
||||
assert parse('If(false, 1/0, 5)', FUNCTIONS)[0] == "if"
|
||||
assert assignment("points+=3", FUNCTIONS)["v"]["ast"] == ["bin", "+", ["name", "points"], ["const", 3]]
|
||||
|
||||
|
||||
def test_full_vm_validation_rejects_unimplemented_and_broken_commands():
|
||||
from app.converter.tomie_validation import validate_runtime
|
||||
data = {"scripts": {"Main": {"labels": {"Start": 0}, "commands": [
|
||||
{"op": "AddChoice", "args": {"ChoiceSummary": "OK", "OnSelected": ['@set result=If(true, 1, 2)']}, "line": 1},
|
||||
{"op": "Goto", "args": {"Path": ".Missing"}, "line": 2},
|
||||
{"op": "PlayBgm", "args": {"BgmPath": "song", "CustomPlugin": True}, "line": 3},
|
||||
{"op": "NativeCode", "args": {}, "line": 4},
|
||||
{"op": "BeginIf", "args": {"Expression": "true"}, "line": 5},
|
||||
{"op": "AddChoice", "args": {"OnSelected": ['@set points+1.5']}, "line": 6},
|
||||
]}}}
|
||||
issues = validate_runtime(data)
|
||||
assert {item.code for item in issues} == {"missing_target", "unsupported_argument", "unsupported_command", "invalid_branch", "unsupported_choice"}
|
||||
assert not any(item.location == "Main:1" for item in issues)
|
||||
|
||||
|
||||
def test_choice_templates_and_quoted_commas_are_validated_without_evaluating_them():
|
||||
from app.converter.tomie_validation import inline_commands, validate_runtime
|
||||
assert inline_commands('@set x=If(true, "a,b", "c"), @return') == ['@set x=If(true, "a,b", "c")', '@return']
|
||||
data = {"scripts": {"Main": {"labels": {}, "commands": [
|
||||
{"op": "AddChoice", "args": {"OnSelected": {"template": '@set result="{answer}"'}}},
|
||||
{"op": "AddChoice", "args": {"OnSelected": ['@goto Other.Start']}},
|
||||
]}, "Other": {"labels": {"Start": 0}, "commands": []}}}
|
||||
assert validate_runtime(data) == []
|
||||
with pytest.raises(ValueError, match="Незакрытая"):
|
||||
inline_commands('@set x=If(true, 1')
|
||||
|
||||
|
||||
def test_native_effect_and_ui_extensions_are_rejected_before_media_export():
|
||||
from app.converter.tomie_validation import validate_runtime
|
||||
data = {"scripts": {"Main": {"labels": {}, "commands": [
|
||||
{"op": "Spawn", "args": {"Path": "NativePlugin"}},
|
||||
{"op": "ShowUI", "args": {"UINames": ["CustomScreen"]}},
|
||||
{"op": "EndIf", "args": {}},
|
||||
]}}}
|
||||
assert {issue.code for issue in validate_runtime(data)} == {"unsupported_effect", "unsupported_ui"}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not os.getenv("PGAMES_TOMIE_ORIGINAL"), reason="Requires the user's original Tomie build")
|
||||
def test_original_il_tables_match_the_recovered_port():
|
||||
from hashlib import sha256
|
||||
root = Path(os.environ["PGAMES_TOMIE_ORIGINAL"])
|
||||
assert sha256((root / "TomieWGM_Data/Managed/Assembly-CSharp.dll").read_bytes()).hexdigest() == ASSEMBLY_HASH
|
||||
tables = extract_tables(root)
|
||||
assert {key: len(value) for key, value in tables.items()} == {"names": 2738, "normalComment": 55, "lewdComment": 181, "videos": 191}
|
||||
if os.getenv("PGAMES_TOMIE_PORT"):
|
||||
reference = json.loads((Path(os.environ["PGAMES_TOMIE_PORT"]) / "story/function_data.json").read_text(encoding="utf-8"))
|
||||
assert tables == reference
|
||||
Reference in new issue
Block a user