- replace the Haskell bridge with transactional SQLite state and validation - centralize versions and harden resource, capture, and release tooling - complete active gacha schedules, point exchange, and mail grants - add equipment refinement, presets, and ordinary formation workflows - improve client plugins, request timing, and normalized persistence
376 lines
14 KiB
Python
376 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Create a searchable, read-only deobfuscated mirror of C# client source.
|
|
|
|
The original source tree is never modified. Mapping files use ``left⇨right``;
|
|
``#ReverseOrder`` reverses mappings on subsequent lines. Output is a mirror
|
|
with a JSON manifest describing every effective replacement and limitation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
from typing import Iterable
|
|
import unicodedata
|
|
|
|
try:
|
|
from .inherited_stage import create_inherited_stage
|
|
except ImportError: # Direct script execution.
|
|
from inherited_stage import create_inherited_stage
|
|
|
|
|
|
TOOL = "bd2.deobfuscate_client_source"
|
|
MANIFEST = ".bd2-deobfuscate-manifest.json"
|
|
ARROW = "⇨"
|
|
CSHARP_KEYWORDS = {
|
|
"abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char",
|
|
"checked", "class", "const", "continue", "decimal", "default", "delegate", "do",
|
|
"double", "else", "enum", "event", "explicit", "extern", "false", "finally",
|
|
"fixed", "float", "for", "foreach", "goto", "if", "implicit", "in", "int",
|
|
"interface", "internal", "is", "lock", "long", "namespace", "new", "null",
|
|
"object", "operator", "out", "override", "params", "private", "protected", "public",
|
|
"readonly", "ref", "return", "sbyte", "sealed", "short", "sizeof", "stackalloc",
|
|
"static", "string", "struct", "switch", "this", "throw", "true", "try", "typeof",
|
|
"uint", "ulong", "unchecked", "unsafe", "ushort", "using", "virtual", "void",
|
|
"volatile", "while",
|
|
}
|
|
|
|
|
|
def is_within(child: Path, parent: Path) -> bool:
|
|
try:
|
|
child.relative_to(parent)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def require_distinct_trees(source: Path, output: Path) -> tuple[Path, Path]:
|
|
source, output = source.resolve(), output.resolve()
|
|
if not source.is_dir():
|
|
raise ValueError(f"source is not a directory: {source}")
|
|
if source == output or is_within(output, source) or is_within(source, output):
|
|
raise ValueError("output must be outside, and not contain, source")
|
|
return source, output
|
|
|
|
|
|
def managed_output(output: Path) -> bool:
|
|
manifest = output / MANIFEST
|
|
if not manifest.is_file():
|
|
return False
|
|
try:
|
|
return json.loads(manifest.read_text(encoding="utf-8")).get("tool") == TOOL
|
|
except (OSError, json.JSONDecodeError):
|
|
return False
|
|
|
|
|
|
def prepare_stage(output: Path) -> Path:
|
|
if output.exists() and not managed_output(output):
|
|
raise FileExistsError(
|
|
f"refusing to overwrite non-managed output directory: {output}"
|
|
)
|
|
return create_inherited_stage(output)
|
|
|
|
|
|
def publish_stage(stage: Path, output: Path) -> None:
|
|
if output.exists():
|
|
# The caller already established that this is a directory created by us.
|
|
shutil.rmtree(output)
|
|
stage.replace(output)
|
|
|
|
|
|
def identifier_from_meaning(value: str) -> str:
|
|
"""Turn a map value into one legal, readable C# identifier.
|
|
|
|
Translation databases sometimes store a qualified path such as
|
|
``Net.Player/UserInfo``. An identifier replacement must be a single token,
|
|
so the useful terminal component is selected before normalising it.
|
|
"""
|
|
parts = [part for part in re.split(r"(?:\.|::|/|\\)+", value.strip()) if part]
|
|
raw = parts[-1] if parts else value.strip()
|
|
result = []
|
|
for index, char in enumerate(raw):
|
|
if (char == "_" or char.isascii() and char.isalpha() or
|
|
index > 0 and char.isascii() and char.isdigit()):
|
|
result.append(char)
|
|
elif char.isascii() and char.isdigit() and index == 0:
|
|
result.extend(("_", char))
|
|
else:
|
|
result.append("_")
|
|
name = "".join(result).strip("_") or "unnamed"
|
|
if name[0].isdigit():
|
|
name = "_" + name
|
|
if name in CSHARP_KEYWORDS:
|
|
name = "_" + name
|
|
return name
|
|
|
|
|
|
def is_identifier_start(char: str) -> bool:
|
|
return char == "_" or unicodedata.category(char) in {
|
|
"Lu", "Ll", "Lt", "Lm", "Lo", "Nl",
|
|
}
|
|
|
|
|
|
def is_identifier_continue(char: str) -> bool:
|
|
return is_identifier_start(char) or unicodedata.category(char) in {
|
|
"Mn", "Mc", "Nd", "Pc", "Cf",
|
|
}
|
|
|
|
|
|
def parse_mapping(path: Path) -> tuple[list[dict], list[str]]:
|
|
"""Read the mapping while retaining malformed/conflicting entries as notes."""
|
|
entries: list[dict] = []
|
|
warnings: list[str] = []
|
|
for line_number, raw_line in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), 1):
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("//"):
|
|
continue
|
|
if line.casefold() == "#reverseorder":
|
|
# Official files carry this as format metadata, but their actual
|
|
# rows are still visibly obfuscated-name ⇨ readable-name.
|
|
continue
|
|
if ARROW not in line:
|
|
if not line.startswith("#"):
|
|
warnings.append(f"line {line_number}: ignored (no {ARROW!r})")
|
|
continue
|
|
left, right = (part.strip() for part in line.split(ARROW, 1))
|
|
source, meaning = left, right
|
|
if not source or not meaning:
|
|
warnings.append(f"line {line_number}: ignored (empty mapping side)")
|
|
continue
|
|
if not source or not is_identifier_start(source[0]) or not all(
|
|
is_identifier_continue(char) for char in source[1:]):
|
|
warnings.append(f"line {line_number}: ignored (non-identifier source {source!r})")
|
|
continue
|
|
entries.append({
|
|
"line": line_number,
|
|
"source": source,
|
|
"meaning": meaning,
|
|
})
|
|
return entries, warnings
|
|
|
|
|
|
def build_replacements(entries: Iterable[dict]) -> tuple[dict[str, str], list[dict], list[str]]:
|
|
replacements: dict[str, str] = {}
|
|
report: list[dict] = []
|
|
warnings: list[str] = []
|
|
used: set[str] = set()
|
|
grouped: dict[str, list[dict]] = {}
|
|
for entry in entries:
|
|
grouped.setdefault(entry["source"], []).append(entry)
|
|
for source, candidates in grouped.items():
|
|
meanings = {entry["meaning"] for entry in candidates}
|
|
if len(meanings) != 1:
|
|
warnings.append(
|
|
f"source {source!r}: ignored ambiguous scoped mappings "
|
|
f"({len(meanings)} meanings)"
|
|
)
|
|
continue
|
|
entry = candidates[0]
|
|
base = identifier_from_meaning(entry["meaning"])
|
|
replacement = base
|
|
collision = False
|
|
if replacement in used:
|
|
collision = True
|
|
suffix = re.sub(r"[^A-Za-z0-9_]", "_", source)
|
|
if not suffix.strip("_"):
|
|
suffix = "_".join(f"u{ord(char):04X}" for char in source)
|
|
replacement = f"{base}__from_{suffix}"
|
|
number = 2
|
|
while replacement in used:
|
|
replacement = f"{base}__from_{suffix}_{number}"
|
|
number += 1
|
|
used.add(replacement)
|
|
replacements[source] = replacement
|
|
report.append({
|
|
**entry,
|
|
"replacement": replacement,
|
|
"sanitized": replacement != entry["meaning"],
|
|
"name_collision": collision,
|
|
})
|
|
return replacements, report, warnings
|
|
|
|
|
|
def _consume_quoted(text: str, start: int, quote: str) -> int:
|
|
"""Return the index after a C# string/character/raw-string literal."""
|
|
quotes = 0
|
|
while start + quotes < len(text) and text[start + quotes] == quote:
|
|
quotes += 1
|
|
if quote == '"' and quotes >= 3:
|
|
end_marker = quote * quotes
|
|
end = text.find(end_marker, start + quotes)
|
|
return len(text) if end < 0 else end + quotes
|
|
index = start + 1
|
|
verbatim = start > 0 and text[start - 1] == "@"
|
|
while index < len(text):
|
|
if verbatim and quote == '"' and text.startswith('""', index):
|
|
index += 2
|
|
elif text[index] == quote:
|
|
return index + 1
|
|
elif not verbatim and text[index] == "\\":
|
|
index += 2
|
|
else:
|
|
index += 1
|
|
return len(text)
|
|
|
|
|
|
def replace_csharp_identifiers(text: str, replacements: dict[str, str]) -> tuple[str, int]:
|
|
"""Replace code identifiers only; comments and literal payloads stay exact."""
|
|
output: list[str] = []
|
|
index = changed = 0
|
|
length = len(text)
|
|
while index < length:
|
|
if text.startswith("//", index):
|
|
end = text.find("\n", index)
|
|
end = length if end < 0 else end
|
|
output.append(text[index:end])
|
|
index = end
|
|
elif text.startswith("/*", index):
|
|
end = text.find("*/", index + 2)
|
|
end = length if end < 0 else end + 2
|
|
output.append(text[index:end])
|
|
index = end
|
|
elif text[index] in "\"'":
|
|
end = _consume_quoted(text, index, text[index])
|
|
output.append(text[index:end])
|
|
index = end
|
|
elif is_identifier_start(text[index]):
|
|
end = index + 1
|
|
while end < length and is_identifier_continue(text[end]):
|
|
end += 1
|
|
token = text[index:end]
|
|
replacement = replacements.get(token, token)
|
|
output.append(replacement)
|
|
changed += replacement != token
|
|
index = end
|
|
else:
|
|
output.append(text[index])
|
|
index += 1
|
|
return "".join(output), changed
|
|
|
|
|
|
def read_csharp(path: Path) -> tuple[str, str] | None:
|
|
data = path.read_bytes()
|
|
if data.startswith(b"\xff\xfe"):
|
|
return data[2:].decode("utf-16-le"), "utf-16-le"
|
|
if data.startswith(b"\xfe\xff"):
|
|
return data[2:].decode("utf-16-be"), "utf-16-be"
|
|
try:
|
|
return data.decode("utf-8-sig"), "utf-8-sig" if data.startswith(b"\xef\xbb\xbf") else "utf-8"
|
|
except UnicodeDecodeError:
|
|
return None
|
|
|
|
|
|
def write_csharp(path: Path, text: str, encoding: str) -> None:
|
|
if encoding == "utf-16-le":
|
|
path.write_bytes(b"\xff\xfe" + text.encode(encoding))
|
|
elif encoding == "utf-16-be":
|
|
path.write_bytes(b"\xfe\xff" + text.encode(encoding))
|
|
elif encoding == "utf-8-sig":
|
|
path.write_bytes(text.encode(encoding))
|
|
else:
|
|
path.write_text(text, encoding="utf-8", newline="")
|
|
|
|
|
|
def destination_for(relative: Path, replacements: dict[str, str], occupied: set[Path]) -> Path:
|
|
if relative.suffix.casefold() != ".cs":
|
|
return relative
|
|
stem = replacements.get(relative.stem, relative.stem)
|
|
candidate = relative.with_name(stem + relative.suffix)
|
|
if candidate not in occupied:
|
|
return candidate
|
|
number = 2
|
|
while True:
|
|
candidate = relative.with_name(f"{stem}__file_{number}{relative.suffix}")
|
|
if candidate not in occupied:
|
|
return candidate
|
|
number += 1
|
|
|
|
|
|
def deobfuscate(source: Path, mapping: Path, output: Path) -> dict:
|
|
source, output = require_distinct_trees(source, output)
|
|
mapping = mapping.resolve()
|
|
if not mapping.is_file():
|
|
raise ValueError(f"mapping is not a file: {mapping}")
|
|
entries, warnings = parse_mapping(mapping)
|
|
replacements, mapping_report, replacement_warnings = build_replacements(entries)
|
|
warnings.extend(replacement_warnings)
|
|
stage = prepare_stage(output)
|
|
files: list[dict] = []
|
|
occupied: set[Path] = set()
|
|
try:
|
|
for input_path in sorted(path for path in source.rglob("*") if path.is_file()):
|
|
relative = input_path.relative_to(source)
|
|
destination = destination_for(relative, replacements, occupied)
|
|
occupied.add(destination)
|
|
destination_path = stage / destination
|
|
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
|
record = {"source": relative.as_posix(), "output": destination.as_posix()}
|
|
if input_path.suffix.casefold() == ".cs":
|
|
decoded = read_csharp(input_path)
|
|
if decoded is None:
|
|
shutil.copy2(input_path, destination_path)
|
|
record.update({"action": "copied", "reason": "unsupported text encoding"})
|
|
warnings.append(f"{relative}: copied without replacement (unsupported encoding)")
|
|
else:
|
|
text, encoding = decoded
|
|
translated, changed = replace_csharp_identifiers(text, replacements)
|
|
write_csharp(destination_path, translated, encoding)
|
|
record.update({"action": "translated", "identifier_replacements": changed})
|
|
else:
|
|
shutil.copy2(input_path, destination_path)
|
|
record["action"] = "copied"
|
|
files.append(record)
|
|
manifest = {
|
|
"tool": TOOL,
|
|
"source": str(source),
|
|
"mapping": str(mapping),
|
|
"mapping_entries": mapping_report,
|
|
"files": files,
|
|
"warnings": warnings,
|
|
"statistics": {
|
|
"mapping_entries": len(mapping_report),
|
|
"files": len(files),
|
|
"csharp_files": sum(item["source"].casefold().endswith(".cs") for item in files),
|
|
"identifier_replacements": sum(item.get("identifier_replacements", 0) for item in files),
|
|
},
|
|
"limitations": [
|
|
"Only C# identifier tokens are changed; comments and literal contents are preserved.",
|
|
"Qualified mapping values become their terminal identifier component.",
|
|
"This is a searchable mirror, not a promise that the transformed source compiles.",
|
|
],
|
|
}
|
|
(stage / MANIFEST).write_text(
|
|
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
|
)
|
|
publish_stage(stage, output)
|
|
return manifest
|
|
except Exception:
|
|
shutil.rmtree(stage, ignore_errors=True)
|
|
raise
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--source", required=True, type=Path, help="client Assembly-CSharp directory")
|
|
parser.add_argument("--mapping", required=True, type=Path, help="ObfuscationTranslation file")
|
|
parser.add_argument("--output", required=True, type=Path, help="mirror directory outside --source")
|
|
return parser
|
|
|
|
|
|
def main() -> int:
|
|
args = build_parser().parse_args()
|
|
try:
|
|
manifest = deobfuscate(args.source, args.mapping, args.output)
|
|
except (OSError, ValueError, UnicodeError) as exc:
|
|
raise SystemExit(f"deobfuscate_client_source: {exc}")
|
|
stats = manifest["statistics"]
|
|
print(f"wrote {args.output.resolve()} ({stats['files']} files, {stats['identifier_replacements']} replacements)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|