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 для запуска клиента
169 lines
6.1 KiB
Python
169 lines
6.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Re-apply hand fixes to a freshly decompiled tree.
|
|
|
|
Decompiling is repeatable, our corrections to its output are not. Every manual
|
|
fix therefore lives in a patch file next to this script and is re-applied after
|
|
each decompile, so regenerating the tree never silently loses work.
|
|
|
|
Patch format, deliberately boring:
|
|
|
|
=== FILE net/minecraft/block/BlockPortal.java
|
|
--- BEFORE
|
|
<text to find>
|
|
--- AFTER
|
|
<text to write>
|
|
|
|
and for a whole file:
|
|
|
|
=== FILE net/minecraft/util/IObjectIntIterable.java
|
|
--- REPLACE
|
|
<entire new file contents>
|
|
|
|
Comments start with '#'. Blank lines are ignored outside a block. A patch that
|
|
matches nothing is reported as a failure, because a silently skipped patch is
|
|
worse than a loud one: the tree looks right and is not.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
|
|
|
|
def _trim(buf):
|
|
"""Drops trailing blank and comment lines from a section buffer.
|
|
|
|
A comment or a blank line always separates one patch from the next, so
|
|
without this a section would swallow the comment introducing the patch that
|
|
follows it and then never match.
|
|
"""
|
|
out = list(buf)
|
|
while out and (not out[-1].strip() or out[-1].lstrip().startswith('#')):
|
|
out.pop()
|
|
return out
|
|
|
|
|
|
def parse(path):
|
|
"""Returns {relative_path: [(before, after, whole_file), ...]}.
|
|
|
|
A file may carry several patches, so each path maps to a list rather than a
|
|
single entry: two patches for one file are two patches, not one that
|
|
overwrites the other.
|
|
"""
|
|
patches = {}
|
|
state = {'file': None, 'section': None, 'before': [], 'after': []}
|
|
|
|
def flush():
|
|
if state['file']:
|
|
if state['section'] == 'replace':
|
|
entry = (None, '\n'.join(_trim(state['after'])), True)
|
|
else:
|
|
entry = ('\n'.join(_trim(state['before'])),
|
|
'\n'.join(_trim(state['after'])), False)
|
|
patches.setdefault(state['file'], []).append(entry)
|
|
state['file'] = None
|
|
state['section'] = None
|
|
state['before'] = []
|
|
state['after'] = []
|
|
|
|
with open(path, encoding='utf-8') as handle:
|
|
for raw in handle:
|
|
line = raw.rstrip('\n').rstrip('\r')
|
|
|
|
if line.startswith('=== FILE '):
|
|
flush()
|
|
state['file'] = line[len('=== FILE '):].strip()
|
|
continue
|
|
|
|
if line in ('--- BEFORE', '--- AFTER', '--- REPLACE'):
|
|
if not state['file']:
|
|
raise SystemExit('%s appears before any === FILE' % line)
|
|
state['section'] = line[4:].strip().lower()
|
|
continue
|
|
|
|
if state['file'] and state['section'] == 'replace':
|
|
# A REPLACE block holds the whole file, so it would otherwise run
|
|
# on into whatever follows. Java has no line directives, so a line
|
|
# starting with '#' is safe to treat as the end of the block.
|
|
if line.startswith('#'):
|
|
flush()
|
|
else:
|
|
state['after'].append(line)
|
|
continue
|
|
|
|
if state['file'] and state['section']:
|
|
state[state['section']].append(line)
|
|
elif line.strip() and not line.startswith('#'):
|
|
pass
|
|
|
|
flush()
|
|
return patches
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('tree', help='decompiled source root')
|
|
parser.add_argument('patches', help='patch file')
|
|
args = parser.parse_args()
|
|
|
|
patches = parse(args.patches)
|
|
applied = 0
|
|
failed = []
|
|
total = 0
|
|
|
|
for rel, entries in sorted(patches.items()):
|
|
target = os.path.join(args.tree, rel.replace('/', os.sep))
|
|
if not os.path.isfile(target):
|
|
failed.append('%s: no such file' % rel)
|
|
total += len(entries)
|
|
continue
|
|
with open(target, encoding='utf-8', errors='replace') as handle:
|
|
text = handle.read()
|
|
for index, (before, after, whole) in enumerate(entries):
|
|
total += 1
|
|
label = rel if len(entries) == 1 else '%s [%d]' % (rel, index + 1)
|
|
if whole:
|
|
with open(target, 'w', encoding='utf-8', newline='\n') as handle:
|
|
handle.write(after)
|
|
applied += 1
|
|
continue
|
|
if before in text:
|
|
with open(target, 'w', encoding='utf-8', newline='\n') as handle:
|
|
handle.write(text.replace(before, after))
|
|
applied += 1
|
|
# Re-read: the next patch for this file must see what this one
|
|
# just wrote. Without this each patch writes from the snapshot
|
|
# taken before the loop, and only the last one survives.
|
|
with open(target, encoding='utf-8', errors='replace') as handle:
|
|
text = handle.read()
|
|
elif after.strip() and after.strip() in text:
|
|
# already applied, which is the normal case on a second run
|
|
applied += 1
|
|
else:
|
|
failed.append('%s: pattern not found' % label)
|
|
|
|
# Safety net: after writing, no BEFORE text may still be present. This is
|
|
# what catches a patch that reported success while doing nothing, which is
|
|
# the failure mode that hides longest.
|
|
stale = []
|
|
for rel, entries in sorted(patches.items()):
|
|
target = os.path.join(args.tree, rel.replace('/', os.sep))
|
|
if not os.path.isfile(target):
|
|
continue
|
|
with open(target, encoding='utf-8', errors='replace') as handle:
|
|
text = handle.read()
|
|
for index, (before, after, whole) in enumerate(entries):
|
|
if whole or not before:
|
|
continue
|
|
if before in text and after.strip() and after.strip() not in text:
|
|
stale.append('%s [%d]' % (rel, index + 1))
|
|
|
|
print('patches applied: %d of %d' % (applied, total))
|
|
for line in failed:
|
|
print(' FAILED %s' % line)
|
|
for line in stale:
|
|
print(' NOT WRITTEN %s' % line)
|
|
return 1 if (failed or stale) else 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main()) |