Files
sasheg dev 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 для запуска клиента
2026-10-18 21:31:27 +03:00

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())