Files
pgames-manager/backend/app/converter/tomie_profile.py
T

158 lines
8.2 KiB
Python

"""Version-pinned Tomie metadata. Reads IL constants; never executes managed code."""
import hashlib
from pathlib import Path
import re
PROFILE = "tomie/expansion-1.3801"
ASSEMBLY_HASH = "1c6aec12894362f1b9c62858a5a4c286eb93aee175ed1ef339218dc5a7b2729b"
FUNCTIONS = set("WillpowerDamage RoundValue MinMax Random NewRandList AddRandList MergeRandList HasValue StringContain StringReplace ItemExist itemExist ItemCount AddListString AddListString2 AddListStringWithRemoveDot RemoveListString StringContains StringReplaceSeed IntParse GetListCount GetArrayCount GetFromArray GetFromRandPick2 GetFromRandPick3 StringSplitComma RandPick RandPick2 RandSort RandPicks GetRandValue GetRandList GetRandListViaIndex ExtractRandList GetTargetList Pow Round random Rand HasLabel GetName GetRandName GetNormalComment GetLewdComment GetVODFileCount GetVODFileNameVidIndex GetMODFileNames GetModList OpenURL IsChristmas GetVODFileNames HasVideo ResetString SumString".split())
def identify(root: Path) -> str | None:
assembly = root / "TomieWGM_Data" / "Managed" / "Assembly-CSharp.dll"
if assembly.is_file() and hashlib.sha256(assembly.read_bytes()).hexdigest() == ASSEMBLY_HASH:
return PROFILE
return None
def constant_value(instructions, user_string):
"""Only a constant string / string-array initializer, not an IL interpreter."""
stack = []
for instruction in instructions:
op, value = instruction.mnemonic, instruction.operand
if op == "nop":
continue
if op in {"ldc.i4", "ldc.i4.s"}:
stack.append(int(value))
elif op.startswith("ldc.i4."):
stack.append(-1 if op.endswith("m1") else int(op.rsplit(".", 1)[-1]))
elif op == "ldstr":
stack.append(user_string(value.rid))
elif op == "newarr":
size = stack.pop()
if not isinstance(size, int) or not 0 <= size <= 100000:
raise ValueError("Некорректный размер таблицы Tomie")
stack.append([None] * size)
elif op == "dup":
stack.append(stack[-1])
elif op == "stelem.ref":
element, index, array = stack.pop(), stack.pop(), stack.pop()
if not isinstance(array, list) or not 0 <= index < len(array) or not isinstance(element, str):
raise ValueError("Некорректный элемент таблицы Tomie")
array[index] = element
else:
raise ValueError(f"Неизвестная инициализация таблицы Tomie: {op}")
if len(stack) != 1 or not isinstance(stack[0], (str, list)):
raise ValueError("Не удалось восстановить таблицу Tomie")
if isinstance(stack[0], list) and any(not isinstance(value, str) for value in stack[0]):
raise ValueError("Неполная таблица Tomie")
return stack[0]
def extract_tables(root: Path) -> dict[str, list[str]]:
if identify(root) != PROFILE:
raise ValueError("Версия Assembly-CSharp.dll не соответствует адаптеру Tomie")
import dnfile
from dncil.cil.body.reader import read_method_body_from_bytes
pe = dnfile.dnPE(str(root / "TomieWGM_Data/Managed/Assembly-CSharp.dll"))
try:
definition = next(row for row in pe.net.mdtables.TypeDef.rows
if str(row.TypeNamespace) == "Naninovel" and str(row.TypeName) == "KBCustom")
initializer = next(method.row for method in definition.MethodList if str(method.row.Name) == ".cctor")
body = read_method_body_from_bytes(pe.get_data(initializer.Rva))
wanted = {"names", "normalComment", "lewdComment", "VODstr"}
tables, segment = {}, []
for instruction in body.instructions:
if instruction.mnemonic == "stsfld":
field = str(pe.net.mdtables.Field.rows[instruction.operand.rid - 1].Name)
if field in wanted:
if field in tables:
raise ValueError(f"Повторная инициализация таблицы Tomie: {field}")
tables[field] = constant_value(segment, lambda rid: pe.net.user_strings.get(rid).value)
segment = []
else:
segment.append(instruction)
if set(tables) != wanted:
raise ValueError("Не найдены все исходные таблицы Tomie")
tables["videos"] = tables.pop("VODstr").split(",")
return tables
finally:
pe.close()
def runtime_data(package) -> dict:
"""Keep the original flat command machine and exact label nesting depths."""
if package.profile != PROFILE or not package.defaults or not package.function_data:
raise ValueError("Не загружены исходные настройки проверенной сборки Tomie")
scripts = {}
for name, source in package.scripts.items():
commands, labels, depths, depth = [], {}, {}, 0
for line in source["lines"]:
if line.get("label"):
labels.setdefault(line["label"], len(commands))
depths.setdefault(line["label"], depth)
for index, command in enumerate(line["commands"]):
identity = hashlib.sha256(f"{name}:{line['line']}:{index}".encode()).hexdigest()[:24]
commands.append({**command, "line": line["line"], "tid": "nani-" + identity})
if command["op"] == "BeginIf":
depth += 1
elif command["op"] == "EndIf":
depth = max(0, depth - 1)
scripts[name] = {"commands": commands, "labels": labels, "label_depths": depths}
if "StartGameSetting" not in scripts or "Start" not in scripts["StartGameSetting"]["labels"]:
raise ValueError("Не найдено исходное создание персонажа Tomie")
return {"scripts": scripts, "defaults": package.defaults, "function_data": package.function_data,
"start": {"script": "StartGameSetting", "label": "Start"},
"initial_inventory": {"item_Phone": 1, "item_StudentID": 1, "item_Wardrobe": 1,
"item_Book": 1, "item_SecretDiary": 1}}
def validate_expressions(scripts: dict) -> dict:
"""Check the full recovered expression corpus, including dynamic templates."""
from app.converter.nani_expr import assignment, parse
from collections import Counter
expressions, errors = {}, Counter()
examples = {}
def add(source, location):
try:
expressions[source] = parse(source, FUNCTIONS)
except ValueError as exc:
errors[str(exc)] += 1
examples.setdefault(str(exc), location)
def templates(value, location):
if isinstance(value, dict):
if "template" in value:
for match in re.finditer(r"\{([^{}]+)\}", value["template"]):
add(match[1], location)
else:
for child in value.values():
templates(child, location)
elif isinstance(value, list):
for child in value:
templates(child, location)
for name, script in scripts.items():
for command in script["commands"]:
args, op = command["args"], command["op"]
location = f"{name}:{command.get('line', -1)}"
templates(args, location)
value = args.get("Expression")
if isinstance(value, str):
if op == "SetCustomVariable":
try:
expression = assignment(value, FUNCTIONS)["v"]["ast"]
expressions[value.split("=", 1)[1].strip()] = expression
except ValueError as exc:
errors[str(exc)] += 1
examples.setdefault(str(exc), location)
else:
add(value, location)
if isinstance(args.get("ConditionalExpression"), str):
add(args["ConditionalExpression"], location)
return {"count": len(expressions), "asts": expressions,
"issues": [{"message": message, "count": count, "location": examples[message]}
for message, count in errors.items()]}