База из ремейка: ассеты, предметы, звуки и правки плана
Выгрузили из ремейка 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, деревья - пока блоки мира.
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Turn the item database of the Rustex remake into a table we can read.
|
||||
|
||||
The remake keeps its item definitions in
|
||||
assets/rustex/legacy/minecraft/models/stacks.json . The file is not JSON
|
||||
but the Minecraft "stacks" notation: a sequence of records
|
||||
|
||||
{id:"minecraft:iron_hoe",Count:1s,tag:{rid:"m92pistol",rdamage:25,...}}
|
||||
|
||||
The tag keys are the remake's own names, so the table keeps them. The
|
||||
result is two files:
|
||||
|
||||
rsx-protocol/src/main/resources/items/items.csv one row per item
|
||||
assets/rustex/data/item-texts.json the titles and the
|
||||
descriptions out of
|
||||
the language file
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"G:\RustNotMC")
|
||||
STACKS = ROOT / "assets" / "rustex" / "legacy" / "minecraft" / "models" / "stacks.json"
|
||||
LANG = ROOT / "assets" / "rustex" / "legacy" / "minecraft" / "rustex_lang" / "en_us.lang"
|
||||
OUT_CSV = ROOT / "rsx-protocol" / "src" / "main" / "resources" / "items" / "items.csv"
|
||||
OUT_TEXTS = ROOT / "rsx-protocol" / "src" / "main" / "resources" / "items" / "item-texts.json"
|
||||
|
||||
# The columns of the table, in the order they are written.
|
||||
COLUMNS = [
|
||||
"rid", "name", "category", "material", "nid",
|
||||
"damage", "range", "cooldown", "reach",
|
||||
"durability", "durability_max",
|
||||
"wood", "ore", "skinning",
|
||||
"ammo", "ammo_type", "weapon_id", "gun", "throw", "model", "icon",
|
||||
"zoom_x", "zoom_y", "zoom_z", "zoom_count",
|
||||
"heal", "fast_heal", "calories", "hydration", "radiation", "bleeding",
|
||||
"consume_time", "food_cooldown", "eat_sound",
|
||||
"armor_projectile", "armor_melee", "armor_bite", "armor_cold",
|
||||
"armor_explosion", "armor_radiation", "armor_speed_reduction",
|
||||
"armor_slot",
|
||||
]
|
||||
|
||||
|
||||
class Parser:
|
||||
"""A reader for the Minecraft stacks notation."""
|
||||
|
||||
def __init__(self, text: str):
|
||||
self.text = text
|
||||
self.pos = 0
|
||||
|
||||
def skip(self):
|
||||
while self.pos < len(self.text) and self.text[self.pos] in " \t\r\n":
|
||||
self.pos += 1
|
||||
|
||||
def peek(self) -> str:
|
||||
self.skip()
|
||||
return self.text[self.pos] if self.pos < len(self.text) else ""
|
||||
|
||||
def read_value(self):
|
||||
self.skip()
|
||||
ch = self.text[self.pos]
|
||||
if ch == "{":
|
||||
return self.read_compound()
|
||||
if ch == "[":
|
||||
return self.read_list()
|
||||
if ch == '"':
|
||||
return self.read_string()
|
||||
return self.read_scalar()
|
||||
|
||||
def read_compound(self):
|
||||
self.pos += 1 # {
|
||||
out = {}
|
||||
while True:
|
||||
self.skip()
|
||||
ch = self.peek()
|
||||
if ch == "}":
|
||||
self.pos += 1
|
||||
return out
|
||||
if ch == ",":
|
||||
self.pos += 1
|
||||
continue
|
||||
key = self.read_key()
|
||||
self.skip()
|
||||
if self.pos < len(self.text) and self.text[self.pos] == ":":
|
||||
self.pos += 1
|
||||
out[key] = self.read_value()
|
||||
|
||||
def read_list(self):
|
||||
self.pos += 1 # [
|
||||
out = []
|
||||
while True:
|
||||
self.skip()
|
||||
if self.peek() == "]":
|
||||
self.pos += 1
|
||||
return out
|
||||
out.append(self.read_value())
|
||||
self.skip()
|
||||
if self.peek() == ",":
|
||||
self.pos += 1
|
||||
|
||||
def read_key(self):
|
||||
start = self.pos
|
||||
while self.text[self.pos] not in ':,}]':
|
||||
self.pos += 1
|
||||
return self.text[start:self.pos].strip()
|
||||
|
||||
def read_string(self):
|
||||
self.pos += 1 # "
|
||||
out = []
|
||||
while self.text[self.pos] != '"':
|
||||
if self.text[self.pos] == "\\":
|
||||
self.pos += 1
|
||||
out.append(self.text[self.pos])
|
||||
self.pos += 1
|
||||
self.pos += 1
|
||||
return "".join(out)
|
||||
|
||||
def read_scalar(self):
|
||||
start = self.pos
|
||||
while self.pos < len(self.text) and self.text[self.pos] not in ',}]':
|
||||
self.pos += 1
|
||||
raw = self.text[start:self.pos].strip()
|
||||
if raw.endswith("b"):
|
||||
return raw[:-1] == "1"
|
||||
if raw.endswith("s"):
|
||||
return int(raw[:-1])
|
||||
if raw.endswith("d") or raw.endswith("f"):
|
||||
return float(raw[:-1])
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return raw
|
||||
|
||||
|
||||
def flatten(tag: dict) -> dict:
|
||||
"""The fields of a tag, with the names we use in the table."""
|
||||
out = {}
|
||||
|
||||
def num(value):
|
||||
if isinstance(value, bool):
|
||||
return 1 if value else 0
|
||||
if isinstance(value, (int, float)):
|
||||
return value
|
||||
return None
|
||||
|
||||
def put(key, value):
|
||||
n = num(value)
|
||||
if n is not None:
|
||||
out[key] = n
|
||||
|
||||
put("damage", tag.get("rdamage"))
|
||||
put("range", tag.get("rrange"))
|
||||
put("cooldown", tag.get("rcooldown"))
|
||||
put("reach", tag.get("rustreach"))
|
||||
put("durability", tag.get("rdurability"))
|
||||
put("durability_max", tag.get("rdurabilitymax"))
|
||||
put("wood", tag.get("rwood"))
|
||||
put("ore", tag.get("rore"))
|
||||
put("skinning", tag.get("skinning"))
|
||||
put("ammo", tag.get("rammo"))
|
||||
put("ammo_type", tag.get("rammotype"))
|
||||
put("weapon_id", tag.get("rweaponid"))
|
||||
put("gun", tag.get("rustgun"))
|
||||
put("throw", tag.get("rustthrow"))
|
||||
put("zoom_x", tag.get("rustzoomx"))
|
||||
put("zoom_y", tag.get("rustzoomy"))
|
||||
put("zoom_z", tag.get("rustzoomz"))
|
||||
put("zoom_count", tag.get("rustzoomcount"))
|
||||
put("heal", tag.get("rheal"))
|
||||
put("fast_heal", tag.get("rfastheal"))
|
||||
put("calories", tag.get("rcalories"))
|
||||
put("hydration", tag.get("rhydration"))
|
||||
put("radiation", tag.get("rradiation"))
|
||||
put("bleeding", tag.get("rbleeding"))
|
||||
put("consume_time", tag.get("rconsumetime"))
|
||||
put("food_cooldown", tag.get("rfoodcooldown"))
|
||||
put("armor_projectile", tag.get("rarmorprojectile"))
|
||||
put("armor_melee", tag.get("rarmormelee"))
|
||||
put("armor_bite", tag.get("rarmorbite"))
|
||||
put("armor_cold", tag.get("rarmorcold"))
|
||||
put("armor_explosion", tag.get("rarmorexplosion"))
|
||||
put("armor_radiation", tag.get("rarmorradiation"))
|
||||
put("armor_speed_reduction", tag.get("rarmorspeedreduction"))
|
||||
|
||||
display = tag.get("display")
|
||||
if isinstance(display, dict):
|
||||
name = display.get("Name")
|
||||
if isinstance(name, str):
|
||||
out["name"] = re.sub(r"^.\S*", "", name).strip()
|
||||
for slot, value in tag.items():
|
||||
if slot.startswith("armorslot.") and value:
|
||||
out["armor_slot"] = slot[len("armorslot."):]
|
||||
return out
|
||||
|
||||
|
||||
def parse_stacks(text: str):
|
||||
parser = Parser(text)
|
||||
items = []
|
||||
while True:
|
||||
parser.skip()
|
||||
if parser.pos >= len(parser.text):
|
||||
break
|
||||
if parser.peek() != "{":
|
||||
parser.pos += 1
|
||||
continue
|
||||
record = parser.read_compound()
|
||||
tag = record.get("tag")
|
||||
if not isinstance(tag, dict):
|
||||
continue
|
||||
rid = tag.get("rid")
|
||||
if not isinstance(rid, str):
|
||||
continue
|
||||
row = {
|
||||
"rid": rid,
|
||||
"category": tag.get("rcategory", ""),
|
||||
"material": tag.get("rmaterial", ""),
|
||||
"nid": tag.get("nid", ""),
|
||||
"model": tag.get("rmodel", ""),
|
||||
"icon": tag.get("rusticon", ""),
|
||||
}
|
||||
row.update(flatten(tag))
|
||||
items.append(row)
|
||||
return items
|
||||
|
||||
|
||||
def parse_lang(text: str):
|
||||
"""The item titles and descriptions out of the language file."""
|
||||
out = {}
|
||||
for line in text.splitlines():
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
match = re.match(r"^rustex\.items\.([a-z]+)\.([a-z0-9_]+)\.(title|disc)$", key)
|
||||
if not match:
|
||||
continue
|
||||
category, rid, kind = match.groups()
|
||||
entry = out.setdefault(rid, {"category": category})
|
||||
entry[kind] = value
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not STACKS.is_file():
|
||||
print("нет stacks.json:", STACKS)
|
||||
return 1
|
||||
|
||||
items = parse_stacks(STACKS.read_text(encoding="utf-8", errors="replace"))
|
||||
print("предметов:", len(items))
|
||||
|
||||
OUT_CSV.parent.mkdir(parents=True, exist_ok=True)
|
||||
with OUT_CSV.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=COLUMNS, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
for item in items:
|
||||
writer.writerow({key: item.get(key, "") for key in COLUMNS})
|
||||
print("таблица:", OUT_CSV)
|
||||
|
||||
if LANG.is_file():
|
||||
texts = parse_lang(LANG.read_text(encoding="utf-8", errors="replace"))
|
||||
OUT_TEXTS.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT_TEXTS.write_text(json.dumps(texts, ensure_ascii=False, indent=1),
|
||||
encoding="utf-8")
|
||||
print("описаний:", len(texts), "->", OUT_TEXTS)
|
||||
|
||||
# What the table has to tell us about the weapons.
|
||||
guns = [i for i in items if i.get("gun")]
|
||||
print("огнестрела в таблице:", len(guns))
|
||||
for gun in guns:
|
||||
print(" %-22s id=%-3s магазин=%-3s модель=%s"
|
||||
% (gun["rid"], gun.get("weapon_id", "?"), gun.get("ammo", "?"),
|
||||
gun.get("model", "-")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The sound registry of the Rustex remake as a table.
|
||||
|
||||
The remake keeps its sounds in two places: the files under sounds/ and
|
||||
fsounds/ and the registry sounds.json plus rustex_sounds.json, which say
|
||||
which file belongs to which event.
|
||||
|
||||
The result is one csv:
|
||||
|
||||
rsx-protocol/src/main/resources/items/sounds.csv
|
||||
|
||||
with the columns id, file, volume, pitch.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"G:\RustNotMC")
|
||||
ASSETS = ROOT / "assets" / "rustex" / "legacy" / "minecraft"
|
||||
OUT = ROOT / "rsx-protocol" / "src" / "main" / "resources" / "items" / "sounds.csv"
|
||||
|
||||
|
||||
def collect(path: Path, out: dict):
|
||||
if not path.is_file():
|
||||
return
|
||||
data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
|
||||
for key, value in data.items():
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
sounds = value.get("sounds")
|
||||
if isinstance(sounds, list):
|
||||
for sound in sounds:
|
||||
if isinstance(sound, dict) and "name" in sound:
|
||||
out.setdefault(key, []).append(
|
||||
(sound["name"], sound.get("volume", 1.0), sound.get("pitch", 1.0)))
|
||||
elif isinstance(sounds, str):
|
||||
out.setdefault(key, []).append((sounds, 1.0, 1.0))
|
||||
name = value.get("name")
|
||||
if isinstance(name, str):
|
||||
out.setdefault(key, []).append((name, 1.0, 1.0))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
out = {}
|
||||
collect(ASSETS / "sounds.json", out)
|
||||
collect(ASSETS / "rustex_sounds.json", out)
|
||||
print("событий:", len(out))
|
||||
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
rows = 0
|
||||
with OUT.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow(["id", "file", "volume", "pitch"])
|
||||
for key in sorted(out):
|
||||
for name, volume, pitch in out[key]:
|
||||
writer.writerow([key, name, volume, pitch])
|
||||
rows += 1
|
||||
print("строк:", rows, "->", OUT)
|
||||
|
||||
# The sounds that came with the remake, so that we know what exists.
|
||||
files = 0
|
||||
for base in (ASSETS / "sounds", ROOT / "assets" / "rustex" / "legacy" / "assets" / "fsounds"):
|
||||
if base.is_dir():
|
||||
files += sum(1 for _ in base.rglob("*.ogg"))
|
||||
print("файлов ogg на диске:", files)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user