/* * opsu! - an open-source osu! client * Copyright (C) 2014, 2015 Jeffrey Han * * opsu! is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * opsu! is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with opsu!. If not, see . */ package itdelatrisu.opsu.replay; import itdelatrisu.opsu.ErrorHandler; import itdelatrisu.opsu.Options; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.io.OsuReader; import itdelatrisu.opsu.io.OsuWriter; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.nio.CharBuffer; import java.nio.charset.CharsetEncoder; import java.nio.charset.StandardCharsets; import java.text.DecimalFormat; import java.text.NumberFormat; import java.util.ArrayList; import java.util.Date; import java.util.List; import lzma.streams.LzmaOutputStream; import org.apache.commons.compress.compressors.lzma.LZMACompressorInputStream; import org.newdawn.slick.util.Log; /** * Captures osu! replay data. * https://osu.ppy.sh/wiki/Osr_%28file_format%29 * * @author smoogipooo (https://github.com/smoogipooo/osu-Replay-API/) */ public class Replay { /** The associated file. */ private File file; /** Whether or not the replay data has been loaded from the file. */ public boolean loaded = false; /** The game mode. */ public byte mode; /** Game version when the replay was created. */ public int version; /** Beatmap MD5 hash. */ public String beatmapHash; /** The player's name. */ public String playerName; /** Replay MD5 hash. */ public String replayHash; /** Hit result counts. */ public short hit300, hit100, hit50, geki, katu, miss; /** The score. */ public int score; /** The max combo. */ public short combo; /** Whether or not a full combo was achieved. */ public boolean perfect; /** Game mod bitmask. */ public int mods; /** Life frames. */ public LifeFrame[] lifeFrames; /** The time when the replay was created. */ public Date timestamp; /** Length of the replay data. */ public int replayLength; /** Replay frames. */ public ReplayFrame[] frames; /** Seed. (?) */ public int seed; /** Seed string. */ private static final String SEED_STRING = "-12345"; /** * Empty constructor. */ public Replay() {} /** * Constructor. * @param file the file to load from */ public Replay(File file) { this.file = file; } /** * Loads the replay data. * @throws IOException failure to load the data */ public void load() throws IOException { if (loaded) return; OsuReader reader = new OsuReader(file); loadHeader(reader); loadData(reader); reader.close(); loaded = true; } /** * Loads the replay header data. * @param reader the associated reader * @throws IOException */ private void loadHeader(OsuReader reader) throws IOException { this.mode = reader.readByte(); this.version = reader.readInt(); this.beatmapHash = reader.readString(); this.playerName = reader.readString(); this.replayHash = reader.readString(); this.hit300 = reader.readShort(); this.hit100 = reader.readShort(); this.hit50 = reader.readShort(); this.geki = reader.readShort(); this.katu = reader.readShort(); this.miss = reader.readShort(); this.score = reader.readInt(); this.combo = reader.readShort(); this.perfect = reader.readBoolean(); this.mods = reader.readInt(); } /** * Loads the replay data. * @param reader the associated reader * @throws IOException */ private void loadData(OsuReader reader) throws IOException { // life data String[] lifeData = reader.readString().split(","); List lifeFrameList = new ArrayList(lifeData.length); for (String frame : lifeData) { String[] tokens = frame.split("\\|"); if (tokens.length < 2) continue; try { int time = Integer.parseInt(tokens[0]); float percentage = Float.parseFloat(tokens[1]); lifeFrameList.add(new LifeFrame(time, percentage)); } catch (NumberFormatException e) { Log.warn(String.format("Failed to load life frame: '%s'", frame), e); } } this.lifeFrames = lifeFrameList.toArray(new LifeFrame[lifeFrameList.size()]); // timestamp this.timestamp = reader.readDate(); // LZMA-encoded replay data this.replayLength = reader.readInt(); if (replayLength > 0) { LZMACompressorInputStream lzma = new LZMACompressorInputStream(reader.getInputStream()); String[] replayFrames = Utils.convertStreamToString(lzma).split(","); lzma.close(); List replayFrameList = new ArrayList(replayFrames.length); int lastTime = 0; for (String frame : replayFrames) { if (frame.isEmpty()) continue; String[] tokens = frame.split("\\|"); if (tokens.length < 4) continue; try { if (tokens[0].equals(SEED_STRING)) { seed = Integer.parseInt(tokens[3]); continue; } int timeDiff = Integer.parseInt(tokens[0]); int time = timeDiff + lastTime; float x = Float.parseFloat(tokens[1]); float y = Float.parseFloat(tokens[2]); int keys = Integer.parseInt(tokens[3]); replayFrameList.add(new ReplayFrame(timeDiff, time, x, y, keys)); lastTime = time; } catch (NumberFormatException e) { Log.warn(String.format("Failed to parse frame: '%s'", frame), e); } } this.frames = replayFrameList.toArray(new ReplayFrame[replayFrameList.size()]); } } /** * Saves the replay data to a file in the replays directory. */ public void save() { // create replay directory File dir = Options.getReplayDir(); if (!dir.isDirectory()) { if (!dir.mkdir()) { ErrorHandler.error("Failed to create replay directory.", null, false); return; } } // write file in new thread final File file = new File(dir, String.format("%s.osr", getReplayFilename())); new Thread() { @Override public void run() { try (FileOutputStream out = new FileOutputStream(file)) { OsuWriter writer = new OsuWriter(out); // header writer.write(mode); writer.write(version); writer.write(beatmapHash); writer.write(playerName); writer.write(replayHash); writer.write(hit300); writer.write(hit100); writer.write(hit50); writer.write(geki); writer.write(katu); writer.write(miss); writer.write(score); writer.write(combo); writer.write(perfect); writer.write(mods); // life data StringBuilder sb = new StringBuilder(); if (lifeFrames != null) { NumberFormat nf = new DecimalFormat("##.##"); for (int i = 0; i < lifeFrames.length; i++) { LifeFrame frame = lifeFrames[i]; sb.append(String.format("%d|%s,", frame.getTime(), nf.format(frame.getPercentage()))); } } writer.write(sb.toString()); // timestamp writer.write(timestamp); // LZMA-encoded replay data if (frames != null && frames.length > 0) { // build full frame string NumberFormat nf = new DecimalFormat("###.#####"); sb = new StringBuilder(); for (int i = 0; i < frames.length; i++) { ReplayFrame frame = frames[i]; sb.append(String.format("%d|%s|%s|%d,", frame.getTimeDiff(), nf.format(frame.getX()), nf.format(frame.getY()), frame.getKeys())); } sb.append(String.format("%s|0|0|%d", SEED_STRING, seed)); // get bytes from string CharsetEncoder encoder = StandardCharsets.US_ASCII.newEncoder(); CharBuffer buffer = CharBuffer.wrap(sb); byte[] bytes = encoder.encode(buffer).array(); // compress data ByteArrayOutputStream bout = new ByteArrayOutputStream(); LzmaOutputStream compressedOut = new LzmaOutputStream.Builder(bout).useMediumDictionarySize().build(); try { compressedOut.write(bytes); } catch (IOException e) { // possible OOM: https://github.com/jponge/lzma-java/issues/9 ErrorHandler.error("LZMA compression failed (possible out-of-memory error).", e, true); } compressedOut.close(); bout.close(); // write to file byte[] compressed = bout.toByteArray(); writer.write(compressed.length); writer.write(compressed); } else writer.write(0); writer.close(); } catch (IOException e) { ErrorHandler.error("Could not save replay data.", e, true); } } }.start(); } /** * Returns the file name of where the replay should be saved and loaded, * or null if the required fields are not set. */ public String getReplayFilename() { if (replayHash == null) return null; return String.format("%s-%d%d%d%d%d%d", replayHash, hit300, hit100, hit50, geki, katu, miss); } @Override public String toString() { final int LINE_SPLIT = 5; final int MAX_LINES = LINE_SPLIT * 10; StringBuilder sb = new StringBuilder(); sb.append("File: "); sb.append(file.getName()); sb.append('\n'); sb.append("Mode: "); sb.append(mode); sb.append('\n'); sb.append("Version: "); sb.append(version); sb.append('\n'); sb.append("Beatmap hash: "); sb.append(beatmapHash); sb.append('\n'); sb.append("Player name: "); sb.append(playerName); sb.append('\n'); sb.append("Replay hash: "); sb.append(replayHash); sb.append('\n'); sb.append("Hits: "); sb.append(hit300); sb.append(' '); sb.append(hit100); sb.append(' '); sb.append(hit50); sb.append(' '); sb.append(geki); sb.append(' '); sb.append(katu); sb.append(' '); sb.append(miss); sb.append('\n'); sb.append("Score: "); sb.append(score); sb.append('\n'); sb.append("Max combo: "); sb.append(combo); sb.append('\n'); sb.append("Perfect: "); sb.append(perfect); sb.append('\n'); sb.append("Mods: "); sb.append(mods); sb.append('\n'); sb.append("Life data ("); sb.append(lifeFrames.length); sb.append(" total):\n"); for (int i = 0; i < lifeFrames.length && i < MAX_LINES; i++) { if (i % LINE_SPLIT == 0) sb.append('\t'); sb.append(lifeFrames[i]); sb.append((i % LINE_SPLIT == LINE_SPLIT - 1) ? '\n' : ' '); } sb.append('\n'); sb.append("Timestamp: "); sb.append(timestamp); sb.append('\n'); sb.append("Replay length: "); sb.append(replayLength); sb.append('\n'); if (frames != null) { sb.append("Frames ("); sb.append(frames.length); sb.append(" total):\n"); for (int i = 0; i < frames.length && i < MAX_LINES; i++) { if (i % LINE_SPLIT == 0) sb.append('\t'); sb.append(frames[i]); sb.append((i % LINE_SPLIT == LINE_SPLIT - 1) ? '\n' : ' '); } sb.append('\n'); } sb.append("Seed: "); sb.append(seed); sb.append('\n'); return sb.toString(); } }