Files
RustNotMC/rsx-client/tools/fix_generic_casts.py
T
sasheg dev bdf0ae866a Форк клиента собирается из исходников, модзагрузчик встроен в игру
Проект начинается с двух вещей: клиент, который мы действительно собрали, и
загрузчик, который является его частью, а не чем-то рядом.

Форк
- prepare-game.ps1 пересобирает клиент целиком: ванильный jar, SRG и MCP,
  SpecialSource, FernFlower, патчи, автофиксеры, javac. Идемпотентен.
- Свой код игры лежит в src-overlay, потому что src пересоздаётся при каждой
  сборке и всё написанное вручную там погибает.
- 116 патчей в patches/decompiler-fixes.txt. Каждый сверялся с оригинальным
  байткодом через javap: компиляция не ловит ошибки декомпиляции.
- struct_diff.py сравнивает исходный байткод с нашей сборкой по классам,
  интерфейсам, суперклассам, методам и полям, и отдельно проверяет, что
  пропавшие классы действительно никому не нужны.

Загрузчик
- Собирается и тестируется без игры: build.ps1 не использует classpath
  вообще, поэтому SelfTest из 128 проверок проходит без запущенного форка.
- Клиентский API не содержит ни одного игрового типа, реализацию даёт игра.
- Политика: мод меняет то, что клиент показывает, но никогда то, что истинно
  в мире. Мир, предметы, игроки и сеть принадлежат серверу.
- Capability приведены в соответствие с этой политикой: осталось шесть, и
  за каждым стоит реальная проверка. Прежние WORLD, PLAYER, NETWORK, TEXT,
  INPUT и COMMAND выдавались, но ничего не проверяли.
- Команды модов имеют область CLIENT, SERVER или BOTH. Серверные не
  выполняются на клиенте никогда.
- Свой jar загрузчика лежит в classpath игры и сборки форка.

Инструменты
- add_patch.py, чтобы каждое исправление переживало передекомпиляцию
- find_self_comparisons.py, find_remove_index_bugs.py, find_callers.py
- stage_run.py и fetch_missing.py для запуска клиента
2026-10-18 21:31:27 +03:00

187 lines
7.3 KiB
Python

#!/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())