Форк клиента собирается из исходников, модзагрузчик встроен в игру
Проект начинается с двух вещей: клиент, который мы действительно собрали, и загрузчик, который является его частью, а не чем-то рядом. Форк - 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 для запуска клиента
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
"""Append a hand fix to the patch file so it survives a re-decompile.
|
||||
|
||||
Keeping every fix in one reviewed file, rather than editing generated source,
|
||||
is what makes prepare-game.ps1 reproducible: the tree is thrown away and rebuilt
|
||||
on every run, so a fix that is not recorded simply disappears.
|
||||
|
||||
python add_patch.py <patch-file> <java-file-path> <before-file> <after-file>
|
||||
|
||||
The before/after files hold the exact text to replace and must match the
|
||||
decompiled output byte for byte.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
patch_file, java_path, before_file, after_file = sys.argv[1:5]
|
||||
with io.open(before_file, encoding='utf-8') as handle:
|
||||
before = handle.read().rstrip('\n')
|
||||
with io.open(after_file, encoding='utf-8') as handle:
|
||||
after = handle.read().rstrip('\n')
|
||||
|
||||
if not before or not after:
|
||||
print('ERROR: before/after must not be empty')
|
||||
return 1
|
||||
if before == after:
|
||||
print('ERROR: before and after are identical')
|
||||
return 1
|
||||
|
||||
existing = ''
|
||||
if os.path.isfile(patch_file):
|
||||
with io.open(patch_file, encoding='utf-8') as handle:
|
||||
existing = handle.read()
|
||||
|
||||
header = '=== FILE %s\n' % java_path
|
||||
if header + '--- BEFORE\n' + before + '\n--- AFTER\n' in existing:
|
||||
print('patch already present')
|
||||
return 0
|
||||
|
||||
with io.open(patch_file, 'a', encoding='utf-8') as handle:
|
||||
if existing and not existing.endswith('\n'):
|
||||
handle.write('\n')
|
||||
handle.write('\n' + header)
|
||||
handle.write('--- BEFORE\n' + before + '\n')
|
||||
handle.write('--- AFTER\n' + after + '\n')
|
||||
print('appended patch for %s' % java_path)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Fetch library files the local meta folder never downloaded.
|
||||
|
||||
The AstralRinth install has the 1.12.2 java libraries but not the
|
||||
natives-windows classifiers, and the client cannot open a window without
|
||||
lwjgl.dll. Rather than write into another application's cache, missing files go
|
||||
into our own mirror of the same layout, which stage_run.py searches first.
|
||||
|
||||
python fetch_missing.py <dest-root> <meta-dir>
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
|
||||
def wanted(manifest):
|
||||
"""Every artifact and natives-windows path the manifest declares."""
|
||||
out = []
|
||||
for entry in manifest['libraries']:
|
||||
downloads = entry.get('downloads') or {}
|
||||
artifact = (downloads.get('artifact') or {}).get('path')
|
||||
if artifact:
|
||||
out.append(artifact)
|
||||
classifiers = downloads.get('classifiers') or {}
|
||||
for key, value in classifiers.items():
|
||||
if key.startswith('natives-windows') and value and value.get('path'):
|
||||
out.append(value['path'])
|
||||
return sorted(set(out))
|
||||
|
||||
|
||||
def main():
|
||||
dest_root, meta = sys.argv[1], sys.argv[2]
|
||||
manifest_path = os.path.join(meta, 'versions', '1.12.2', '1.12.2.json')
|
||||
with open(manifest_path, encoding='utf-8') as handle:
|
||||
manifest = json.load(handle)
|
||||
|
||||
meta_lib = os.path.join(meta, 'libraries')
|
||||
base = 'https://libraries.minecraft.net/'
|
||||
fetched, present, failed = [], 0, []
|
||||
|
||||
for rel in wanted(manifest):
|
||||
if os.path.isfile(os.path.join(meta_lib, rel.replace('/', os.sep))):
|
||||
present += 1
|
||||
continue
|
||||
target = os.path.join(dest_root, rel.replace('/', os.sep))
|
||||
url = base + rel
|
||||
try:
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
with urllib.request.urlopen(url, timeout=60) as response:
|
||||
blob = response.read()
|
||||
with open(target, 'wb') as handle:
|
||||
handle.write(blob)
|
||||
fetched.append('%s (%d bytes)' % (os.path.basename(rel), len(blob)))
|
||||
except Exception as error: # noqa: BLE001
|
||||
failed.append('%s -> %s' % (rel, error))
|
||||
|
||||
print('already present: %d' % present)
|
||||
print('fetched: %d' % len(fetched))
|
||||
for line in fetched:
|
||||
print(' %s' % line)
|
||||
if failed:
|
||||
print('failed: %d' % len(failed))
|
||||
for line in failed:
|
||||
print(' %s' % line)
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Find every class whose constant pool references a named method.
|
||||
|
||||
Used to answer "who actually calls this" across a whole jar, where grepping
|
||||
source would only show the decompiled view and javap would need one command per
|
||||
class.
|
||||
|
||||
python find_callers.py <classes-dir> <method-name>
|
||||
"""
|
||||
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from struct_diff import Reader, read_constant_pool, CONSTANT_UTF8, SKIP_TWO_SLOT
|
||||
|
||||
|
||||
def method_names(path):
|
||||
with open(path, 'rb') as handle:
|
||||
data = handle.read()
|
||||
reader = Reader(data)
|
||||
if reader.u4() != 0xCAFEBABE:
|
||||
return None
|
||||
reader.u2()
|
||||
reader.u2()
|
||||
pool = read_constant_pool(reader)
|
||||
names = []
|
||||
for index, entry in enumerate(pool):
|
||||
if isinstance(entry, str):
|
||||
names.append(entry)
|
||||
return names
|
||||
|
||||
|
||||
def main():
|
||||
root, needle = sys.argv[1], sys.argv[2]
|
||||
hits = []
|
||||
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:
|
||||
names = method_names(full)
|
||||
except Exception:
|
||||
continue
|
||||
if names is None:
|
||||
continue
|
||||
if any(n == needle or n.startswith(needle) for n in names):
|
||||
hits.append(key)
|
||||
print('%d class(es) reference %s' % (len(hits), needle))
|
||||
for key in hits:
|
||||
print(' %s' % key)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Find List<Integer>/Set<Integer>.remove(int) misdecompilations.
|
||||
|
||||
FernFlower sometimes resolves List.remove(Object) to remove(int index) when the
|
||||
element type is Integer. The bytecode always boxes with Integer.valueOf first,
|
||||
so a source-level remove() on an Integer collection without that box is a bug:
|
||||
it deletes by position, and throws IndexOutOfBounds on an empty list.
|
||||
|
||||
python find_remove_index_bugs.py <src-root>
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
FIELD = re.compile(r'\b(?:private|public|protected)?\s*(?:final\s+)?'
|
||||
r'((?:java\.util\.)?(?:List|Set|Collection|ArrayList|LinkedList|Queue|Deque))\s*'
|
||||
r'<\s*(?:java\.lang\.)?Integer\s*>\s+(\w+)\s*[;=]')
|
||||
REMOVE = re.compile(r'\b(\w+)\.remove\(([^()]*)\)')
|
||||
|
||||
|
||||
def main():
|
||||
root = sys.argv[1]
|
||||
hits = []
|
||||
for base, _dirs, files in os.walk(root):
|
||||
for name in files:
|
||||
if not name.endswith('.java'):
|
||||
continue
|
||||
path = os.path.join(base, name)
|
||||
with io.open(path, encoding='utf-8') as handle:
|
||||
text = handle.read()
|
||||
fields = {}
|
||||
for match in FIELD.finditer(text):
|
||||
fields[match.group(2)] = match.group(1)
|
||||
if not fields:
|
||||
continue
|
||||
for number, line in enumerate(text.split('\n'), 1):
|
||||
for match in REMOVE.finditer(line):
|
||||
receiver, argument = match.group(1), match.group(2).strip()
|
||||
if receiver not in fields:
|
||||
continue
|
||||
if 'valueOf' in argument or 'Integer' in argument:
|
||||
continue
|
||||
hits.append((os.path.relpath(path, root).replace(os.sep, '/'),
|
||||
number, receiver, argument, line.strip()))
|
||||
|
||||
if not hits:
|
||||
print('no List<Integer>.remove(int) candidates found')
|
||||
return 0
|
||||
print('%d candidate(s):' % len(hits))
|
||||
for path, number, receiver, argument, line in hits:
|
||||
print(' %s:%d %s.remove(%s)' % (path, number, receiver, argument))
|
||||
print(' %s' % line)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Find comparisons of a value against itself.
|
||||
|
||||
FernFlower sometimes merges two distinct locals into one name, producing
|
||||
expressions like d1 != d1 that are always false. The compiler accepts them, so
|
||||
they survive a clean build and silently disable the code around them.
|
||||
|
||||
python find_self_comparisons.py <src-root>
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
# a.b() != a.b() and simple locals: word != word, word == word
|
||||
COMPARISON = re.compile(r'(?<![\w.])([A-Za-z_]\w*(?:\(\))?)\s*(!=|==)\s*\1(?![\w(])')
|
||||
|
||||
|
||||
def main():
|
||||
root = sys.argv[1]
|
||||
hits = []
|
||||
for base, _dirs, files in os.walk(root):
|
||||
for name in files:
|
||||
if not name.endswith('.java'):
|
||||
continue
|
||||
path = os.path.join(base, name)
|
||||
with io.open(path, encoding='utf-8') as handle:
|
||||
for number, line in enumerate(handle, 1):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith('*') or stripped.startswith('//'):
|
||||
continue
|
||||
for match in COMPARISON.finditer(line):
|
||||
hits.append((os.path.relpath(path, root).replace(os.sep, '/'),
|
||||
number, match.group(0), stripped))
|
||||
|
||||
if not hits:
|
||||
print('no self-comparisons found')
|
||||
return 0
|
||||
print('%d self-comparison(s):' % len(hits))
|
||||
for path, number, found, line in hits:
|
||||
print(' %s:%d %s' % (path, number, found))
|
||||
print(' %s' % line)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regex rules that repair systematic decompiler output.
|
||||
|
||||
These are the fixes that recur across many files, where a patch entry per
|
||||
occurrence would be hundreds of lines of noise. Each rule is a named regex
|
||||
with a stated reason, applied to the whole tree.
|
||||
|
||||
Rules run after the patch file, because they can rewrite lines a patch already
|
||||
touched and it is easier to reason about "patches first, then shape rules".
|
||||
|
||||
python fix_decompiler_rules.py <tree>
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
RULES = [
|
||||
(
|
||||
'super-cast-generic-render',
|
||||
# RenderLiving<T>.doRender takes T. The decompiler inserted a cast to the
|
||||
# supertype, which turns the argument into something that is no longer a T.
|
||||
# Passing the variable as declared is always valid here: the parameter is
|
||||
# the subclass type this renderer was parameterised with.
|
||||
re.compile(r'(super\.\w+\()\((?:Entity|EntityLiving|EntityLivingBase|EntityMob)\)(\s+\w+,)'),
|
||||
r'\1\2',
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print('usage: fix_decompiler_rules.py <tree>')
|
||||
return 2
|
||||
tree = sys.argv[1]
|
||||
touched = {}
|
||||
files = set()
|
||||
|
||||
for root, _dirs, names in os.walk(tree):
|
||||
for name in names:
|
||||
if not name.endswith('.java'):
|
||||
continue
|
||||
path = os.path.join(root, name)
|
||||
with open(path, encoding='utf-8', errors='replace') as handle:
|
||||
text = handle.read()
|
||||
original = text
|
||||
for label, pattern, replacement in RULES:
|
||||
new_text = pattern.sub(replacement, text)
|
||||
if new_text != text:
|
||||
touched[label] = touched.get(label, 0) + \
|
||||
len(pattern.findall(text))
|
||||
text = new_text
|
||||
if text != original:
|
||||
files.add(path)
|
||||
with open(path, 'w', encoding='utf-8', newline='\n') as handle:
|
||||
handle.write(text)
|
||||
|
||||
if not touched:
|
||||
print('decompiler rules: nothing to change')
|
||||
return 0
|
||||
for label, count in sorted(touched.items()):
|
||||
print(' %s: %d' % (label, count))
|
||||
print('files rewritten: %d' % len(files))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Insert the missing cast where a generic method loses its type parameter.
|
||||
|
||||
Almost every remaining error in the fork has one shape: a value whose static
|
||||
type came out erased is used where the enclosing method's type parameter is
|
||||
expected.
|
||||
|
||||
Object cannot be converted to T
|
||||
Comparable cannot be converted to T
|
||||
Class<CAP#1> cannot be converted to Class<T>
|
||||
|
||||
The cast that is already there was written to the erased supertype, so the fix
|
||||
is to retype it. This is only safe when the line contains exactly one candidate
|
||||
cast, which is the condition checked here; anything ambiguous is reported and
|
||||
left alone.
|
||||
|
||||
python fix_generic_casts.py <errors.txt> [--apply]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
|
||||
ERROR = re.compile(r'^(?P<file>[A-Za-z]:.*?\.java):(?P<line>\d+): error: (?P<msg>.*)$')
|
||||
# javac prefixes most of these with "incompatible types: ", some not at all.
|
||||
PREFIX = re.compile(r'^(?:incompatible types:\s*)?')
|
||||
MISMATCH = re.compile(r'^(?P<from>[\w.]+(?:<.*?>)?(?:\[\])?) cannot be converted to (?P<to>\w+)$')
|
||||
GENERIC_MISMATCH = re.compile(r'^Class<CAP#1> cannot be converted to Class<(?P<to>\w+)>$')
|
||||
|
||||
# A method head, used to confirm that a return really has the type we are about
|
||||
# to cast it to. The type is whatever follows the last '>' of the pre-name
|
||||
# segment, so a declaration like "public <T extends TileEntity> T getTile()"
|
||||
# reports T and "private static <T extends C<T>> String getName()" reports
|
||||
# String. Reading it any other way gets generics-bearing declarations wrong.
|
||||
SIGNATURE = re.compile(r'^[ \t]{0,8}(?:(?:public|private|protected|static|final|abstract|'
|
||||
r'synchronized|native|default|strictfp)\s+)*'
|
||||
r'(?P<pre>[\w$.<>,\[\] ]+?)\s+(?P<name>\w+)\s*\([^;]*\)'
|
||||
r'\s*(?:throws\s+[\w., ]+)?\s*\{\s*$')
|
||||
|
||||
# if / while / for and friends fit the shape above but are not method heads.
|
||||
KEYWORDS = frozenset((
|
||||
'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'try', 'catch',
|
||||
'finally', 'return', 'new', 'synchronized', 'assert', 'throw', 'super',
|
||||
'this', 'null', 'true', 'false',
|
||||
))
|
||||
|
||||
|
||||
def enclosing_return_type(lines, index):
|
||||
# The window is generous on purpose: a method body can run long before the
|
||||
# value that needs the cast, and missing the declaration would skip a fix
|
||||
# that is perfectly safe.
|
||||
for back in range(index, max(-1, index - 60), -1):
|
||||
sig = SIGNATURE.match(lines[back])
|
||||
if sig and sig.group('name') not in KEYWORDS:
|
||||
return sig.group('pre').split('>')[-1].strip()
|
||||
return None
|
||||
|
||||
# Bare single letters and short names are the type variables javac prints.
|
||||
TYPE_VAR = re.compile(r'^[A-Z]\w{0,2}$')
|
||||
|
||||
|
||||
def candidates(line, from_type):
|
||||
"""Every `(Type)` cast on the line matching the type javac complained about."""
|
||||
plain = from_type.split('<')[0]
|
||||
found = []
|
||||
for m in re.finditer(r'\(([^()]*)\)', line):
|
||||
text = m.group(1).strip()
|
||||
if text == plain or text == from_type:
|
||||
found.append(m)
|
||||
return found
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('errors')
|
||||
parser.add_argument('--apply', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
per_file = {}
|
||||
skipped = []
|
||||
current = None
|
||||
with open(args.errors, encoding='utf-8', errors='replace') as handle:
|
||||
for raw in handle:
|
||||
m = ERROR.match(raw.rstrip('\n').rstrip('\r'))
|
||||
if not m:
|
||||
continue
|
||||
current = (m.group('file'), int(m.group('line')), m.group('msg'))
|
||||
msg = PREFIX.sub('', current[2])
|
||||
|
||||
generic = GENERIC_MISMATCH.match(msg)
|
||||
if generic:
|
||||
to = generic.group('to')
|
||||
per_file.setdefault(current[0], []).append(
|
||||
(current[1], to, None, msg))
|
||||
continue
|
||||
|
||||
mm = MISMATCH.match(msg)
|
||||
if not mm:
|
||||
continue
|
||||
to = mm.group('to')
|
||||
if not TYPE_VAR.match(to):
|
||||
continue
|
||||
per_file.setdefault(current[0], []).append(
|
||||
(current[1], to, mm.group('from'), msg))
|
||||
|
||||
fixed = 0
|
||||
files_changed = 0
|
||||
|
||||
for path, items in sorted(per_file.items()):
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
with open(path, encoding='utf-8', errors='replace') as handle:
|
||||
lines = handle.read().split('\n')
|
||||
|
||||
touched = False
|
||||
for item in items:
|
||||
lineno, to, from_type, message = item
|
||||
if lineno - 1 >= len(lines):
|
||||
continue
|
||||
line = lines[lineno - 1]
|
||||
|
||||
if from_type is None:
|
||||
# Class<CAP#1> -> Class<T>: the capture comes out of a getter
|
||||
# such as getRawType(), so the cast belongs on the right hand side.
|
||||
spots = [m for m in re.finditer(r'=\s*([^=].*?);\s*$', line)]
|
||||
if len(spots) != 1:
|
||||
skipped.append('%s:%d %s (assignments=%d)'
|
||||
% (os.path.basename(path), lineno, message, len(spots)))
|
||||
continue
|
||||
m = spots[0]
|
||||
lines[lineno - 1] = (line[:m.start(1)]
|
||||
+ '(Class<%s>) ' % to
|
||||
+ line[m.start(1):])
|
||||
touched = True
|
||||
fixed += 1
|
||||
continue
|
||||
|
||||
spots = candidates(line, from_type)
|
||||
replacement = '(%s)' % to
|
||||
|
||||
if len(spots) == 1:
|
||||
m = spots[0]
|
||||
lines[lineno - 1] = line[:m.start()] + replacement + line[m.end():]
|
||||
touched = True
|
||||
fixed += 1
|
||||
continue
|
||||
|
||||
# No cast on the line at all: the value was simply used raw. Insert
|
||||
# one immediately after the assignment or the return, which is where
|
||||
# the target type is known. A return is only safe when the method
|
||||
# actually returns that type variable: casting a String to T would
|
||||
# introduce a new error rather than fix the old one.
|
||||
anchor = re.search(r'(?<=return )|(?<==\s)(?!=)', line)
|
||||
if anchor is None:
|
||||
skipped.append('%s:%d %s (no anchor)'
|
||||
% (os.path.basename(path), lineno, message))
|
||||
continue
|
||||
cut = anchor.end()
|
||||
if line[:cut].rstrip().endswith('return'):
|
||||
enclosing = enclosing_return_type(lines, lineno - 1)
|
||||
if enclosing != to:
|
||||
skipped.append('%s:%d %s (method returns %s, not %s)'
|
||||
% (os.path.basename(path), lineno, message,
|
||||
enclosing or '?', to))
|
||||
continue
|
||||
lines[lineno - 1] = line[:cut] + replacement + ' ' + line[cut:]
|
||||
touched = True
|
||||
fixed += 1
|
||||
|
||||
if touched:
|
||||
files_changed += 1
|
||||
if args.apply:
|
||||
with open(path, 'w', encoding='utf-8', newline='\n') as handle:
|
||||
handle.write('\n'.join(lines))
|
||||
|
||||
print('casts rewritten: %d in %d files' % (fixed, files_changed))
|
||||
if skipped:
|
||||
print('left alone: %d' % len(skipped))
|
||||
for line in skipped:
|
||||
print(' %s' % line)
|
||||
if not args.apply:
|
||||
print('dry run, pass --apply to write')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Repair FernFlower's stale local variable references, scoped to the method.
|
||||
|
||||
The decompiler renames conflicting locals only sometimes: a declaration ends up
|
||||
as `variantlist` while its uses stay `variantlist1`, and javac reports
|
||||
"cannot find symbol ... variable variantlist1".
|
||||
|
||||
Renaming is confined to the enclosing method. That matters because the suffixed
|
||||
name is often a real, correctly declared variable in a *different* method of the
|
||||
same class, and a whole-file rename would corrupt it.
|
||||
|
||||
The method is found by its declaration line and the closing brace at the same
|
||||
indentation, which is reliable for well-formed decompiled output.
|
||||
|
||||
python fix_stale_locals.py <errors.txt> [--apply]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import os
|
||||
import re
|
||||
|
||||
ERROR = re.compile(r'^(?P<file>[A-Za-z]:.*?\.java):(?P<line>\d+): error: (?P<msg>.*)$')
|
||||
SYMBOL = re.compile(r'^ {2}symbol:\s*variable (?P<name>\w+)\s*$')
|
||||
DECL = re.compile(r'^[ \t]*(?:final\s+)?[A-Za-z_$][\w$.<>,\[\] ]*\s+(?P<name>[A-Za-z_$]\w*)\s*(?==|;|,|\))')
|
||||
METHOD = re.compile(r'^[ \t]{0,8}(?:(?:public|private|protected|static|final|abstract|synchronized|native|default|strictfp)\s+)*'
|
||||
r'[\w$.<>,\[\] ]+\s+\w+\s*\([^;]*\)\s*(?:throws\s+[\w., ]+)?\s*\{\s*$')
|
||||
|
||||
|
||||
def method_regions(lines):
|
||||
"""Returns [(start_index, end_index_exclusive, indent)] for each method."""
|
||||
regions = []
|
||||
for i, line in enumerate(lines):
|
||||
if not METHOD.match(line):
|
||||
continue
|
||||
indent = len(line) - len(line.lstrip())
|
||||
close = ' ' * indent + '}'
|
||||
for j in range(i + 1, len(lines)):
|
||||
if lines[j].rstrip() == close:
|
||||
regions.append((i, j + 1, indent))
|
||||
break
|
||||
return regions
|
||||
|
||||
|
||||
def region_of(regions, index):
|
||||
for start, end, _ in regions:
|
||||
if start <= index < end:
|
||||
return start, end
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('errors')
|
||||
parser.add_argument('--apply', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
missing = collections.defaultdict(set)
|
||||
current = None
|
||||
with open(args.errors, encoding='utf-8', errors='replace') as handle:
|
||||
for raw in handle:
|
||||
line = raw.rstrip('\n').rstrip('\r')
|
||||
m = ERROR.match(line)
|
||||
if m:
|
||||
current = m.group('file')
|
||||
continue
|
||||
s = SYMBOL.match(line)
|
||||
if s and current:
|
||||
missing[current].add(s.group('name'))
|
||||
|
||||
files_changed = 0
|
||||
renames = 0
|
||||
skipped = []
|
||||
|
||||
for path in sorted(missing):
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
with open(path, encoding='utf-8', errors='replace') as handle:
|
||||
text = handle.read()
|
||||
lines = text.split('\n')
|
||||
regions = method_regions(lines)
|
||||
|
||||
# for each stale name, which regions hold the clean declaration
|
||||
edits = collections.defaultdict(list) # old name -> list of (start, end)
|
||||
for name in sorted(missing[path]):
|
||||
pattern = re.compile(r'\b%s\b' % re.escape(name))
|
||||
longer = re.compile(r'\b%s\d+\b' % re.escape(name))
|
||||
shorter = name.rstrip('0123456789')
|
||||
shorter_pattern = re.compile(r'\b%s\b' % re.escape(shorter)) if shorter != name else None
|
||||
|
||||
hits = 0
|
||||
for start, end, _indent in regions:
|
||||
chunk = '\n'.join(lines[start:end])
|
||||
if not pattern.search(chunk):
|
||||
continue
|
||||
# Either direction may be the stale one: sometimes the use kept
|
||||
# the short name while the declaration gained a suffix.
|
||||
target = None
|
||||
if shorter_pattern is not None and shorter_pattern.search(chunk):
|
||||
target = shorter
|
||||
else:
|
||||
seen = {m.group(0) for m in longer.finditer(chunk)}
|
||||
if len(seen) == 1:
|
||||
target = seen.pop()
|
||||
if target is None:
|
||||
continue
|
||||
edits[name].append((start, end, target))
|
||||
hits += 1
|
||||
|
||||
if not hits:
|
||||
skipped.append('%s: nothing in scope matches %s'
|
||||
% (os.path.basename(path), name))
|
||||
else:
|
||||
renames += hits
|
||||
|
||||
if not edits:
|
||||
continue
|
||||
|
||||
# rebuild from the last region so earlier edits keep their positions
|
||||
replacements = []
|
||||
for name, spans in edits.items():
|
||||
for start, end, target in spans:
|
||||
chunk = '\n'.join(lines[start:end])
|
||||
chunk = re.sub(r'\b%s\b' % re.escape(name), target, chunk)
|
||||
replacements.append((start, end, chunk))
|
||||
|
||||
replacements.sort(key=lambda t: t[0], reverse=True)
|
||||
for start, end, chunk in replacements:
|
||||
lines[start:end] = chunk.split('\n')
|
||||
|
||||
files_changed += 1
|
||||
if args.apply:
|
||||
with open(path, 'w', encoding='utf-8', newline='\n') as handle:
|
||||
handle.write('\n'.join(lines))
|
||||
|
||||
print('methods fixed: %d across %d files' % (renames, files_changed))
|
||||
if skipped:
|
||||
print('unresolved %d:' % len(skipped))
|
||||
for line in sorted(set(skipped)):
|
||||
print(' %s' % line)
|
||||
if not args.apply:
|
||||
print('dry run, pass --apply to write')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Merge a vanilla SRG with the MCP CSVs into a single obf -> MCP SRG.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
The vanilla mapping for 1.12.2 arrives as `joined.srg`, whose right hand side is
|
||||
in SRG names: classes already carry their MCP names, but fields and methods are
|
||||
still `field_96303_A` / `func_175744_a`. The MCP CSVs are the table that turns
|
||||
those into `fancyStyling` / `isFancyStyling`.
|
||||
|
||||
SpecialSource can then apply the merged file in one pass, which avoids two
|
||||
things that a tiny v2 pipeline would require: reading every class file to learn
|
||||
member descriptors, and chaining two remappers.
|
||||
|
||||
python merge_srg_mcp.py joined.srg mcp-stable out.srg
|
||||
|
||||
Any member missing from the CSVs keeps its SRG name. That is deliberate and
|
||||
matches how a normal development jar looks: mapped where MCP knows the name,
|
||||
`field_XXXX_` where it does not.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import sys
|
||||
|
||||
|
||||
def read_csv(path):
|
||||
"""searge name -> MCP name."""
|
||||
table = {}
|
||||
try:
|
||||
with open(path, newline='', encoding='utf-8') as handle:
|
||||
reader = csv.reader(handle)
|
||||
header = next(reader, None)
|
||||
if header is None:
|
||||
return table
|
||||
# the first column is the SRG name, the second the MCP name
|
||||
for row in reader:
|
||||
if len(row) < 2:
|
||||
continue
|
||||
searge = row[0].strip()
|
||||
named = row[1].strip()
|
||||
if searge and named:
|
||||
table[searge] = named
|
||||
except OSError as exc:
|
||||
print('cannot read %s: %s' % (path, exc), file=sys.stderr)
|
||||
return table
|
||||
|
||||
|
||||
def merge_member_name(token, table):
|
||||
"""`owner/srgName` -> `owner/mcpName`, keeping the owner untouched."""
|
||||
if '/' not in token:
|
||||
return token
|
||||
owner, _, name = token.rpartition('/')
|
||||
return owner + '/' + table.get(name, name)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('srg', help='vanilla joined.srg')
|
||||
parser.add_argument('mcp_dir', help='directory holding fields.csv and methods.csv')
|
||||
parser.add_argument('out', help='merged SRG to write')
|
||||
args = parser.parse_args()
|
||||
|
||||
fields = read_csv(args.mcp_dir + '/fields.csv')
|
||||
methods = read_csv(args.mcp_dir + '/methods.csv')
|
||||
print('csv: %d fields, %d methods' % (len(fields), len(methods)))
|
||||
|
||||
stats = {'CL': 0, 'FD': 0, 'MD': 0, 'PK': 0, 'renamed_f': 0, 'renamed_m': 0}
|
||||
skipped = 0
|
||||
|
||||
with open(args.srg, 'r', encoding='utf-8', errors='replace') as src, \
|
||||
open(args.out, 'w', encoding='utf-8', newline='\n') as dst:
|
||||
|
||||
for line in src:
|
||||
line = line.rstrip('\r\n')
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
if line.startswith('CL: '):
|
||||
# class names in the SRG already are the MCP names
|
||||
stats['CL'] += 1
|
||||
dst.write(line + '\n')
|
||||
continue
|
||||
|
||||
if line.startswith('FD: '):
|
||||
parts = line.split(' ')
|
||||
if len(parts) < 3:
|
||||
skipped += 1
|
||||
continue
|
||||
before = parts[2].rpartition('/')[2]
|
||||
after = merge_member_name(parts[2], fields)
|
||||
if after.rpartition('/')[2] != before:
|
||||
stats['renamed_f'] += 1
|
||||
stats['FD'] += 1
|
||||
dst.write('FD: %s %s\n' % (parts[1], after))
|
||||
continue
|
||||
|
||||
if line.startswith('MD: '):
|
||||
# MD: <obfOwner>/<obfMethod> <obfDesc> <srgOwner>/<srgMethod> <srgDesc>;
|
||||
#
|
||||
# The trailing semicolon belongs to the descriptor and is absent
|
||||
# for primitive and void returns: ()I, ()Z, ()V carry none, while
|
||||
# ()Ljava/lang/String; does. Appending one unconditionally turns
|
||||
# ()I into the invalid ()I; and the remapper rejects it with
|
||||
# "Unrecognized type in method descriptor: ;". Keep it verbatim.
|
||||
trail = ';' if line.rstrip().endswith(';') else ''
|
||||
parts = line.rstrip().rstrip(';').split(' ')
|
||||
if len(parts) < 5:
|
||||
skipped += 1
|
||||
continue
|
||||
before = parts[3].rpartition('/')[2]
|
||||
after = merge_member_name(parts[3], methods)
|
||||
if after.rpartition('/')[2] != before:
|
||||
stats['renamed_m'] += 1
|
||||
stats['MD'] += 1
|
||||
dst.write('MD: %s %s %s %s%s\n'
|
||||
% (parts[1], parts[2], after, parts[4], trail))
|
||||
continue
|
||||
|
||||
if line.startswith('PK: '):
|
||||
# Package lines exist for MCP's source-jar layout: `PK: . net/minecraft/src`
|
||||
# moves the whole root package under net/minecraft/src. The CL lines
|
||||
# already place every class at its final path, so applying this on top
|
||||
# would double-prefix them, and SpecialSource's parser rejects the
|
||||
# combination. Not needed for remapping a client jar.
|
||||
stats['PK'] += 1
|
||||
continue
|
||||
|
||||
skipped += 1
|
||||
|
||||
print('wrote %s' % args.out)
|
||||
print(' CL=%(CL)d FD=%(FD)d MD=%(MD)d PK=%(PK)d' % stats)
|
||||
print(' renamed %d fields, %d methods' % (stats['renamed_f'], stats['renamed_m']))
|
||||
if skipped:
|
||||
print(' skipped %d unrecognised lines' % skipped)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Show compile errors next to the source line that caused them.
|
||||
|
||||
javac prints file:line: error: message, which is not enough to work from: the
|
||||
interesting part is usually the statement, and hunting for it in a 3000 line
|
||||
decompiled file by hand is what turns a 160 error job into an afternoon.
|
||||
|
||||
python show_errors.py <errors.txt> [<errors.txt> ...] [--top N] [--file NAME]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import os
|
||||
import re
|
||||
|
||||
LINE = re.compile(r'^(?P<file>[A-Za-z]:.*?\.java):(?P<line>\d+): error: (?P<msg>.*)$')
|
||||
DETAIL = re.compile(r'^ {2}(?P<key>symbol|location|where|required|found|reason):\s*(?P<rest>.*)$')
|
||||
|
||||
|
||||
def load(paths):
|
||||
"""Returns [(file, line, message, [detail lines])].
|
||||
|
||||
javac puts the useful part after the message: which symbol was missing, and
|
||||
which types were expected. Those continuation lines are indented and carry
|
||||
no filename, so they are attached to the error above them.
|
||||
"""
|
||||
found = []
|
||||
for path in paths:
|
||||
pending = None
|
||||
with open(path, encoding='utf-8', errors='replace') as handle:
|
||||
for raw in handle:
|
||||
line = raw.rstrip('\n').rstrip('\r')
|
||||
m = LINE.match(line)
|
||||
if m:
|
||||
if pending:
|
||||
found.append(pending)
|
||||
pending = (m.group('file'), int(m.group('line')),
|
||||
m.group('msg'), [])
|
||||
continue
|
||||
if pending is not None:
|
||||
# The quoted source line and its caret sit between the error
|
||||
# and the symbol block, so anything that is not a detail line
|
||||
# is skipped rather than treated as the end of the error.
|
||||
d = DETAIL.match(line)
|
||||
if d:
|
||||
text = d.group('rest').strip()
|
||||
pending[3].append('%s: %s' % (d.group('key'), text)
|
||||
if text else d.group('key'))
|
||||
if pending:
|
||||
found.append(pending)
|
||||
return found
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('errors', nargs='+')
|
||||
parser.add_argument('--top', type=int, default=0, help='only the N worst files')
|
||||
parser.add_argument('--file', help='show only errors in this file (substring match)')
|
||||
parser.add_argument('--context', type=int, default=0, help='lines around the error')
|
||||
args = parser.parse_args()
|
||||
|
||||
found = load(args.errors)
|
||||
if not found:
|
||||
print('no errors')
|
||||
return 0
|
||||
|
||||
counts = collections.Counter(f for f, _, _, _ in found)
|
||||
if args.file:
|
||||
targets = [f for f in counts if args.file in f]
|
||||
elif args.top:
|
||||
targets = [f for f, _ in counts.most_common(args.top)]
|
||||
else:
|
||||
targets = sorted(counts)
|
||||
|
||||
shown = 0
|
||||
for path in targets:
|
||||
rows = [(ln, msg, det) for f, ln, msg, det in found if f == path]
|
||||
print('%s (%d errors)' % (path, len(rows)))
|
||||
cache = {}
|
||||
for ln, msg, details in rows:
|
||||
if path not in cache:
|
||||
try:
|
||||
with open(path, encoding='utf-8', errors='replace') as handle:
|
||||
cache[path] = handle.readlines()
|
||||
except OSError:
|
||||
cache[path] = []
|
||||
body = cache[path]
|
||||
print(' %d: %s' % (ln, msg))
|
||||
for extra in details:
|
||||
print(' %s' % extra)
|
||||
lo = max(0, ln - 1 - args.context)
|
||||
hi = min(len(body), ln + args.context)
|
||||
for i in range(lo, hi):
|
||||
mark = '>' if i == ln - 1 else ' '
|
||||
print(' %s %5d | %s' % (mark, i + 1, body[i].rstrip()))
|
||||
shown += 1
|
||||
print()
|
||||
print('---- %d error(s) across %d file(s)' % (len(found), len(counts)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Stage a runnable 1.12.2 client from the compiled fork.
|
||||
|
||||
Resolves the exact library set the launcher already downloaded for 1.12.2 rather
|
||||
than globbing the whole meta folder, which holds every version ever installed
|
||||
and would put four LWJGL generations on one classpath.
|
||||
|
||||
python stage_run.py <run-dir> <classes-dir> <meta-dir>
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
|
||||
def allowed(entry):
|
||||
"""Honours the launcher rules block: natives are per-OS."""
|
||||
rules = entry.get('rules')
|
||||
if not rules:
|
||||
return True
|
||||
verdict = False
|
||||
for rule in rules:
|
||||
os_name = rule.get('os', {}).get('name')
|
||||
if os_name is None or os_name == 'windows':
|
||||
verdict = rule['action'] == 'allow'
|
||||
return verdict
|
||||
|
||||
|
||||
def resolve(entry, lib_roots):
|
||||
"""Yields (path, kind) for one library coordinate, kind in class/native.
|
||||
|
||||
1.12.2 keeps natives in downloads.classifiers rather than in the maven
|
||||
coordinate, so the artifact path and the natives-windows path are read
|
||||
separately. Some entries are natives-only and carry no artifact at all,
|
||||
which is why an absent artifact must not be treated as a fallback signal
|
||||
on its own. Roots are searched in order, so a local mirror wins.
|
||||
"""
|
||||
def locate(rel):
|
||||
for root in lib_roots:
|
||||
jar = os.path.join(root, rel.replace('/', os.sep))
|
||||
if os.path.isfile(jar):
|
||||
return jar
|
||||
return None
|
||||
|
||||
downloads = entry.get('downloads') or {}
|
||||
artifact = (downloads.get('artifact') or {}).get('path')
|
||||
seen_artifact = False
|
||||
if artifact:
|
||||
jar = locate(artifact)
|
||||
if jar:
|
||||
seen_artifact = True
|
||||
yield jar, 'class'
|
||||
|
||||
classifiers = downloads.get('classifiers') or {}
|
||||
natives_only = False
|
||||
for key in sorted(classifiers):
|
||||
if not key.startswith('natives-windows'):
|
||||
continue
|
||||
path = (classifiers[key] or {}).get('path')
|
||||
natives_only = True
|
||||
if not path:
|
||||
continue
|
||||
jar = locate(path)
|
||||
if jar:
|
||||
yield jar, 'native'
|
||||
|
||||
if seen_artifact or natives_only or artifact:
|
||||
return
|
||||
|
||||
# pre-1.13 style coordinates: group:artifact:version[:classifier]
|
||||
parts = entry['name'].split(':')
|
||||
classifier = parts[3] if len(parts) > 3 and parts[3] else ''
|
||||
rel = '/'.join([parts[0].replace('.', '/'), parts[1], parts[2],
|
||||
'%s-%s%s.jar' % (parts[1], parts[2],
|
||||
'-' + classifier if classifier else '')])
|
||||
jar = locate(rel)
|
||||
if jar:
|
||||
yield jar, 'native' if classifier.startswith('natives') else 'class'
|
||||
|
||||
|
||||
def extract(jar, dest):
|
||||
"""Unpacks a natives jar, returning False when it is not a real archive.
|
||||
|
||||
Some downloads in the local meta folder are truncated stubs rather than
|
||||
jars. Losing a natives jar only costs an optional feature, so a bad file is
|
||||
reported and skipped instead of aborting the whole staging.
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(jar) as archive:
|
||||
archive.extractall(dest)
|
||||
return True
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
run, classes, meta = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
version_json = os.path.join(meta, 'versions', '1.12.2', '1.12.2.json')
|
||||
with open(version_json, encoding='utf-8') as handle:
|
||||
manifest = json.load(handle)
|
||||
|
||||
for name in ('assets', 'config', 'saves', 'resourcepacks', 'logs',
|
||||
'screenshots', 'shaderpacks'):
|
||||
os.makedirs(os.path.join(run, name), exist_ok=True)
|
||||
|
||||
lib_roots = [os.path.join(run, 'libraries'), os.path.join(meta, 'libraries')]
|
||||
cp = [classes]
|
||||
natives = []
|
||||
missing = []
|
||||
for entry in manifest['libraries']:
|
||||
if not allowed(entry):
|
||||
continue
|
||||
found = list(resolve(entry, lib_roots))
|
||||
if not found:
|
||||
missing.append(entry['name'])
|
||||
continue
|
||||
for jar, kind in found:
|
||||
(natives if kind == 'native' else cp).append(jar)
|
||||
|
||||
# the unpacked jar carries pack.png, log4j2.xml and the in-jar assets;
|
||||
# the compiled classes go first so they shadow the original bytecode
|
||||
unpacked = os.path.join(os.path.dirname(os.path.dirname(classes)), 'classes')
|
||||
if os.path.isdir(unpacked):
|
||||
cp.append(unpacked)
|
||||
|
||||
# the mod loader is part of the game now, not an agent beside it, so it
|
||||
# belongs on the classpath of the same JVM that renders the client
|
||||
loader_jar = os.path.join(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__))), 'dist', 'rsx-loader.jar')
|
||||
if os.path.isfile(loader_jar):
|
||||
cp.append(loader_jar)
|
||||
else:
|
||||
print('NOTE: %s missing, run scripts\\build.ps1' % loader_jar)
|
||||
|
||||
with open(os.path.join(run, 'classpath.txt'), 'w', encoding='utf-8') as handle:
|
||||
handle.write(os.pathsep.join(cp))
|
||||
|
||||
native_dir = os.path.join(run, 'natives')
|
||||
os.makedirs(native_dir, exist_ok=True)
|
||||
skipped = []
|
||||
for jar in natives:
|
||||
if not extract(jar, native_dir):
|
||||
skipped.append(os.path.basename(jar))
|
||||
|
||||
print('classpath entries: %d' % len(cp))
|
||||
print('natives: %d jars -> %s' % (len(natives), native_dir))
|
||||
if missing:
|
||||
print('MISSING libraries (%d):' % len(missing))
|
||||
for name in missing:
|
||||
print(' %s' % name)
|
||||
if skipped:
|
||||
print('unusable native jars (%d), skipped:' % len(skipped))
|
||||
for name in skipped:
|
||||
print(' %s' % name)
|
||||
return 1 if missing else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,288 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user