package com.faforever.client.replay; import com.faforever.client.fx.PlatformService; import com.faforever.client.game.GameInfoBean; import com.faforever.client.game.GameService; import com.faforever.client.game.GameType; import com.faforever.client.i18n.I18n; import com.faforever.client.notification.Action; import com.faforever.client.notification.DismissAction; import com.faforever.client.notification.ImmediateNotification; import com.faforever.client.notification.NotificationService; import com.faforever.client.notification.PersistentNotification; import com.faforever.client.notification.ReportAction; import com.faforever.client.notification.Severity; import com.faforever.client.preferences.PreferencesService; import com.faforever.client.reporting.ReportingService; import com.faforever.client.task.TaskService; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Splitter; import com.google.common.net.UrlEscapers; import com.google.common.primitives.Bytes; import org.apache.http.client.utils.URIBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationContext; import org.springframework.core.env.Environment; import javax.annotation.Resource; import java.io.ByteArrayInputStream; import java.io.IOException; import java.lang.invoke.MethodHandles; import java.net.URI; import java.net.URISyntaxException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletionStage; import static com.faforever.client.notification.Severity.WARN; import static java.nio.charset.StandardCharsets.US_ASCII; import static java.util.Arrays.asList; import static java.util.Collections.emptyMap; import static java.util.Collections.emptySet; import static java.util.Collections.singletonList; public class ReplayServiceImpl implements ReplayService { private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); /** * Byte offset at which a SupCom replay's version number starts. */ private static final int VERSION_OFFSET = 0x18; private static final int MAP_NAME_OFFSET = 0x2D; private static final String FAF_REPLAY_FILE_ENDING = ".fafreplay"; private static final String SUP_COM_REPLAY_FILE_ENDING = ".scfareplay"; private static final String FAF_LIFE_PROTOCOL = "faflive"; private static final String GPGNET_SCHEME = "gpgnet"; private static final String TEMP_SCFA_REPLAY_FILE_NAME = "temp.scfareplay"; @Resource Environment environment; @Resource PreferencesService preferencesService; @Resource ReplayFileReader replayFileReader; @Resource NotificationService notificationService; @Resource GameService gameService; @Resource TaskService taskService; @Resource I18n i18n; @Resource ReportingService reportingService; @Resource ReplayServerAccessor replayServerAccessor; @Resource ApplicationContext applicationContext; @Resource PlatformService platformService; @Resource ReplayServer replayServer; @VisibleForTesting static Integer parseSupComVersion(byte[] rawReplayBytes) { int versionDelimiterIndex = Bytes.indexOf(rawReplayBytes, (byte) 0x00); return Integer.parseInt(new String(rawReplayBytes, VERSION_OFFSET, versionDelimiterIndex - VERSION_OFFSET, US_ASCII)); } @VisibleForTesting static String parseMapName(byte[] rawReplayBytes) { int mapDelimiterIndex = Bytes.indexOf(rawReplayBytes, new byte[]{0x00, 0x0D, 0x0A, 0x1A}); String mapPath = new String(rawReplayBytes, MAP_NAME_OFFSET, mapDelimiterIndex - MAP_NAME_OFFSET, US_ASCII); return mapPath.split("/")[2]; } @VisibleForTesting static String guessModByFileName(String fileName) { String[] splitFileName = fileName.split("\\."); if (splitFileName.length > 2) { return splitFileName[splitFileName.length - 2]; } return GameType.DEFAULT.getString(); } @Override public Collection<ReplayInfoBean> getLocalReplays() throws IOException { Collection<ReplayInfoBean> replayInfos = new ArrayList<>(); String replayFileGlob = environment.getProperty("replayFileGlob"); Path replaysDirectory = preferencesService.getReplaysDirectory(); if (!Files.notExists(replaysDirectory)) { Files.createDirectories(replaysDirectory); } try (DirectoryStream<Path> directoryStream = Files.newDirectoryStream(replaysDirectory, replayFileGlob)) { for (Path replayFile : directoryStream) { try { LocalReplayInfo replayInfo = replayFileReader.readReplayInfo(replayFile); replayInfos.add(new ReplayInfoBean(replayInfo, replayFile)); } catch (Exception e) { logger.warn("Could not read replay file {} ({})", replayFile, e.getMessage()); moveCorruptedReplayFile(replayFile); } } } return replayInfos; } private void moveCorruptedReplayFile(Path replayFile) throws IOException { Path corruptedReplaysDirectory = preferencesService.getCorruptedReplaysDirectory(); Files.createDirectories(corruptedReplaysDirectory); Path target = corruptedReplaysDirectory.resolve(replayFile.getFileName()); logger.debug("Moving corrupted replay file from {} to {}", replayFile, target); Files.move(replayFile, target); notificationService.addNotification(new PersistentNotification( i18n.get("corruptedReplayFiles.notification"), WARN, singletonList( new Action(i18n.get("corruptedReplayFiles.show"), event -> platformService.reveal(replayFile)) ) )); } @Override public CompletionStage<List<ReplayInfoBean>> getOnlineReplays() { return replayServerAccessor.requestOnlineReplays(); } @Override public void runReplay(ReplayInfoBean item) { if (item.getReplayFile() != null) { runReplayFile(item.getReplayFile()); } else { runOnlineReplay(item.getId()); } } @Override public void runLiveReplay(int gameId, int playerId) throws IOException { GameInfoBean gameInfoBean = gameService.getByUid(gameId); if (gameInfoBean == null) { throw new RuntimeException("There's no game with ID: " + gameId); } URIBuilder uriBuilder = new URIBuilder(); uriBuilder.setScheme(FAF_LIFE_PROTOCOL); uriBuilder.setHost(environment.getProperty("lobby.host")); uriBuilder.setPath("/" + gameId + "/" + playerId + SUP_COM_REPLAY_FILE_ENDING); uriBuilder.addParameter("map", UrlEscapers.urlFragmentEscaper().escape(gameInfoBean.getMapFolderName())); uriBuilder.addParameter("mod", gameInfoBean.getFeaturedMod()); try { runLiveReplay(uriBuilder.build()); } catch (URISyntaxException e) { throw new RuntimeException(e); } } @Override public void runLiveReplay(URI uri) throws IOException { logger.debug("Running replay from URL: {}", uri); if (!uri.getScheme().equals(FAF_LIFE_PROTOCOL)) { throw new IllegalArgumentException("Invalid protocol: " + uri.getScheme()); } Map<String, String> queryParams = Splitter.on('&').trimResults().withKeyValueSeparator("=").split(uri.getQuery()); String gameType = queryParams.get("mod"); String mapName = URLDecoder.decode(queryParams.get("map"), StandardCharsets.UTF_8.name()); Integer gameId = Integer.parseInt(uri.getPath().split("/")[1]); try { URI replayUri = new URI(GPGNET_SCHEME, null, uri.getHost(), uri.getPort(), uri.getPath(), null, null); gameService.runWithLiveReplay(replayUri, gameId, gameType, mapName) .exceptionally(throwable -> { notificationService.addNotification(new ImmediateNotification( i18n.get("errorTitle"), i18n.get("liveReplayCouldNotBeStarted"), Severity.ERROR, throwable, asList(new DismissAction(i18n), new ReportAction(i18n, reportingService, throwable)) )); return null; }); } catch (URISyntaxException e) { throw new RuntimeException(e); } } @Override public void startReplayServer(int gameUid) { replayServer.start(gameUid); } @Override public void stopReplayServer() { replayServer.stop(); } private void runReplayFile(Path path) { try { String fileName = path.getFileName().toString(); if (fileName.endsWith(FAF_REPLAY_FILE_ENDING)) { runFafReplayFile(path); } else if (fileName.endsWith(SUP_COM_REPLAY_FILE_ENDING)) { runSupComReplayFile(path); } } catch (IOException e) { logger.warn("Replay could not be started", e); notificationService.addNotification(new ImmediateNotification( i18n.get("errorTitle"), i18n.get("replayCouldNotBeStarted", path.getFileName()), WARN, e, singletonList(new ReportAction(i18n, reportingService, e)) )); } } private void runOnlineReplay(int replayId) { downloadReplayToTemporaryDirectory(replayId) .thenAccept(this::runReplayFile) .exceptionally(throwable -> { notificationService.addNotification(new ImmediateNotification( i18n.get("errorTitle"), i18n.get("replayCouldNotBeDownloaded", replayId), Severity.ERROR, throwable, singletonList(new ReportAction(i18n, reportingService, throwable))) ); return null; }); } private void runFafReplayFile(Path path) throws IOException { byte[] rawReplayBytes = replayFileReader.readReplayData(path); Path tempSupComReplayFile = preferencesService.getCacheDirectory().resolve(TEMP_SCFA_REPLAY_FILE_NAME); Files.createDirectories(tempSupComReplayFile.getParent()); Files.copy(new ByteArrayInputStream(rawReplayBytes), tempSupComReplayFile, StandardCopyOption.REPLACE_EXISTING); LocalReplayInfo replayInfo = replayFileReader.readReplayInfo(path); String gameType = replayInfo.getFeaturedMod(); Integer replayId = replayInfo.getUid(); Map<String, Integer> modVersions = replayInfo.getFeaturedModVersions(); String mapName = replayInfo.getMapname(); Set<String> simMods = replayInfo.getSimMods() != null ? replayInfo.getSimMods().keySet() : emptySet(); Integer version = parseSupComVersion(rawReplayBytes); gameService.runWithReplay(tempSupComReplayFile, replayId, gameType, version, modVersions, simMods, mapName); } private void runSupComReplayFile(Path path) { byte[] rawReplayBytes = replayFileReader.readReplayData(path); Integer version = parseSupComVersion(rawReplayBytes); String mapName = parseMapName(rawReplayBytes); String fileName = path.getFileName().toString(); String gameType = guessModByFileName(fileName); gameService.runWithReplay(path, null, gameType, version, emptyMap(), emptySet(), mapName); } private CompletionStage<Path> downloadReplayToTemporaryDirectory(int replayId) { ReplayDownloadTask task = applicationContext.getBean(ReplayDownloadTask.class); task.setReplayId(replayId); return taskService.submitTask(task).getFuture(); } }