Форк клиента собирается из исходников, модзагрузчик встроен в игру

Проект начинается с двух вещей: клиент, который мы действительно собрали, и
загрузчик, который является его частью, а не чем-то рядом.

Форк
- 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:
sasheg dev
2026-10-18 21:31:27 +03:00
commit bdf0ae866a
66 changed files with 8839 additions and 0 deletions
+53
View File
@@ -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())
+169
View File
@@ -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())
+70
View File
@@ -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())
+57
View File
@@ -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())
+47
View File
@@ -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())
+68
View File
@@ -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())
+187
View File
@@ -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())
+147
View File
@@ -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())
+139
View File
@@ -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()
+103
View File
@@ -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())
+159
View File
@@ -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())
+288
View File
@@ -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()