Files
RustNotMC/tools/extract-rustex-assets.py
sasheg dev 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, деревья - пока блоки мира.
2026-10-21 19:31:51 +03:00

214 lines
7.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())