Огнестрел: таблица стволов с вики и границы реверса

- weapons.csv: 23 ствола с уроном, множителем в голову, темпом, магазином,
  перезарядкой, разбросом (конус прицела), отдачей, типом патрона, верстаком и
  ценами исследования. Источники - официальная вики и community-вики, расхождения
  помечены в колонке source. Не хватает цены (2 из 23) и дальности (18 из 23).

Границы реверса ремейка, чтобы не тратить на них время снова:
- классы в game.jar помечены DE AD C0 DE и зашифрованы своим форматом, не
  однобайтовым XOR как ассеты; единственный незашифрованный класс - точка входа
  net/minecraft/client/main/RustexMain, в ней видны два статических дешифратора
  строк (qnbt.super и vyuhke.super), а сами классы зашифрованы;
- REA не берет .class как цель (только архивы), Ghidra 12.1.4 стоит в
  D:\ghidra-tools и через analyzeHeadless импортирует распакованные классы;
- главное: урона, отдачи и спрея огнестрела в клиенте нет вообще, это серверные
  данные. Клиент знает только магазин, тип патрона, зум ADS, прочность и reach.
  REA даст формулы, а не числа, поэтому числа берём с вики.

Инструменты в tools/: inspect-constants.py (разбор constant pool класса) и
find-decryptor.py (поиск маркера и слов дешифратора).

План дополнен этими находками.
This commit is contained in:
sasheg dev
2026-10-21 20:17:55 +03:00
parent cda7198c76
commit c472bd4a8c
4 changed files with 209 additions and 3 deletions
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Find who reads the encrypted classes of the remake.
The class files inside game.jar start with DE AD C0 DE and are not readable
after that. Something has to decrypt them at runtime, and that something
lives in the launcher, in one of the jars or in the java directory. This
script looks for the marker and for the words that go with it.
Usage: find-decryptor.py <file or folder> [...]
"""
import sys
from pathlib import Path
MARKER = b"\xde\xad\xc0\xde"
WORDS = [b"deadcode", b"DEADC0DE", b"decrypt", b"Decrypt", b"cipher", b"Cipher",
b"ClassLoader", b"defineClass", b"xload", b"scramble", b"obfus"]
def scan(path):
hits = []
try:
data = path.read_bytes()
except OSError:
return hits
pos = data.find(MARKER)
if pos >= 0:
hits.append(("marker", pos))
for word in WORDS:
pos = data.find(word)
if pos >= 0:
hits.append((word.decode("ascii", "replace"), pos))
return hits
def main(argv):
if len(argv) < 2:
print(__doc__)
return 1
for name in argv[1:]:
path = Path(name)
if path.is_dir():
for child in path.rglob("*"):
if child.is_file() and child.stat().st_size < 200_000_000:
hits = scan(child)
for what, where in hits:
print("%-70s %s at %d" % (str(child)[-70:], what, where))
elif path.is_file():
for what, where in scan(path):
print("%-70s %s at %d" % (str(path)[-70:], what, where))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Look at the constant pool of a class of the remake and see what is hidden.
The remake obfuscates the string constants: they are not readable in the class
file. This script pulls the UTF-8 entries out of the constant pool and tries
to work out how they were scrambled, because the same cipher is probably what
protects the .obj and .rrs files.
Usage: inspect-constants.py <class file> [...]
"""
import struct
import sys
from pathlib import Path
TAGS = {
1: "Utf8", 3: "Integer", 4: "Float", 5: "Long", 6: "Double",
7: "Class", 8: "String", 9: "Fieldref", 10: "Methodref", 11: "InterfaceMethodref",
12: "NameAndType", 15: "MethodHandle", 16: "MethodType", 17: "Dynamic",
18: "InvokeDynamic", 19: "Module", 20: "Package",
}
def read_pool(data):
"""The constant pool of a class: a list of (tag, value) pairs."""
if data[:4] != b"\xca\xfe\xba\xbe":
raise ValueError("not a class file")
count = struct.unpack_from(">H", data, 8)[0]
pool = []
pos = 10
i = 1
while i < count:
tag = data[pos]
pos += 1
if tag == 1:
length = struct.unpack_from(">H", data, pos)[0]
pos += 2
pool.append((tag, data[pos:pos + length]))
pos += length
elif tag in (3, 4):
pool.append((tag, data[pos:pos + 4]))
pos += 4
elif tag in (5, 6):
pool.append((tag, data[pos:pos + 8]))
pos += 8
elif tag in (7, 8, 16, 19, 20):
pool.append((tag, struct.unpack_from(">H", data, pos)[0]))
pos += 2
elif tag in (9, 10, 11, 12, 17, 18):
pool.append((tag, struct.unpack_from(">HH", data, pos)))
pos += 4
elif tag == 15:
pool.append((tag, data[pos:pos + 3]))
pos += 3
else:
raise ValueError("unknown tag %d at %d" % (tag, pos))
if tag in (5, 6):
i += 2
else:
i += 1
return pool, pos
def printable(data):
good = 0
for b in data:
if 32 <= b < 127:
good += 1
return good / max(1, len(data))
def crack(text):
"""The single byte key that makes a string readable, or None."""
best = None
for key in range(1, 256):
out = bytes(b ^ key for b in text)
score = printable(out)
if score > 0.98 and all(32 <= b < 127 or b in (9, 10, 13) for b in out):
best = (key, out)
break
return best
def main(argv):
if len(argv) < 2:
print(__doc__)
return 1
for name in argv[1:]:
path = Path(name)
data = path.read_bytes()
pool, _ = read_pool(data)
strings = [v for t, v in pool if t == 1]
readable = [s for s in strings if printable(s) > 0.95]
hidden = [s for s in strings if printable(s) <= 0.95]
print("=== %s: %d strings, %d readable, %d hidden"
% (path.name, len(strings), len(readable), len(hidden)))
for s in readable[:12]:
print(" plain %r" % s[:60])
for s in hidden[:8]:
cracked = crack(s)
if cracked:
print(" xor %r key 0x%02X" % (s[:40], cracked[0]))
else:
print(" hidden %r" % s[:40])
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))