158 lines
8.2 KiB
Python
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()]}
|