bdf0ae866a
Проект начинается с двух вещей: клиент, который мы действительно собрали, и загрузчик, который является его частью, а не чем-то рядом. Форк - prepare-game.ps1 пересобирает клиент целиком: ванильный jar, SRG и MCP, SpecialSource, FernFlower, патчи, автофиксеры, javac. Идемпотентен. - Свой код игры лежит в src-overlay, потому что src пересоздаётся при каждой сборке и всё написанное вручную там погибает. - 116 патчей в patches/decompiler-fixes.txt. Каждый сверялся с оригинальным байткодом через javap: компиляция не ловит ошибки декомпиляции. - struct_diff.py сравнивает исходный байткод с нашей сборкой по классам, интерфейсам, суперклассам, методам и полям, и отдельно проверяет, что пропавшие классы действительно никому не нужны. Загрузчик - Собирается и тестируется без игры: build.ps1 не использует classpath вообще, поэтому SelfTest из 128 проверок проходит без запущенного форка. - Клиентский API не содержит ни одного игрового типа, реализацию даёт игра. - Политика: мод меняет то, что клиент показывает, но никогда то, что истинно в мире. Мир, предметы, игроки и сеть принадлежат серверу. - Capability приведены в соответствие с этой политикой: осталось шесть, и за каждым стоит реальная проверка. Прежние WORLD, PLAYER, NETWORK, TEXT, INPUT и COMMAND выдавались, но ничего не проверяли. - Команды модов имеют область CLIENT, SERVER или BOTH. Серверные не выполняются на клиенте никогда. - Свой jar загрузчика лежит в classpath игры и сборки форка. Инструменты - add_patch.py, чтобы каждое исправление переживало передекомпиляцию - find_self_comparisons.py, find_remove_index_bugs.py, find_callers.py - stage_run.py и fetch_missing.py для запуска клиента
288 lines
9.7 KiB
Python
288 lines
9.7 KiB
Python
"""Structural comparison between the original bytecode and the rebuilt fork.
|
|
|
|
javac proves a file compiles. It proves nothing about whether the compiler
|
|
emitted the same class the decompiler read, and a misdecompiled class can be
|
|
silently wrong: dropping an interface, losing a method, or emitting the wrong
|
|
superclass all produce clean output.
|
|
|
|
Both trees are on disk, so the honest check is to parse the class files
|
|
directly and compare shape. This is a structural pass only, not a bytecode
|
|
equivalence proof, but it catches the whole class of errors that compiling
|
|
cannot: missing classes, lost interfaces, absent methods and fields.
|
|
|
|
python struct_diff.py <original-dir> <rebuilt-dir> [--json out.json]
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import struct
|
|
import sys
|
|
|
|
CONSTANT_UTF8 = 1
|
|
CONSTANT_CLASS = 7
|
|
CONSTANT_STRING = 8
|
|
CONSTANT_LONG = 5
|
|
CONSTANT_DOUBLE = 6
|
|
SKIP_TWO_SLOT = (CONSTANT_LONG, CONSTANT_DOUBLE)
|
|
|
|
INTERFACE = 0x0200
|
|
ABSTRACT = 0x0400
|
|
SYNTHETIC = 0x1000
|
|
|
|
|
|
class Reader:
|
|
"""Minimal big-endian cursor over a class file."""
|
|
|
|
def __init__(self, data):
|
|
self.data = data
|
|
self.pos = 0
|
|
|
|
def u1(self):
|
|
value = self.data[self.pos]
|
|
self.pos += 1
|
|
return value
|
|
|
|
def u2(self):
|
|
value = struct.unpack_from('>H', self.data, self.pos)[0]
|
|
self.pos += 2
|
|
return value
|
|
|
|
def u4(self):
|
|
value = struct.unpack_from('>I', self.data, self.pos)[0]
|
|
self.pos += 4
|
|
return value
|
|
|
|
def take(self, size):
|
|
value = self.data[self.pos:self.pos + size]
|
|
self.pos += size
|
|
return value
|
|
|
|
|
|
def read_constant_pool(reader):
|
|
"""Returns a list indexed by pool index; entries are str or int."""
|
|
count = reader.u2()
|
|
pool = [None] * count
|
|
index = 1
|
|
while index < count:
|
|
tag = reader.u1()
|
|
if tag == CONSTANT_UTF8:
|
|
length = reader.u2()
|
|
pool[index] = reader.take(length).decode('utf-8', 'replace')
|
|
elif tag in (CONSTANT_CLASS, CONSTANT_STRING):
|
|
pool[index] = ('ref', reader.u2())
|
|
elif tag in (3, 4): # Integer, Float
|
|
pool[index] = reader.take(4)
|
|
elif tag in SKIP_TWO_SLOT: # Long, Double
|
|
pool[index] = reader.take(8)
|
|
elif tag in (9, 10, 11, 12, 17, 18): # refs, NameAndType, Dynamic
|
|
reader.take(4)
|
|
pool[index] = None
|
|
elif tag == 15: # MethodHandle
|
|
reader.take(3)
|
|
pool[index] = None
|
|
elif tag in (16, 19, 20): # MethodType, Module, Package
|
|
reader.take(2)
|
|
pool[index] = None
|
|
else:
|
|
raise ValueError('unknown constant pool tag %d' % tag)
|
|
index += 2 if tag in SKIP_TWO_SLOT else 1
|
|
return pool
|
|
|
|
|
|
def utf(pool, index):
|
|
entry = pool[index]
|
|
return entry if isinstance(entry, str) else '?'
|
|
|
|
|
|
def class_name(pool, index):
|
|
entry = pool[index]
|
|
return utf(pool, entry[1]) if isinstance(entry, tuple) else '?'
|
|
|
|
|
|
def skip_attributes(reader, pool):
|
|
count = reader.u2()
|
|
for _ in range(count):
|
|
reader.u2() # name index
|
|
length = reader.u4()
|
|
reader.take(length)
|
|
|
|
|
|
def parse(path):
|
|
"""Returns (name, superName, interfaces, methods, fields, referenced)."""
|
|
with open(path, 'rb') as handle:
|
|
data = handle.read()
|
|
reader = Reader(data)
|
|
if reader.u4() != 0xCAFEBABE:
|
|
raise ValueError('not a class file: %s' % path)
|
|
reader.u2() # minor
|
|
reader.u2() # major
|
|
pool = read_constant_pool(reader)
|
|
reader.u2() # access flags
|
|
this_class = reader.u2()
|
|
super_class = reader.u2()
|
|
referenced = set()
|
|
for entry in pool:
|
|
if isinstance(entry, tuple) and entry[0] == 'ref':
|
|
target = pool[entry[1]]
|
|
if isinstance(target, str):
|
|
referenced.add(target)
|
|
interfaces = []
|
|
for _ in range(reader.u2()):
|
|
interfaces.append(class_name(pool, reader.u2()))
|
|
fields = set()
|
|
for _ in range(reader.u2()):
|
|
flags = reader.u2()
|
|
name = utf(pool, reader.u2())
|
|
descriptor = utf(pool, reader.u2())
|
|
if not flags & SYNTHETIC:
|
|
fields.add('%s %s' % (name, descriptor))
|
|
skip_attributes(reader, pool)
|
|
methods = set()
|
|
for _ in range(reader.u2()):
|
|
flags = reader.u2()
|
|
name = utf(pool, reader.u2())
|
|
descriptor = utf(pool, reader.u2())
|
|
if not flags & SYNTHETIC and name not in ('<clinit>',):
|
|
methods.add('%s %s' % (name, descriptor))
|
|
skip_attributes(reader, pool)
|
|
return (class_name(pool, this_class), class_name(pool, super_class),
|
|
interfaces, methods, fields, referenced)
|
|
|
|
|
|
def collect(root):
|
|
found = {}
|
|
for base, _dirs, files in os.walk(root):
|
|
for name in files:
|
|
if not name.endswith('.class'):
|
|
continue
|
|
full = os.path.join(base, name)
|
|
key = os.path.relpath(full, root).replace(os.sep, '/')
|
|
try:
|
|
found[key] = parse(full)
|
|
except (ValueError, struct.error, IndexError) as error:
|
|
found[key] = None
|
|
sys.stderr.write('unreadable %s: %s\n' % (key, error))
|
|
return found
|
|
|
|
|
|
def describe(kind, items):
|
|
return sorted(items)
|
|
|
|
|
|
def main():
|
|
original_root, rebuilt_root = sys.argv[1], sys.argv[2]
|
|
report_path = None
|
|
if '--json' in sys.argv:
|
|
report_path = sys.argv[sys.argv.index('--json') + 1]
|
|
|
|
original = collect(original_root)
|
|
rebuilt = collect(rebuilt_root)
|
|
|
|
only_original = sorted(set(original) - set(rebuilt))
|
|
only_rebuilt = sorted(set(rebuilt) - set(original))
|
|
|
|
lost_interfaces = []
|
|
lost_methods = []
|
|
lost_fields = []
|
|
changed_super = []
|
|
unreadable = []
|
|
referenced_by_rebuild = set()
|
|
|
|
for key in sorted(set(rebuilt)):
|
|
entry = rebuilt[key]
|
|
if entry is not None:
|
|
referenced_by_rebuild |= entry[5]
|
|
|
|
for key in sorted(set(original) & set(rebuilt)):
|
|
left, right = original[key], rebuilt[key]
|
|
if left is None or right is None:
|
|
unreadable.append(key)
|
|
continue
|
|
if set(left[2]) != set(right[2]):
|
|
lost_interfaces.append({
|
|
'class': key,
|
|
'missing': describe('i', set(left[2]) - set(right[2])),
|
|
'added': describe('i', set(right[2]) - set(left[2])),
|
|
})
|
|
if left[1] != right[1]:
|
|
changed_super.append({
|
|
'class': key,
|
|
'original': left[1],
|
|
'rebuilt': right[1],
|
|
})
|
|
gone_methods = left[3] - right[3]
|
|
if gone_methods:
|
|
lost_methods.append({'class': key, 'missing': sorted(gone_methods)})
|
|
gone_fields = left[4] - right[4]
|
|
if gone_fields:
|
|
lost_fields.append({'class': key, 'missing': sorted(gone_fields)})
|
|
|
|
# a class the rebuild never mentions cannot be reached at runtime, so the
|
|
# original bytecode on the classpath is never consulted for it
|
|
orphans = [k for k in only_original
|
|
if k[:-6] not in referenced_by_rebuild]
|
|
still_needed = [k for k in only_original
|
|
if k[:-6] in referenced_by_rebuild]
|
|
|
|
report = {
|
|
'classes_original': len(original),
|
|
'classes_rebuilt': len(rebuilt),
|
|
'only_in_original': only_original,
|
|
'only_in_rebuilt': only_rebuilt,
|
|
'lost_interfaces': lost_interfaces,
|
|
'changed_super': changed_super,
|
|
'lost_methods': lost_methods,
|
|
'lost_fields': lost_fields,
|
|
'unreadable': unreadable,
|
|
'orphans': orphans,
|
|
'still_needed': still_needed,
|
|
}
|
|
|
|
print('classes: original %d, rebuilt %d' % (len(original), len(rebuilt)))
|
|
print('classes only in original : %d' % len(only_original))
|
|
print('classes only in rebuilt : %d' % len(only_rebuilt))
|
|
print('lost interfaces : %d' % len(lost_interfaces))
|
|
print('changed superclass : %d' % len(changed_super))
|
|
print('lost methods : %d' % sum(len(r['missing']) for r in lost_methods))
|
|
print('lost fields : %d' % sum(len(r['missing']) for r in lost_fields))
|
|
print('missing but unreferenced : %d (dead weight, never loaded)' % len(orphans))
|
|
print('missing and still needed : %d (would fail without the fallback)' % len(still_needed))
|
|
|
|
if lost_interfaces:
|
|
print('\n--- LOST INTERFACES (fatal class of bug) ---')
|
|
for row in lost_interfaces:
|
|
print(' %s' % row['class'])
|
|
print(' - %s' % ', '.join(row['missing']))
|
|
if row['added']:
|
|
print(' + %s' % ', '.join(row['added']))
|
|
if changed_super:
|
|
print('\n--- CHANGED SUPERCLASS ---')
|
|
for row in changed_super:
|
|
print(' %-60s %s -> %s' % (row['class'], row['original'], row['rebuilt']))
|
|
if lost_methods:
|
|
print('\n--- LOST METHODS ---')
|
|
for row in lost_methods:
|
|
print(' %s' % row['class'])
|
|
for name in row['missing']:
|
|
print(' - %s' % name)
|
|
if lost_fields:
|
|
print('\n--- LOST FIELDS ---')
|
|
for row in lost_fields:
|
|
print(' %s' % row['class'])
|
|
for name in row['missing']:
|
|
print(' - %s' % name)
|
|
if only_original:
|
|
print('\n--- ONLY IN ORIGINAL (first 60) ---')
|
|
for key in only_original[:60]:
|
|
print(' %s' % key)
|
|
if len(only_original) > 60:
|
|
print(' ... and %d more' % (len(only_original) - 60))
|
|
|
|
if report_path:
|
|
with open(report_path, 'w', encoding='utf-8') as handle:
|
|
json.dump(report, handle, indent=2)
|
|
print('\nreport written to %s' % report_path)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main() |