- Rate-limit failed logins (8 per 15 min per IP or username, 429 + Retry-After) - Add ruff/mypy config and GitHub Actions workflow (ruff, pytest, tsc, tests, build) - Apply ruff autofixes (import order, pyupgrade) - Extract rpy_helpers.py, engine-util.ts, TranslationParts.tsx and translation-types.ts from oversized modules - Add dependency-free React UI tests - Refresh README and ARCHITECTURE (Naninovel/Tomie, status and plans) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
159 lines
8.2 KiB
Python
159 lines
8.2 KiB
Python
"""Version-pinned Tomie metadata. Reads IL constants; never executes managed code."""
|
|
import hashlib
|
|
import re
|
|
from pathlib import Path
|
|
|
|
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 collections import Counter
|
|
|
|
from app.converter.nani_expr import assignment, parse
|
|
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()]}
|