#!/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 --- AFTER and for a whole file: === FILE net/minecraft/util/IObjectIntIterable.java --- REPLACE 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())