package net.vhati.modmanager.core; import java.io.BufferedInputStream; import java.io.File; import java.io.FileInputStream; import java.io.InputStream; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import net.vhati.ftldat.FTLDat; import net.vhati.ftldat.FTLDat.AbstractPack; import net.vhati.ftldat.FTLDat.FTLPack; import net.vhati.modmanager.core.ModPatchObserver; import net.vhati.modmanager.core.ModUtilities; import org.jdom2.JDOMException; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; public class ModPatchThread extends Thread { private static final Logger log = LogManager.getLogger(ModPatchThread.class); // Other threads can check or set this. public volatile boolean keepRunning = true; private Thread shutdownHook = null; private List<File> modFiles = new ArrayList<File>(); private BackedUpDat dataDat = null; private BackedUpDat resDat = null; private boolean globalPanic = false; private ModPatchObserver observer = null; private final int progMax = 100; private final int progBackupMax = 25; private final int progClobberMax = 25; private final int progModsMax = 40; private final int progRepackMax = 5; private int progMilestone = 0; public ModPatchThread( List<File> modFiles, BackedUpDat dataDat, BackedUpDat resDat, boolean globalPanic, ModPatchObserver observer ) { this.modFiles.addAll( modFiles ); this.dataDat = dataDat; this.resDat = resDat; this.globalPanic = globalPanic; this.observer = observer; } public void run() { boolean result; Exception exception = null; // When JVM tries to exit, stall until this thread ends on its own. shutdownHook = new Thread() { @Override public void run() { keepRunning = false; boolean interrupted = false; try { while ( ModPatchThread.this.isAlive() ) { try { ModPatchThread.this.join(); } catch ( InterruptedException e ) { interrupted = true; } } } finally { if ( interrupted ) Thread.currentThread().interrupt(); } } }; Runtime.getRuntime().addShutdownHook( shutdownHook ); try { result = patch(); } catch ( Exception e ) { log.error( "Patching failed.", e ); exception = e; result = false; } observer.patchingEnded( result, exception ); Runtime.getRuntime().removeShutdownHook( shutdownHook ); } private boolean patch() throws IOException, JDOMException { observer.patchingProgress( 0, progMax ); BackedUpDat[] allDats = new BackedUpDat[] {dataDat, resDat}; FTLPack dataP = null; FTLPack resP = null; try { int backupsCreated = 0; int datsClobbered = 0; int modsInstalled = 0; int datsRepacked = 0; // Create backup dats, if necessary. for ( BackedUpDat dat : allDats ) { if ( !dat.bakFile.exists() ) { log.info( String.format( "Backing up \"%s\".", dat.datFile.getName() ) ); observer.patchingStatus( String.format( "Backing up \"%s\".", dat.datFile.getName() ) ); FTLDat.copyFile( dat.datFile, dat.bakFile ); backupsCreated++; observer.patchingProgress( progMilestone + progBackupMax/allDats.length*backupsCreated, progMax ); if ( !keepRunning ) return false; } } progMilestone += progBackupMax; observer.patchingProgress( progMilestone, progMax ); observer.patchingStatus( null ); if ( backupsCreated != allDats.length ) { // Clobber current dat files with their respective backups. // But don't bother if we made those backups just now. for ( BackedUpDat dat : allDats ) { log.info( String.format( "Restoring vanilla \"%s\"...", dat.datFile.getName() ) ); observer.patchingStatus( String.format( "Restoring vanilla \"%s\"...", dat.datFile.getName() ) ); FTLDat.copyFile( dat.bakFile, dat.datFile ); datsClobbered++; observer.patchingProgress( progMilestone + progClobberMax/allDats.length*datsClobbered, progMax ); if ( !keepRunning ) return false; } observer.patchingStatus( null ); } progMilestone += progClobberMax; observer.patchingProgress( progMilestone, progMax ); if ( modFiles.isEmpty() ) { // No mods. Nothing else to do. observer.patchingProgress( progMax, progMax ); return true; } dataP = new FTLPack( dataDat.datFile, "r+" ); resP = new FTLPack( resDat.datFile, "r+" ); Map<String,AbstractPack> topFolderMap = new HashMap<String,AbstractPack>(); topFolderMap.put( "data", dataP ); topFolderMap.put( "audio", resP ); topFolderMap.put( "fonts", resP ); topFolderMap.put( "img", resP ); topFolderMap.put( "mod-appendix", null ); // Track modified innerPaths in case they're clobbered. List<String> moddedItems = new ArrayList<String>(); List<String> knownPaths = new ArrayList<String>(); knownPaths.addAll( dataP.list() ); knownPaths.addAll( resP.list() ); List<String> knownPathsLower = new ArrayList<String>( knownPaths.size() ); for ( String innerPath : knownPaths ) { knownPathsLower.add( innerPath.toLowerCase() ); } // Group1: parentPath, Group2: topFolder, Group3: fileName Pattern pathPtn = Pattern.compile( "^(([^/]+)/(?:.*/)?)([^/]+)$" ); for ( File modFile : modFiles ) { if ( !keepRunning ) return false; FileInputStream fis = null; ZipInputStream zis = null; try { log.info( "" ); log.info( String.format( "Installing mod: %s", modFile.getName() ) ); observer.patchingMod( modFile ); fis = new FileInputStream( modFile ); zis = new ZipInputStream( new BufferedInputStream( fis ) ); ZipEntry item; while ( (item = zis.getNextEntry()) != null ) { if ( item.isDirectory() ) { zis.closeEntry(); continue; } String innerPath = item.getName(); innerPath = innerPath.replace( '\\', '/' ); // Non-standard zips. Matcher m = pathPtn.matcher( innerPath ); if ( !m.matches() ) { log.warn( String.format( "Unexpected innerPath: %s", innerPath ) ); zis.closeEntry(); continue; } String parentPath = m.group(1); String topFolder = m.group(2); String fileName = m.group(3); AbstractPack ftlP = topFolderMap.get( topFolder ); if ( ftlP == null ) { if ( !topFolderMap.containsKey( topFolder ) ) log.warn( String.format( "Unexpected innerPath: %s", innerPath ) ); zis.closeEntry(); continue; } if ( ModUtilities.isJunkFile( innerPath ) ) { log.warn( String.format( "Skipping junk file: %s", innerPath ) ); zis.closeEntry(); continue; } if ( fileName.endsWith( ".xml.append" ) || fileName.endsWith( ".append.xml" ) ) { innerPath = parentPath + fileName.replaceAll( "[.](?:xml[.]append|append[.]xml)$", ".xml" ); innerPath = checkCase( innerPath, knownPaths, knownPathsLower ); if ( !ftlP.contains( innerPath ) ) { log.warn( String.format( "Non-existent innerPath wasn't appended: %s", innerPath ) ); } else { InputStream mainStream = null; try { mainStream = ftlP.getInputStream(innerPath); InputStream mergedStream = ModUtilities.patchXMLFile( mainStream, zis, "windows-1252", globalPanic, ftlP.getName()+":"+innerPath, modFile.getName()+":"+parentPath+fileName ); mainStream.close(); ftlP.remove( innerPath ); ftlP.add( innerPath, mergedStream ); } finally { try {if ( mainStream != null ) mainStream.close();} catch ( IOException e ) {} } if ( !moddedItems.contains(innerPath) ) { moddedItems.add( innerPath ); } } } else if ( fileName.endsWith( ".xml.rawappend" ) || fileName.endsWith( ".rawappend.xml" ) ) { innerPath = parentPath + fileName.replaceAll( "[.](?:xml[.]rawappend|rawappend[.]xml)$", ".xml" ); innerPath = checkCase( innerPath, knownPaths, knownPathsLower ); if ( !ftlP.contains( innerPath ) ) { log.warn( String.format( "Non-existent innerPath wasn't raw appended: %s", innerPath ) ); } else { log.warn( String.format( "Appending xml as raw text: %s", innerPath ) ); InputStream mainStream = null; try { mainStream = ftlP.getInputStream(innerPath); InputStream mergedStream = ModUtilities.appendXMLFile( mainStream, zis, "windows-1252", ftlP.getName()+":"+innerPath, modFile.getName()+":"+parentPath+fileName ); mainStream.close(); ftlP.remove( innerPath ); ftlP.add( innerPath, mergedStream ); } finally { try {if ( mainStream != null ) mainStream.close();} catch ( IOException e ) {} } if ( !moddedItems.contains(innerPath) ) { moddedItems.add( innerPath ); } } } else if ( fileName.endsWith( ".xml.rawclobber" ) || fileName.endsWith( ".rawclobber.xml" ) ) { innerPath = parentPath + fileName.replaceAll( "[.](?:xml[.]rawclobber|rawclobber[.]xml)$", ".xml" ); innerPath = checkCase( innerPath, knownPaths, knownPathsLower ); log.warn( String.format( "Copying xml as raw text: %s", innerPath ) ); // Normalize line endings to CR-LF. // decodeText() reads anything and returns an LF string. String fixedText = ModUtilities.decodeText( zis, modFile.getName()+":"+parentPath+fileName ).text; fixedText = Pattern.compile("\n").matcher( fixedText ).replaceAll( "\r\n" ); InputStream fixedStream = ModUtilities.encodeText( fixedText, "windows-1252", modFile.getName()+":"+parentPath+fileName+" (with new EOL)" ); if ( !moddedItems.contains(innerPath) ) { moddedItems.add( innerPath ); } else { log.warn( String.format( "Clobbering earlier mods: %s", innerPath ) ); } if ( ftlP.contains( innerPath ) ) ftlP.remove( innerPath ); ftlP.add( innerPath, fixedStream ); } else if ( fileName.endsWith( ".xml" ) ) { innerPath = checkCase( innerPath, knownPaths, knownPathsLower ); InputStream fixedStream = ModUtilities.rebuildXMLFile( zis, "windows-1252", modFile.getName()+":"+parentPath+fileName ); if ( !moddedItems.contains(innerPath) ) { moddedItems.add( innerPath ); } else { log.warn( String.format( "Clobbering earlier mods: %s", innerPath ) ); } if ( ftlP.contains( innerPath ) ) ftlP.remove( innerPath ); ftlP.add( innerPath, fixedStream ); } else if ( fileName.endsWith( ".txt" ) ) { innerPath = checkCase( innerPath, knownPaths, knownPathsLower ); // Normalize line endings for other text files to CR-LF. // decodeText() reads anything and returns an LF string. String fixedText = ModUtilities.decodeText( zis, modFile.getName()+":"+parentPath+fileName ).text; fixedText = Pattern.compile("\n").matcher( fixedText ).replaceAll( "\r\n" ); InputStream fixedStream = ModUtilities.encodeText( fixedText, "windows-1252", modFile.getName()+":"+parentPath+fileName+" (with new EOL)" ); if ( !moddedItems.contains(innerPath) ) { moddedItems.add( innerPath ); } else { log.warn( String.format( "Clobbering earlier mods: %s", innerPath ) ); } if ( ftlP.contains( innerPath ) ) ftlP.remove( innerPath ); ftlP.add( innerPath, fixedStream ); } else { innerPath = checkCase( innerPath, knownPaths, knownPathsLower ); if ( !moddedItems.contains(innerPath) ) { moddedItems.add( innerPath ); } else { log.warn( String.format( "Clobbering earlier mods: %s", innerPath ) ); } if ( ftlP.contains( innerPath ) ) ftlP.remove( innerPath ); ftlP.add( innerPath, zis ); } zis.closeEntry(); } } finally { try {if ( zis != null ) zis.close();} catch ( Exception e ) {} try {if ( fis != null ) fis.close();} catch ( Exception e ) {} System.gc(); } modsInstalled++; observer.patchingProgress( progMilestone + progModsMax/modFiles.size()*modsInstalled, progMax ); } progMilestone += progModsMax; observer.patchingProgress( progMilestone, progMax ); // Prune 'removed' files from dats. for ( AbstractPack ftlP : new AbstractPack[]{dataP,resP} ) { if ( ftlP instanceof FTLPack ) { observer.patchingStatus( String.format( "Repacking \"%s\"...", ftlP.getName() ) ); long bytesChanged = ((FTLPack)ftlP).repack().bytesChanged; log.info( String.format( "Repacked \"%s\" (%d bytes affected)", ftlP.getName(), bytesChanged ) ); datsRepacked++; observer.patchingProgress( progMilestone + progRepackMax/allDats.length*datsRepacked, progMax ); } } progMilestone += progRepackMax; observer.patchingProgress( progMilestone, progMax ); observer.patchingProgress( 100, progMax ); return true; } finally { try {if ( dataP != null ) dataP.close();} catch( Exception e ) {} try {if ( resP != null ) resP.close();} catch( Exception e ) {} } } /** * Checks if an innerPath exists, ignoring letter case. * * If there is no collision, the innerPath is added to the known lists. * A warning will be logged if a path with differing case exists. * * @param knownPaths a list of innerPaths seen so far * @param knownPathsLower a copy of knownPaths, lower-cased * @return the existing path (if different), or innerPath */ private String checkCase( String innerPath, List<String> knownPaths, List<String> knownPathsLower ) { if ( knownPaths.contains( innerPath ) ) return innerPath; String lowerPath = innerPath.toLowerCase(); int lowerIndex = knownPathsLower.indexOf( lowerPath ); if ( lowerIndex != -1 ) { String knownPath = knownPaths.get( lowerIndex ); log.warn( String.format( "Modded file's case doesn't match existing path: \"%s\" vs \"%s\"", innerPath, knownPath ) ); return knownPath; } knownPaths.add( innerPath ); knownPathsLower.add( lowerPath ); return innerPath; } public static class BackedUpDat { public File datFile = null; public File bakFile = null; } }