27047e8071
Выгрузили из ремейка Rustex всё, на чём будем работать, и переписали план под реальные цифры. Ассеты: - tools/extract-rustex-assets.py распаковывает res/ и game.jar и декодирует однобайтовый XOR (ключ по сигнатуре: PNG ^0x89, OGG ^0x4F, JSON ^0x7B). Итог: assets/rustex, 1.65 ГБ, 19 тыс. файлов. В репозиторий не кладём. - Не прочитались 553 .obj, 106 .rrs, 113 .nbt: у них другой шифр, нужен разбор кода (REA). Таблицы (в репозитории): - items.csv: 218 предметов с уроном, дальностью, темпом, прочностью, добычей за удар, материалом, иконкой и моделью; - sounds.csv: 505 звуковых событий с файлами; - item-texts.json: 543 названия и описания предметов. План (PLAN.md): - Сбор теперь по балансу ремейка: камень берёт 50 дерева и 35 руды, топор 85 дерева, каменная кирка 80 руды. Дерево 750 топором рубится за 9 ударов, с меткой за 6-7 - ровно те 7-10 ударов, которых ждали. - Исследования - версия после 2020: два пути. Стол (15/30/60/120, нужен сам предмет, превращается в чертёж) и тех-дерево верстака (125/250/500, ветки с предками, разблокировка личная). Утверждение "тех-дерево бесплатно" было ошибкой community-вики. - Лозы больше нет: она из джунглей, а джунглей нет. Выкинута из HP-таблицы, цен, таймеров гниения и правил шкафа. - Новые разделы 1.10 (мусорка на дороге: серая бочка - скрап, синяя - компоненты, красная - нефть и топливо, плюс ящики с едой) и 1.11 (дороги: сетка 3-4 полос плюс кольцо, сегменты - точки спавна мусорки). - Первый источник скрапа - мусорка, лутбоксы отложены: вопрос о прогрессии крафта снят. - Узлы: руда - своя 3D-модель с AABB, деревья - пока блоки мира.
214 lines
7.1 KiB
Python
214 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
||
"""Extract the assets of the Rustex remake into a plain, decoded tree.
|
||
|
||
The remake ships its resources obfuscated: every file is XOR-ed with one
|
||
byte, and the byte differs per file. The key is recoverable because the
|
||
first bytes of every format are known:
|
||
|
||
PNG 89 50 4E 47 key = file[0] ^ 0x89
|
||
OGG 4F 67 67 53 key = file[0] ^ 0x4F
|
||
JSON 7B ... key = file[0] ^ 0x7B
|
||
|
||
Files that are already plain (KTX2, MODL, the icon atlas inside game.jar)
|
||
are copied as they are. The formats with a stronger cipher (.obj, .rrs,
|
||
.nbt, .properties, .lang) are copied untouched and reported, so that the
|
||
reverse engineering pass knows what is still missing.
|
||
|
||
The result lands in assets/rustex/ :
|
||
|
||
legacy/ the Minecraft style assets of res/assets
|
||
nextgen/ the high resolution models of res/nextgen
|
||
gamejar/ the resources packed into game.jar (the icon atlas)
|
||
maps/ the server map data (the monument layout)
|
||
MANIFEST.json
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import shutil
|
||
import sys
|
||
import time
|
||
import zipfile
|
||
from pathlib import Path
|
||
|
||
SRC_ROOT = Path(r"G:\rustexremake\rustexremake")
|
||
SRC_RES = SRC_ROOT / "gamefiles" / "res"
|
||
SRC_JAR = SRC_ROOT / "gamefiles" / "game.jar"
|
||
SRC_MAPS = SRC_ROOT / "working_dir" / "rustex" / "maps"
|
||
DST = Path(r"G:\RustNotMC\assets\rustex")
|
||
|
||
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
|
||
OGG_MAGIC = b"OggS"
|
||
KTX2_MAGIC = b"\xabKTX 20\xbb\r\n\x1a\n"
|
||
|
||
# The first byte of the plain file, per extension. None means "we do not
|
||
# know the format, try to brute force the key and score the result".
|
||
FIRST_BYTE = {
|
||
".png": 0x89,
|
||
".ogg": 0x4F,
|
||
".json": 0x7B,
|
||
}
|
||
|
||
# The formats we can never guess: their cipher is not a single byte.
|
||
OPAQUE = {".obj", ".rrs", ".nbt", ".bin", ".db", ".jpm"}
|
||
|
||
TEXTUAL = {".json", ".properties", ".lang", ".txt", ".mcmeta", ".fsh", ".vsh", ".glsl"}
|
||
|
||
|
||
def score_text(data: bytes) -> float:
|
||
"""How much of the data looks like readable text."""
|
||
if not data:
|
||
return 0.0
|
||
good = 0
|
||
for b in data:
|
||
if b in (9, 10, 13) or 32 <= b < 127:
|
||
good += 1
|
||
return good / len(data)
|
||
|
||
|
||
def unxor(data: bytes, key: int) -> bytes:
|
||
if key == 0:
|
||
return data
|
||
return bytes(b ^ key for b in data)
|
||
|
||
|
||
def decode_file(path: Path, ext: str):
|
||
"""Return (decoded bytes, key, kind, note)."""
|
||
data = path.read_bytes()
|
||
if not data:
|
||
return data, 0, "empty", "пустой файл"
|
||
|
||
# Already plain?
|
||
if data.startswith(PNG_MAGIC):
|
||
return data, 0, "png", ""
|
||
if data.startswith(OGG_MAGIC):
|
||
return data, 0, "ogg", ""
|
||
if data.startswith(KTX2_MAGIC):
|
||
return data, 0, "ktx2", ""
|
||
if data.startswith(b"MODL"):
|
||
return data, 0, "modl", ""
|
||
if data.startswith(b"glTF"):
|
||
return data, 0, "gltf", ""
|
||
|
||
# The fast path: the key is implied by the first byte of the format.
|
||
if ext in FIRST_BYTE:
|
||
key = data[0] ^ FIRST_BYTE[ext]
|
||
head = unxor(data[:16], key)
|
||
if ext == ".png" and head.startswith(PNG_MAGIC):
|
||
return unxor(data, key), key, "png", ""
|
||
if ext == ".ogg" and head.startswith(OGG_MAGIC):
|
||
return unxor(data, key), key, "ogg", ""
|
||
if ext == ".json" and head[:1] in (b"{", b"["):
|
||
return unxor(data, key), key, "json", ""
|
||
|
||
# The brute force path: find the key that makes the file text.
|
||
if ext in TEXTUAL or ext not in OPAQUE:
|
||
best = (0.0, 0, b"")
|
||
for key in range(1, 256):
|
||
head = unxor(data[:512], key)
|
||
s = score_text(head)
|
||
if s > best[0]:
|
||
best = (s, key, head)
|
||
if best[0] >= 0.95:
|
||
return unxor(data, best[1]), best[1], "text", ""
|
||
|
||
# Nothing worked.
|
||
return data, -1, "opaque", "ключ не подобрался"
|
||
|
||
|
||
def walk(base: Path):
|
||
for dirpath, dirnames, filenames in os.walk(base):
|
||
for name in filenames:
|
||
yield Path(dirpath) / name
|
||
|
||
|
||
def extract_tree(src: Path, dst: Path, manifest: list, stats: dict):
|
||
for path in walk(src):
|
||
rel = path.relative_to(src)
|
||
target = dst / rel
|
||
ext = path.suffix.lower()
|
||
try:
|
||
decoded, key, kind, note = decode_file(path, ext)
|
||
except Exception as exc: # a broken file must not kill the run
|
||
stats["error"] = stats.get("error", 0) + 1
|
||
manifest.append({"src": str(rel), "status": "error", "note": str(exc)})
|
||
continue
|
||
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
target.write_bytes(decoded)
|
||
|
||
bucket = stats.setdefault(kind, {"count": 0, "bytes": 0, "encoded": 0})
|
||
bucket["count"] += 1
|
||
bucket["bytes"] += len(decoded)
|
||
if key > 0:
|
||
bucket["encoded"] += 1
|
||
|
||
entry = {"src": str(rel), "kind": kind, "key": key, "bytes": len(decoded)}
|
||
if note:
|
||
entry["note"] = note
|
||
manifest.append(entry)
|
||
|
||
|
||
def extract_jar(jar: Path, dst: Path, manifest: list, stats: dict):
|
||
with zipfile.ZipFile(jar) as zf:
|
||
for info in zf.infolist():
|
||
if info.is_dir():
|
||
continue
|
||
name = info.filename
|
||
if not name.startswith("assets/"):
|
||
continue
|
||
target = dst / name
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
data = zf.read(name)
|
||
target.write_bytes(data)
|
||
stats.setdefault("jar", {"count": 0, "bytes": 0, "encoded": 0})
|
||
stats["jar"]["count"] += 1
|
||
stats["jar"]["bytes"] += len(data)
|
||
manifest.append({"src": name, "kind": "jar", "key": 0, "bytes": len(data)})
|
||
|
||
|
||
def main() -> int:
|
||
started = time.time()
|
||
if not SRC_RES.is_dir():
|
||
print("нет исходника:", SRC_RES)
|
||
return 1
|
||
|
||
# The tree is overwritten file by file, so a leftover from an aborted run
|
||
# is harmless. A hard delete is only a convenience, and Windows likes to
|
||
# keep a handle on a directory that was written a moment ago.
|
||
shutil.rmtree(DST, ignore_errors=True)
|
||
DST.mkdir(parents=True, exist_ok=True)
|
||
|
||
manifest: list = []
|
||
stats: dict = {}
|
||
|
||
print("1/3 legacy assets ...", flush=True)
|
||
extract_tree(SRC_RES / "assets", DST / "legacy", manifest, stats)
|
||
|
||
print("2/3 nextgen models ...", flush=True)
|
||
extract_tree(SRC_RES / "nextgen", DST / "nextgen", manifest, stats)
|
||
|
||
print("3/3 game.jar + maps ...", flush=True)
|
||
if SRC_JAR.is_file():
|
||
extract_jar(SRC_JAR, DST / "gamejar", manifest, stats)
|
||
if SRC_MAPS.is_dir():
|
||
extract_tree(SRC_MAPS, DST / "maps", manifest, stats)
|
||
|
||
total = sum(v["bytes"] for v in stats.values())
|
||
print("")
|
||
for kind in sorted(stats):
|
||
v = stats[kind]
|
||
print(" %-8s %6d файлов, %8.1f МБ, зашифровано %d"
|
||
% (kind, v["count"], v["bytes"] / 1048576.0, v["encoded"]))
|
||
print("всего: %.1f МБ за %.0f с" % (total / 1048576.0, time.time() - started))
|
||
|
||
(DST / "MANIFEST.json").write_text(
|
||
json.dumps({"stats": stats, "files": manifest}, ensure_ascii=False, indent=1),
|
||
encoding="utf-8")
|
||
print("манифест:", DST / "MANIFEST.json")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|